From e86839f2dada7ede82330389fdb975991cd00e9b Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Wed, 19 Nov 2025 00:53:37 +0100 Subject: [PATCH] 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. --- backend/commerce/models.py | 37 ++++ backend/commerce/serializers.py | 67 +++++++ .../customer_in_package_returning_form.html | 160 +++++++++++++++ backend/commerce/urls.py | 2 + backend/commerce/views.py | 188 ++++++++++++++---- 5 files changed, 416 insertions(+), 38 deletions(-) create mode 100644 backend/commerce/templates/refund/customer_in_package_returning_form.html diff --git a/backend/commerce/models.py b/backend/commerce/models.py index 23e2b59..8d1dafe 100644 --- a/backend/commerce/models.py +++ b/backend/commerce/models.py @@ -505,6 +505,43 @@ class Refund(models.Model): self.order.status = Order.Status.REFUNDED 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): invoice_number = models.CharField(max_length=50, unique=True) diff --git a/backend/commerce/serializers.py b/backend/commerce/serializers.py index 1a48f9c..a8cafdd 100644 --- a/backend/commerce/serializers.py +++ b/backend/commerce/serializers.py @@ -1,4 +1,71 @@ 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 decimal import Decimal from django.contrib.auth import get_user_model diff --git a/backend/commerce/templates/refund/customer_in_package_returning_form.html b/backend/commerce/templates/refund/customer_in_package_returning_form.html new file mode 100644 index 0000000..747d5b7 --- /dev/null +++ b/backend/commerce/templates/refund/customer_in_package_returning_form.html @@ -0,0 +1,160 @@ + + + + + Return/Refund Slip – Order {{ order.number|default:order.code|default:order.id }} + + + + +
+
+
+
Return / Refund Slip
+
Include this page inside the package for the shopkeeper to examine the return.
+
+
+ +
+
+ +
+
Order number
{{ order.number|default:order.code|default:order.id }}
+
Order date
{% if order.created_at %}{{ order.created_at|date:"Y-m-d H:i" }}{% else %}{% now "Y-m-d" %}{% endif %}
+
Customer name
{{ order.customer_name|default:order.user.get_full_name|default:order.user.username|default:"" }}
+
Customer email
{{ order.customer_email|default:order.user.email|default:"" }}
+
Phone
{{ order.customer_phone|default:"" }}
+
Return created
{% now "Y-m-d H:i" %}
+
+ +
+

Returned items

+ + + + + + + + + + + {% for it in items %} + + + + + + + {% empty %} + + + + {% endfor %} + +
ItemSKUQtyReason (per item)
+
{{ it.product_name|default:it.product.title|default:it.name|default:"Item" }}
+ {% if it.variant or it.options %} +
+ {% if it.variant %}Variant: {{ it.variant }}{% endif %} + {% if it.options %}{% if it.variant %} • {% endif %}Options: {{ it.options }}{% endif %} +
+ {% endif %} +
{{ it.sku|default:"—" }}{{ it.quantity|default:1 }}{% if it.reason %}{{ it.reason }}{% else %} {% endif %}
No items listed.
+ +
+ +
+

Return reason (customer)

+
+ {% if return_reason %}{{ return_reason }}{% else %} + + {% endif %} +
+
+ +
+

Shopkeeper inspection

