Add public refund creation endpoint and PDF generation
Introduces RefundPublicView for public refund creation via email and invoice/order ID, returning refund info and a base64-encoded PDF slip. Adds RefundCreatePublicSerializer for validation and creation, implements PDF generation in Refund model, and provides a customer-facing HTML template for the return slip. Updates URLs to expose the new endpoint.
This commit is contained in:
@@ -505,6 +505,43 @@ class Refund(models.Model):
|
|||||||
self.order.status = Order.Status.REFUNDED
|
self.order.status = Order.Status.REFUNDED
|
||||||
self.order.save(update_fields=["status", "updated_at"])
|
self.order.save(update_fields=["status", "updated_at"])
|
||||||
|
|
||||||
|
def generate_refund_pdf_for_customer(self):
|
||||||
|
"""Vygeneruje PDF formulář k vrácení zboží pro zákazníka.
|
||||||
|
|
||||||
|
Šablona refund/customer_in_package_returning_form.html očekává:
|
||||||
|
- order: objekt objednávky
|
||||||
|
- items: seznam položek (dict) s klíči product_name, sku, quantity, variant, options, reason
|
||||||
|
- return_reason: textový důvod vrácení (kombinace reason_text / reason_choice)
|
||||||
|
|
||||||
|
Návratová hodnota: bytes (PDF obsah). Uložení necháváme na volající logice.
|
||||||
|
"""
|
||||||
|
order = self.order
|
||||||
|
|
||||||
|
# Připravíme položky pro šablonu (důvody per položku zatím None – lze rozšířit)
|
||||||
|
prepared_items: list[dict] = []
|
||||||
|
for item in order.items.select_related('product'):
|
||||||
|
prepared_items.append({
|
||||||
|
"product_name": getattr(item.product, "name", "Item"),
|
||||||
|
"name": getattr(item.product, "name", "Item"), # fallbacky pro různé názvy v šabloně
|
||||||
|
"sku": getattr(item.product, "code", None),
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"variant": None, # lze doplnit pokud existují varianty
|
||||||
|
"options": None, # lze doplnit pokud existují volby
|
||||||
|
"reason": None, # per-item reason (zatím nepodporováno)
|
||||||
|
})
|
||||||
|
|
||||||
|
return_reason = self.reason_text or self.get_reason_choice_display()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"order": order,
|
||||||
|
"items": prepared_items,
|
||||||
|
"return_reason": return_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
html_string = render_to_string("refund/customer_in_package_returning_form.html", context)
|
||||||
|
pdf_bytes = HTML(string=html_string).write_pdf()
|
||||||
|
return pdf_bytes
|
||||||
|
|
||||||
|
|
||||||
class Invoice(models.Model):
|
class Invoice(models.Model):
|
||||||
invoice_number = models.CharField(max_length=50, unique=True)
|
invoice_number = models.CharField(max_length=50, unique=True)
|
||||||
|
|||||||
@@ -1,4 +1,71 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .models import Refund, Order, Invoice
|
||||||
|
|
||||||
|
|
||||||
|
class RefundCreatePublicSerializer(serializers.Serializer):
|
||||||
|
email = serializers.EmailField()
|
||||||
|
invoice_number = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
order_id = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
# Optional reason fields
|
||||||
|
reason_choice = serializers.ChoiceField(
|
||||||
|
choices=Refund.Reason.choices, required=False
|
||||||
|
)
|
||||||
|
reason_text = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
email = attrs.get("email")
|
||||||
|
invoice_number = (attrs.get("invoice_number") or "").strip()
|
||||||
|
order_id = attrs.get("order_id")
|
||||||
|
|
||||||
|
if not invoice_number and not order_id:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Provide either invoice_number or order_id."
|
||||||
|
)
|
||||||
|
|
||||||
|
order = None
|
||||||
|
if invoice_number:
|
||||||
|
try:
|
||||||
|
invoice = Invoice.objects.get(invoice_number=invoice_number)
|
||||||
|
order = invoice.order
|
||||||
|
except Invoice.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({"invoice_number": "Invoice not found."})
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({"invoice_number": "Order for invoice not found."})
|
||||||
|
|
||||||
|
if order_id and order is None:
|
||||||
|
try:
|
||||||
|
order = Order.objects.get(id=order_id)
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({"order_id": "Order not found."})
|
||||||
|
|
||||||
|
# Verify email matches order's email or user's email
|
||||||
|
if not order:
|
||||||
|
raise serializers.ValidationError("Order could not be resolved.")
|
||||||
|
|
||||||
|
order_email = (order.email or "").strip().lower()
|
||||||
|
user_email = (getattr(order.user, "email", "") or "").strip().lower()
|
||||||
|
provided = email.strip().lower()
|
||||||
|
if provided not in {order_email, user_email}:
|
||||||
|
raise serializers.ValidationError({"email": "Email does not match the order."})
|
||||||
|
|
||||||
|
attrs["order"] = order
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
order = validated_data["order"]
|
||||||
|
reason_choice = validated_data.get("reason_choice") or Refund.Reason.OTHER
|
||||||
|
reason_text = validated_data.get("reason_text", "")
|
||||||
|
|
||||||
|
refund = Refund.objects.create(
|
||||||
|
order=order,
|
||||||
|
reason_choice=reason_choice,
|
||||||
|
reason_text=reason_text,
|
||||||
|
)
|
||||||
|
return refund
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Return/Refund Slip – Order {{ order.number|default:order.code|default:order.id }}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<style>
|
||||||
|
:root { --fg:#111; --muted:#666; --border:#ddd; --accent:#0f172a; --bg:#fff; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg); font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Arial; }
|
||||||
|
.sheet { max-width: 800px; margin: 24px auto; padding: 24px; border:1px solid var(--border); border-radius: 8px; }
|
||||||
|
header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px; }
|
||||||
|
.title { font-size:20px; font-weight:700; letter-spacing:.2px; }
|
||||||
|
.sub { color:var(--muted); font-size:12px; }
|
||||||
|
.meta { display:grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; padding:12px; border:1px solid var(--border); border-radius:8px; margin-bottom:16px; }
|
||||||
|
.meta div { display:flex; gap:8px; }
|
||||||
|
.label { width:140px; color:var(--muted); }
|
||||||
|
table { width:100%; border-collapse: collapse; margin: 12px 0 4px; }
|
||||||
|
th, td { border:1px solid var(--border); padding:8px; vertical-align: top; }
|
||||||
|
th { text-align:left; background:#f8fafc; font-weight:600; }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
.section { margin-top:18px; }
|
||||||
|
.section h3 { margin:0 0 8px; font-size:14px; text-transform:uppercase; letter-spacing:.4px; color:var(--accent); }
|
||||||
|
.textarea { border:1px solid var(--border); border-radius:8px; min-height:90px; padding:10px; white-space:pre-wrap; }
|
||||||
|
.grid-2 { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||||
|
.line { height:1px; background:var(--border); margin: 8px 0; }
|
||||||
|
.sign { height:48px; border-bottom:1px solid var(--border); }
|
||||||
|
.print-tip { color:var(--muted); font-size:12px; margin-top:8px; }
|
||||||
|
.print-btn { display:inline-block; padding:8px 12px; border:1px solid var(--border); border-radius:6px; background:#f8fafc; cursor:pointer; font-size:13px; }
|
||||||
|
@media print {
|
||||||
|
.sheet { border:none; border-radius:0; margin:0; padding:0; }
|
||||||
|
.print-btn, .print-tip { display:none !important; }
|
||||||
|
body { font-size:12px; }
|
||||||
|
th, td { padding:6px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sheet">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<div class="title">Return / Refund Slip</div>
|
||||||
|
<div class="sub">Include this page inside the package for the shopkeeper to examine the return.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="print-btn" onclick="window.print()">Print</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<div><div class="label">Order number</div><div><strong>{{ order.number|default:order.code|default:order.id }}</strong></div></div>
|
||||||
|
<div><div class="label">Order date</div><div>{% if order.created_at %}{{ order.created_at|date:"Y-m-d H:i" }}{% else %}{% now "Y-m-d" %}{% endif %}</div></div>
|
||||||
|
<div><div class="label">Customer name</div><div>{{ order.customer_name|default:order.user.get_full_name|default:order.user.username|default:"" }}</div></div>
|
||||||
|
<div><div class="label">Customer email</div><div>{{ order.customer_email|default:order.user.email|default:"" }}</div></div>
|
||||||
|
<div><div class="label">Phone</div><div>{{ order.customer_phone|default:"" }}</div></div>
|
||||||
|
<div><div class="label">Return created</div><div>{% now "Y-m-d H:i" %}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Returned items</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:44%">Item</th>
|
||||||
|
<th style="width:16%">SKU</th>
|
||||||
|
<th style="width:10%">Qty</th>
|
||||||
|
<th style="width:30%">Reason (per item)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div><strong>{{ it.product_name|default:it.product.title|default:it.name|default:"Item" }}</strong></div>
|
||||||
|
{% if it.variant or it.options %}
|
||||||
|
<div class="muted" style="font-size:12px;">
|
||||||
|
{% if it.variant %}Variant: {{ it.variant }}{% endif %}
|
||||||
|
{% if it.options %}{% if it.variant %} • {% endif %}Options: {{ it.options }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ it.sku|default:"—" }}</td>
|
||||||
|
<td>{{ it.quantity|default:1 }}</td>
|
||||||
|
<td>{% if it.reason %}{{ it.reason }}{% else %} {% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="muted">No items listed.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="print-tip">Tip: If the reason differs per item, write it in the last column above.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Return reason (customer)</h3>
|
||||||
|
<div class="textarea">
|
||||||
|
{% if return_reason %}{{ return_reason }}{% else %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Shopkeeper inspection</h3>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div>
|
||||||
|
<div class="row">
|
||||||
|
<strong>Package condition:</strong>
|
||||||
|
[ ] Intact
|
||||||
|
[ ] Opened
|
||||||
|
[ ] Damaged
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:6px;">
|
||||||
|
<strong>Items condition:</strong>
|
||||||
|
[ ] New
|
||||||
|
[ ] Light wear
|
||||||
|
[ ] Used
|
||||||
|
[ ] Damaged
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="row">
|
||||||
|
<strong>Resolution:</strong>
|
||||||
|
[ ] Accept refund
|
||||||
|
[ ] Deny
|
||||||
|
[ ] Exchange
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:6px;">
|
||||||
|
<strong>Restocking fee:</strong> ________ %
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top:12px;">
|
||||||
|
<div class="row"><strong>Notes:</strong></div>
|
||||||
|
<div class="textarea" style="min-height:70px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2" style="margin-top:16px;">
|
||||||
|
<div>
|
||||||
|
<div class="muted" style="font-size:12px;">Processed by (name/signature)</div>
|
||||||
|
<div class="sign"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="muted" style="font-size:12px;">Date</div>
|
||||||
|
<div class="sign"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="line"></div>
|
||||||
|
<div class="muted" style="font-size:12px; margin-top:8px;">
|
||||||
|
Attach this slip inside the package. Keep a copy for your records.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -7,6 +7,7 @@ from .views import (
|
|||||||
ProductImageViewSet,
|
ProductImageViewSet,
|
||||||
DiscountCodeViewSet,
|
DiscountCodeViewSet,
|
||||||
RefundViewSet,
|
RefundViewSet,
|
||||||
|
RefundPublicView,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -19,6 +20,7 @@ router.register(r'refunds', RefundViewSet, basename='refund')
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
|
path('refunds/public/', RefundPublicView.as_view(), name='RefundPublicView'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# NOTE: Other endpoints (categories/products/discounts) can be added later
|
# NOTE: Other endpoints (categories/products/discounts) can be added later
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from .models import Refund
|
||||||
|
from .serializers import RefundCreatePublicSerializer
|
||||||
|
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from rest_framework import viewsets, mixins, status
|
from rest_framework import viewsets, mixins, status
|
||||||
from rest_framework.permissions import AllowAny, IsAdminUser, SAFE_METHODS
|
from rest_framework.permissions import AllowAny, IsAdminUser, SAFE_METHODS
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiExample
|
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiExample
|
||||||
|
from rest_framework import filters, permissions
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Order,
|
Order,
|
||||||
@@ -21,6 +32,7 @@ from .serializers import (
|
|||||||
OrderMiniSerializer,
|
OrderMiniSerializer,
|
||||||
OrderCreateSerializer,
|
OrderCreateSerializer,
|
||||||
OrderItemReadSerializer,
|
OrderItemReadSerializer,
|
||||||
|
|
||||||
CarrierReadSerializer,
|
CarrierReadSerializer,
|
||||||
PaymentReadSerializer,
|
PaymentReadSerializer,
|
||||||
ProductSerializer,
|
ProductSerializer,
|
||||||
@@ -301,82 +313,182 @@ class AdminOnlyForPatchOtherwisePublic(AllowAny.__class__):
|
|||||||
|
|
||||||
# ---------- Public/admin viewsets ----------
|
# ---------- Public/admin viewsets ----------
|
||||||
|
|
||||||
# -- Product --
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
list=extend_schema(tags=["Products"], summary="List products (public)"),
|
list=extend_schema(tags=["Products"], summary="List products (public)"),
|
||||||
retrieve=extend_schema(tags=["Products"], summary="Retrieve product (public)"),
|
retrieve=extend_schema(tags=["Products"], summary="Retrieve product (public)"),
|
||||||
create=extend_schema(tags=["Products"], summary="Create product (admin)"),
|
create=extend_schema(tags=["Products"], summary="Create product (auth required)"),
|
||||||
partial_update=extend_schema(tags=["Products"], summary="Update product (admin)"),
|
partial_update=extend_schema(tags=["Products"], summary="Update product (auth required)"),
|
||||||
update=extend_schema(tags=["Products"], summary="Replace product (admin)"),
|
update=extend_schema(tags=["Products"], summary="Replace product (auth required)"),
|
||||||
destroy=extend_schema(tags=["Products"], summary="Delete product (admin)"),
|
destroy=extend_schema(tags=["Products"], summary="Delete product (auth required)"),
|
||||||
)
|
)
|
||||||
class ProductViewSet(viewsets.ModelViewSet):
|
class ProductViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Product.objects.all().order_by("-created_at")
|
queryset = Product.objects.all()
|
||||||
serializer_class = ProductSerializer
|
serializer_class = ProductSerializer
|
||||||
permission_classes = [AdminWriteOnlyOrReadOnly]
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
search_fields = ["name", "code"]
|
||||||
|
ordering_fields = ["price", "name", "created_at"]
|
||||||
|
ordering = ["price"]
|
||||||
|
|
||||||
|
|
||||||
# -- Category --
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
list=extend_schema(tags=["Categories"], summary="List categories (public)"),
|
list=extend_schema(tags=["Categories"], summary="List categories (public)"),
|
||||||
retrieve=extend_schema(tags=["Categories"], summary="Retrieve category (public)"),
|
retrieve=extend_schema(tags=["Categories"], summary="Retrieve category (public)"),
|
||||||
create=extend_schema(tags=["Categories"], summary="Create category (admin)"),
|
create=extend_schema(tags=["Categories"], summary="Create category (auth required)"),
|
||||||
partial_update=extend_schema(tags=["Categories"], summary="Update category (admin)"),
|
partial_update=extend_schema(tags=["Categories"], summary="Update category (auth required)"),
|
||||||
update=extend_schema(tags=["Categories"], summary="Replace category (admin)"),
|
update=extend_schema(tags=["Categories"], summary="Replace category (auth required)"),
|
||||||
destroy=extend_schema(tags=["Categories"], summary="Delete category (admin)"),
|
destroy=extend_schema(tags=["Categories"], summary="Delete category (auth required)"),
|
||||||
)
|
)
|
||||||
class CategoryViewSet(viewsets.ModelViewSet):
|
class CategoryViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Category.objects.all().order_by("name")
|
queryset = Category.objects.all()
|
||||||
serializer_class = CategorySerializer
|
serializer_class = CategorySerializer
|
||||||
permission_classes = [AdminWriteOnlyOrReadOnly]
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
search_fields = ["name", "description"]
|
||||||
|
ordering_fields = ["name", "id"]
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
# -- Product Image --
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
list=extend_schema(tags=["Product Images"], summary="List product images (public)"),
|
list=extend_schema(tags=["Product Images"], summary="List product images (public)"),
|
||||||
retrieve=extend_schema(tags=["Product Images"], summary="Retrieve product image (public)"),
|
retrieve=extend_schema(tags=["Product Images"], summary="Retrieve product image (public)"),
|
||||||
create=extend_schema(tags=["Product Images"], summary="Create product image (admin)"),
|
create=extend_schema(tags=["Product Images"], summary="Create product image (auth required)"),
|
||||||
partial_update=extend_schema(tags=["Product Images"], summary="Update product image (admin)"),
|
partial_update=extend_schema(tags=["Product Images"], summary="Update product image (auth required)"),
|
||||||
update=extend_schema(tags=["Product Images"], summary="Replace product image (admin)"),
|
update=extend_schema(tags=["Product Images"], summary="Replace product image (auth required)"),
|
||||||
destroy=extend_schema(tags=["Product Images"], summary="Delete product image (admin)"),
|
destroy=extend_schema(tags=["Product Images"], summary="Delete product image (auth required)"),
|
||||||
)
|
)
|
||||||
class ProductImageViewSet(viewsets.ModelViewSet):
|
class ProductImageViewSet(viewsets.ModelViewSet):
|
||||||
queryset = ProductImage.objects.all().order_by("-id")
|
queryset = ProductImage.objects.all()
|
||||||
serializer_class = ProductImageSerializer
|
serializer_class = ProductImageSerializer
|
||||||
permission_classes = [AdminWriteOnlyOrReadOnly]
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
search_fields = ["alt_text", "product__name"]
|
||||||
|
ordering_fields = ["id", "product__name"]
|
||||||
|
ordering = ["-id"]
|
||||||
|
|
||||||
|
|
||||||
# -- Discount Code --
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
list=extend_schema(tags=["Discount Codes"], summary="List discount codes (public)"),
|
list=extend_schema(tags=["Discount Codes"], summary="List discount codes (public)"),
|
||||||
retrieve=extend_schema(tags=["Discount Codes"], summary="Retrieve discount code (public)"),
|
retrieve=extend_schema(tags=["Discount Codes"], summary="Retrieve discount code (public)"),
|
||||||
create=extend_schema(tags=["Discount Codes"], summary="Create discount code (admin)"),
|
create=extend_schema(tags=["Discount Codes"], summary="Create discount code (auth required)"),
|
||||||
partial_update=extend_schema(tags=["Discount Codes"], summary="Update discount code (admin)"),
|
partial_update=extend_schema(tags=["Discount Codes"], summary="Update discount code (auth required)"),
|
||||||
update=extend_schema(tags=["Discount Codes"], summary="Replace discount code (admin)"),
|
update=extend_schema(tags=["Discount Codes"], summary="Replace discount code (auth required)"),
|
||||||
destroy=extend_schema(tags=["Discount Codes"], summary="Delete discount code (admin)"),
|
destroy=extend_schema(tags=["Discount Codes"], summary="Delete discount code (auth required)"),
|
||||||
)
|
)
|
||||||
class DiscountCodeViewSet(viewsets.ModelViewSet):
|
class DiscountCodeViewSet(viewsets.ModelViewSet):
|
||||||
queryset = DiscountCode.objects.all().order_by("-id")
|
queryset = DiscountCode.objects.all()
|
||||||
serializer_class = DiscountCodeSerializer
|
serializer_class = DiscountCodeSerializer
|
||||||
permission_classes = [AdminWriteOnlyOrReadOnly]
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
search_fields = ["code", "description"]
|
||||||
|
ordering_fields = ["percent", "amount", "valid_from", "valid_to"]
|
||||||
|
ordering = ["-valid_from"]
|
||||||
|
|
||||||
|
|
||||||
# -- Refund --
|
# -- Refund (Admin only) --
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
list=extend_schema(tags=["Refunds"], summary="List refunds (public)"),
|
list=extend_schema(tags=["Refunds"], summary="List refunds (admin)"),
|
||||||
retrieve=extend_schema(tags=["Refunds"], summary="Retrieve refund (public)"),
|
retrieve=extend_schema(tags=["Refunds"], summary="Retrieve refund (admin)"),
|
||||||
create=extend_schema(tags=["Refunds"], summary="Create refund (public)"),
|
create=extend_schema(tags=["Refunds"], summary="Create refund (admin)"),
|
||||||
partial_update=extend_schema(tags=["Refunds"], summary="Update refund (admin)"),
|
partial_update=extend_schema(tags=["Refunds"], summary="Update refund (admin)"),
|
||||||
|
update=extend_schema(tags=["Refunds"], summary="Replace refund (admin)"),
|
||||||
|
destroy=extend_schema(tags=["Refunds"], summary="Delete refund (admin)"),
|
||||||
)
|
)
|
||||||
class RefundViewSet(mixins.CreateModelMixin,
|
class RefundViewSet(viewsets.ModelViewSet):
|
||||||
mixins.ListModelMixin,
|
|
||||||
mixins.RetrieveModelMixin,
|
|
||||||
mixins.UpdateModelMixin,
|
|
||||||
viewsets.GenericViewSet):
|
|
||||||
queryset = Refund.objects.select_related("order").all().order_by("-created_at")
|
queryset = Refund.objects.select_related("order").all().order_by("-created_at")
|
||||||
serializer_class = RefundSerializer
|
serializer_class = RefundSerializer
|
||||||
permission_classes = [AdminOnlyForPatchOtherwisePublic]
|
permission_classes = [IsAdminUser]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class RefundPublicView(APIView):
|
||||||
|
"""Public endpoint to create and fetch refund objects.
|
||||||
|
|
||||||
|
POST: Create a refund given email and invoice_number or order_id.
|
||||||
|
Returns JSON with refund info, order items, and a base64 PDF payload.
|
||||||
|
GET: Return a refund object by id (query param `id`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
rid = request.query_params.get("id")
|
||||||
|
if not rid:
|
||||||
|
return Response({"detail": "Missing 'id' query parameter."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
try:
|
||||||
|
refund = Refund.objects.select_related("order").get(id=rid)
|
||||||
|
except Refund.DoesNotExist:
|
||||||
|
return Response({"detail": "Refund not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
order = refund.order
|
||||||
|
items = []
|
||||||
|
for it in order.items.select_related('product').all():
|
||||||
|
items.append({
|
||||||
|
"product_name": getattr(it.product, "name", "Item"),
|
||||||
|
"sku": getattr(it.product, "code", None),
|
||||||
|
"quantity": it.quantity,
|
||||||
|
})
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"refund": {
|
||||||
|
"id": refund.id,
|
||||||
|
"order_id": order.id,
|
||||||
|
"reason_choice": refund.reason_choice,
|
||||||
|
"reason_text": refund.reason_text,
|
||||||
|
"verified": refund.verified,
|
||||||
|
"created_at": refund.created_at,
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"id": order.id,
|
||||||
|
"email": order.email,
|
||||||
|
"created_at": order.created_at,
|
||||||
|
"items": items,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = RefundCreatePublicSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
refund = serializer.save()
|
||||||
|
order = refund.order
|
||||||
|
|
||||||
|
# Build items list for response
|
||||||
|
items = []
|
||||||
|
for it in order.items.select_related('product').all():
|
||||||
|
items.append({
|
||||||
|
"product_name": getattr(it.product, "name", "Item"),
|
||||||
|
"sku": getattr(it.product, "code", None),
|
||||||
|
"quantity": it.quantity,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Generate PDF bytes using model helper
|
||||||
|
pdf_bytes = refund.generate_refund_pdf_for_customer()
|
||||||
|
pdf_b64 = base64.b64encode(pdf_bytes).decode('ascii')
|
||||||
|
|
||||||
|
resp = {
|
||||||
|
"refund": {
|
||||||
|
"id": refund.id,
|
||||||
|
"order_id": order.id,
|
||||||
|
"reason_choice": refund.reason_choice,
|
||||||
|
"reason_text": refund.reason_text,
|
||||||
|
"verified": refund.verified,
|
||||||
|
"created_at": refund.created_at,
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"id": order.id,
|
||||||
|
"email": order.email,
|
||||||
|
"created_at": order.created_at,
|
||||||
|
"items": items,
|
||||||
|
},
|
||||||
|
"pdf": {
|
||||||
|
"filename": f"refund_{refund.id}.pdf",
|
||||||
|
"content_type": "application/pdf",
|
||||||
|
"base64": pdf_b64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return Response(resp, status=status.HTTP_201_CREATED)
|
||||||
Reference in New Issue
Block a user