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 }}
+
+
+
+
+
+
+
+
+
+
+
Returned items
+
+
+
+ | Item |
+ SKU |
+ Qty |
+ Reason (per item) |
+
+
+
+ {% for it in items %}
+
+ |
+ {{ 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 %} |
+
+ {% empty %}
+
+ | No items listed. |
+
+ {% endfor %}
+
+
+
Tip: If the reason differs per item, write it in the last column above.
+
+
+
+
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: ________ %
+
+
+
+
+
+
+
+
+
Processed by (name/signature)
+
+
+
+
+
+
+
+
+ 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