+
+
+
+ Package condition: + [ ] Intact + [ ] Opened + [ ] Damaged +
+
+ Items condition: + [ ] New + [ ] Light wear + [ ] Used + [ ] Damaged +
+
+
+
+ Resolution: + [ ] Accept refund + [ ] Deny + [ ] Exchange +
+
+ Restocking fee: ________ % +
+
+
+ +
+
Notes:
+
+
+ +
+
+
Processed by (name/signature)
+
+
+
+
Date
+
+
+
+
+ +
+
+ Attach this slip inside the package. Keep a copy for your records. +
+
+ + \ No newline at end of file diff --git a/backend/commerce/urls.py b/backend/commerce/urls.py index ecf49dd..2289a24 100644 --- a/backend/commerce/urls.py +++ b/backend/commerce/urls.py @@ -7,6 +7,7 @@ from .views import ( ProductImageViewSet, DiscountCodeViewSet, RefundViewSet, + RefundPublicView, ) router = DefaultRouter() @@ -19,6 +20,7 @@ router.register(r'refunds', RefundViewSet, basename='refund') urlpatterns = [ path('', include(router.urls)), + path('refunds/public/', RefundPublicView.as_view(), name='RefundPublicView'), ] # NOTE: Other endpoints (categories/products/discounts) can be added later diff --git a/backend/commerce/views.py b/backend/commerce/views.py index 13c2a8a..b56ff17 100644 --- a/backend/commerce/views.py +++ b/backend/commerce/views.py @@ -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 rest_framework import viewsets, mixins, status from rest_framework.permissions import AllowAny, IsAdminUser, SAFE_METHODS from rest_framework.decorators import action from rest_framework.response import Response from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiExample +from rest_framework import filters, permissions from .models import ( Order, @@ -21,6 +32,7 @@ from .serializers import ( OrderMiniSerializer, OrderCreateSerializer, OrderItemReadSerializer, + CarrierReadSerializer, PaymentReadSerializer, ProductSerializer, @@ -301,82 +313,182 @@ class AdminOnlyForPatchOtherwisePublic(AllowAny.__class__): # ---------- Public/admin viewsets ---------- -# -- Product -- @extend_schema_view( list=extend_schema(tags=["Products"], summary="List products (public)"), retrieve=extend_schema(tags=["Products"], summary="Retrieve product (public)"), - create=extend_schema(tags=["Products"], summary="Create product (admin)"), - partial_update=extend_schema(tags=["Products"], summary="Update product (admin)"), - update=extend_schema(tags=["Products"], summary="Replace product (admin)"), - destroy=extend_schema(tags=["Products"], summary="Delete product (admin)"), + create=extend_schema(tags=["Products"], summary="Create product (auth required)"), + partial_update=extend_schema(tags=["Products"], summary="Update product (auth required)"), + update=extend_schema(tags=["Products"], summary="Replace product (auth required)"), + destroy=extend_schema(tags=["Products"], summary="Delete product (auth required)"), ) class ProductViewSet(viewsets.ModelViewSet): - queryset = Product.objects.all().order_by("-created_at") + queryset = Product.objects.all() 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( list=extend_schema(tags=["Categories"], summary="List categories (public)"), retrieve=extend_schema(tags=["Categories"], summary="Retrieve category (public)"), - create=extend_schema(tags=["Categories"], summary="Create category (admin)"), - partial_update=extend_schema(tags=["Categories"], summary="Update category (admin)"), - update=extend_schema(tags=["Categories"], summary="Replace category (admin)"), - destroy=extend_schema(tags=["Categories"], summary="Delete category (admin)"), + create=extend_schema(tags=["Categories"], summary="Create category (auth required)"), + partial_update=extend_schema(tags=["Categories"], summary="Update category (auth required)"), + update=extend_schema(tags=["Categories"], summary="Replace category (auth required)"), + destroy=extend_schema(tags=["Categories"], summary="Delete category (auth required)"), ) class CategoryViewSet(viewsets.ModelViewSet): - queryset = Category.objects.all().order_by("name") + queryset = Category.objects.all() 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( list=extend_schema(tags=["Product Images"], summary="List product images (public)"), retrieve=extend_schema(tags=["Product Images"], summary="Retrieve product image (public)"), - create=extend_schema(tags=["Product Images"], summary="Create product image (admin)"), - partial_update=extend_schema(tags=["Product Images"], summary="Update product image (admin)"), - update=extend_schema(tags=["Product Images"], summary="Replace product image (admin)"), - destroy=extend_schema(tags=["Product Images"], summary="Delete 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 (auth required)"), + update=extend_schema(tags=["Product Images"], summary="Replace product image (auth required)"), + destroy=extend_schema(tags=["Product Images"], summary="Delete product image (auth required)"), ) class ProductImageViewSet(viewsets.ModelViewSet): - queryset = ProductImage.objects.all().order_by("-id") + queryset = ProductImage.objects.all() 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( list=extend_schema(tags=["Discount Codes"], summary="List discount codes (public)"), retrieve=extend_schema(tags=["Discount Codes"], summary="Retrieve discount code (public)"), - create=extend_schema(tags=["Discount Codes"], summary="Create discount code (admin)"), - partial_update=extend_schema(tags=["Discount Codes"], summary="Update discount code (admin)"), - update=extend_schema(tags=["Discount Codes"], summary="Replace discount code (admin)"), - destroy=extend_schema(tags=["Discount Codes"], summary="Delete 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 (auth required)"), + update=extend_schema(tags=["Discount Codes"], summary="Replace discount code (auth required)"), + destroy=extend_schema(tags=["Discount Codes"], summary="Delete discount code (auth required)"), ) class DiscountCodeViewSet(viewsets.ModelViewSet): - queryset = DiscountCode.objects.all().order_by("-id") + queryset = DiscountCode.objects.all() 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( - list=extend_schema(tags=["Refunds"], summary="List refunds (public)"), - retrieve=extend_schema(tags=["Refunds"], summary="Retrieve refund (public)"), - create=extend_schema(tags=["Refunds"], summary="Create refund (public)"), + list=extend_schema(tags=["Refunds"], summary="List refunds (admin)"), + retrieve=extend_schema(tags=["Refunds"], summary="Retrieve refund (admin)"), + create=extend_schema(tags=["Refunds"], summary="Create 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, - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet): +class RefundViewSet(viewsets.ModelViewSet): queryset = Refund.objects.select_related("order").all().order_by("-created_at") 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) \ No newline at end of file