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:
2025-11-19 00:53:37 +01:00
parent b8a1a594b2
commit e86839f2da
5 changed files with 416 additions and 38 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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 %}&nbsp;{% 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>

View File

@@ -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

View File

@@ -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)