From 946f86db7e2a2a729092d43c47fe44c2b21a4935 Mon Sep 17 00:00:00 2001 From: David Bruno Vontor Date: Mon, 8 Dec 2025 18:19:20 +0100 Subject: [PATCH] Refactor order creation and add configuration endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored order creation logic to use new serializers and transaction handling, improving validation and modularity. Introduced admin and public endpoints for shop configuration with sensitive fields protected. Enhanced Zásilkovna (Packeta) integration, including packet widget template, new API fields, and improved error handling. Added django-silk for profiling, updated requirements and settings, and improved frontend Orval config for API client generation. --- backend/commerce/models.py | 33 +- backend/commerce/serializers.py | 439 ++++++++++++------ backend/commerce/views.py | 87 +--- backend/configuration/models.py | 6 +- backend/configuration/serializers.py | 54 +++ backend/configuration/urls.py | 8 + backend/configuration/views.py | 27 +- backend/requirements.txt | 2 + backend/thirdparty/zasilkovna/client.py | 7 +- backend/thirdparty/zasilkovna/models.py | 40 +- backend/thirdparty/zasilkovna/serializers.py | 24 +- .../zasilkovna/pickup_point_widget.html | 47 ++ backend/thirdparty/zasilkovna/views.py | 108 ++++- backend/vontor_cz/settings.py | 8 + backend/vontor_cz/urls.py | 2 + frontend/orval.config.ts | 20 +- frontend/package-lock.json | 1 + frontend/package.json | 2 +- 18 files changed, 606 insertions(+), 309 deletions(-) create mode 100644 backend/configuration/urls.py create mode 100644 backend/thirdparty/zasilkovna/templates/zasilkovna/pickup_point_widget.html diff --git a/backend/commerce/models.py b/backend/commerce/models.py index 968e0f2..ea41cef 100644 --- a/backend/commerce/models.py +++ b/backend/commerce/models.py @@ -228,7 +228,14 @@ class Carrier(models.Model): returning = models.BooleanField(default=False, help_text="Zda je tato zásilka na vrácení") + shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) + def save(self, *args, **kwargs): + if self.pk is None: + + if self.shipping_price is None: + self.shipping_price = self.get_price() + super().save(*args, **kwargs) def get_price(self): @@ -289,28 +296,8 @@ class Payment(models.Model): StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment" ) - def save(self, *args, **kwargs): - - if self.order: - order = Order.objects.get(payment=self) - - #validace platebních metod - if self.payment_method == self.PAYMENT.SHOP and Carrier.objects.get(orders=order).shipping_method != Carrier.SHIPPING.STORE: - raise ValueError("Platba v obchodě je možná pouze pro osobní odběr.") - - #validace dobírky (jestli není použitá pro osobní odběr) - elif self.payment_method == self.PAYMENT.CASH_ON_DELIVERY and Carrier.objects.get(orders=order).shipping_method == Carrier.SHIPPING.STORE: - raise ValueError("Dobírka není možná pro osobní odběr.") - - #vytvoření platebních metod pokud nový objekt - if not self.pk: - if self.payment_method == self.PAYMENT.STRIPE: - self.stripe = StripePayment.objects.create(amount=order.total_price) - else: - self.stripe = None - - - super().save(*args, **kwargs) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) # ------------------ SLEVOVÉ KÓDY ------------------ @@ -563,7 +550,7 @@ class Refund(models.Model): "return_reason": return_reason, } - #TODO: přesunou zásilkovna field tady taky (zkontrolovat jestli jsou views napojené a použít metodu send z carrier) + carrier = models.OneToOneField( "Carrier", on_delete=models.CASCADE, diff --git a/backend/commerce/serializers.py b/backend/commerce/serializers.py index a8cafdd..bf3fbe4 100644 --- a/backend/commerce/serializers.py +++ b/backend/commerce/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from backend.thirdparty.stripe.client import StripeClient + from .models import Refund, Order, Invoice @@ -69,6 +71,9 @@ 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 +from django.db import transaction +from django.core.exceptions import ValidationError + from .models import ( Category, @@ -82,155 +87,242 @@ from .models import ( Payment, ) +from thirdparty.stripe.models import StripeModel, StripePayment + +from thirdparty.zasilkovna.serializers import ZasilkovnaPacketSerializer +from thirdparty.zasilkovna.models import ZasilkovnaPacket + User = get_user_model() +# ----------------- CREATING ORDER SERIALIZER ----------------- -class UserBriefSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ["id", "first_name", "last_name", "email"] +#correct +# -- CARRIER -- +class OrderCarrierSerializer(serializers.ModelSerializer): + # vstup: jen ID adresy z widgetu (write-only) + packeta_address_id = serializers.IntegerField(required=False, write_only=True) + # výstup: serializovaný packet + zasilkovna = ZasilkovnaPacketSerializer(many=True, read_only=True) -class ProductBriefSerializer(serializers.ModelSerializer): - class Meta: - model = Product - fields = ["id", "name", "price"] - - -class OrderItemReadSerializer(serializers.ModelSerializer): - product = ProductBriefSerializer(read_only=True) - total_price = serializers.SerializerMethodField() - - def get_total_price(self, obj): - return obj.get_total_price(list(obj.order.discount.all()) if getattr(obj, "order", None) else None) - - class Meta: - model = OrderItem - fields = ["id", "product", "quantity", "total_price"] - read_only_fields = ["id", "total_price"] - - -class CarrierReadSerializer(serializers.ModelSerializer): class Meta: model = Carrier - fields = ["id", "shipping_method", "weight", "returning"] - read_only_fields = ["id"] + fields = ["shipping_method", "state", "zasilkovna", "shipping_price", "packeta_address_id"] + read_only_fields = ["state", "shipping_price", "zasilkovna"] + + def create(self, validated_data): + packeta_address_id = validated_data.pop("packeta_address_id", None) + + carrier = Carrier.objects.create(**validated_data) + + if packeta_address_id is not None: + # vytvoříme nový packet s danou addressId + packet = ZasilkovnaPacket.objects.create(addressId=packeta_address_id) + carrier.zasilkovna.add(packet) + + return carrier -class PaymentReadSerializer(serializers.Serializer): - payment_method = serializers.CharField(read_only=True) - stripe_id = serializers.IntegerField(source="stripe.id", read_only=True, allow_null=True) +#correct +# -- ORDER ITEMs -- +class OrderItemCreateSerializer(serializers.Serializer): + product_id = serializers.IntegerField() + quantity = serializers.IntegerField(min_value=1, default=1) + + def validate(self, attrs): + product_id = attrs.get("product_id") + try: + product = Product.objects.get(pk=product_id) + except Product.DoesNotExist: + raise serializers.ValidationError({"product_id": "Product not found."}) + + attrs["product"] = product + return attrs -class OrderReadSerializer(serializers.ModelSerializer): - items = OrderItemReadSerializer(many=True, read_only=True) - carrier = CarrierReadSerializer(read_only=True) - payment = PaymentReadSerializer(source="payment", read_only=True) - user = serializers.SerializerMethodField() - def get_user(self, obj): - request = self.context.get("request") if hasattr(self, "context") else None - if request and getattr(request, "user", None) and request.user.is_authenticated and obj.user: - return UserBriefSerializer(obj.user).data - return None +# -- PAYMENT -- +class PaymentSerializer(serializers.ModelSerializer): class Meta: - model = Order + model = Payment fields = [ "id", - "status", - "total_price", - "currency", - "first_name", - "last_name", - "email", - "phone", - "address", - "city", - "postal_code", - "country", - "note", - "created_at", - "updated_at", - "carrier", - "payment", - "user", - "items", + "payment_method", + "stripe", + "stripe_session_id", + "stripe_payment_intent", + "stripe_session_url", ] read_only_fields = [ "id", - "status", - "total_price", - "currency", - "created_at", - "updated_at", + "stripe", + "stripe_session_id", + "stripe_payment_intent", + "stripe_session_url", ] + def create(self, validated_data): + order = self.context.get("order") # musíš ho předat při inicializaci serializeru + carrier = self.context.get("carrier") -class OrderMiniSerializer(serializers.ModelSerializer): - amount = serializers.DecimalField(max_digits=10, decimal_places=2, source="total_price", read_only=True) - shipping_method = serializers.CharField(source="carrier.shipping_method", read_only=True) + with transaction.atomic(): + payment = Payment.objects.create( + order=order, + carrier=carrier, + **validated_data + ) - class Meta: - model = Order - fields = ["id", "amount", "status", "email", "shipping_method"] - read_only_fields = fields - - -# ----------------- CREATE PAYLOAD SERIALIZERS (PUBLIC) ----------------- - -class OrderItemCreateSerializer(serializers.Serializer): - product_id = serializers.IntegerField(label="Product ID") - quantity = serializers.IntegerField(min_value=1, label="Quantity") - - -class CarrierCreateSerializer(serializers.Serializer): - shipping_method = serializers.ChoiceField( - choices=Carrier.SHIPPING.choices, - label="Shipping Method", - help_text="Choose 'store' (pickup) or 'packeta'", - ) - weight = serializers.DecimalField( - required=False, - max_digits=10, - decimal_places=2, - label="Weight (kg)", - ) - - -class PaymentCreateSerializer(serializers.Serializer): - payment_method = serializers.ChoiceField( - choices=Payment.PAYMENT.choices, - label="Payment Method", - help_text="Choose 'shop', 'stripe' or 'cash_on_delivery'", - ) + # pokud je Stripe, vytvoříme checkout session + if payment.payment_method == Payment.PAYMENT.SHOP and carrier.shipping_method != Carrier.SHIPPING.STORE: + raise serializers.ValidationError("Platba v obchodě je možná pouze pro osobní odběr.") + + elif payment.payment_method == Payment.PAYMENT.CASH_ON_DELIVERY and carrier.shipping_method == Carrier.SHIPPING.STORE: + raise ValidationError("Dobírka není možná pro osobní odběr.") + + + if payment.payment_method == Payment.PAYMENT.STRIPE: + session = StripeClient.create_checkout_session(order) + + payment.stripe_session_id = session.id + payment.stripe_payment_intent = session.payment_intent + payment.stripe_session_url = session.url + + payment.save(update_fields=[ + "stripe_session_id", + "stripe_payment_intent", + "stripe_session_url", + ]) + + return payment +# -- ORDER CREATE SERIALIZER -- class OrderCreateSerializer(serializers.Serializer): - # Customer/billing - first_name = serializers.CharField(max_length=100, label="First Name") - last_name = serializers.CharField(max_length=100, label="Last Name") - email = serializers.EmailField(label="Email") - phone = serializers.CharField(max_length=20, required=False, allow_blank=True, label="Phone") - address = serializers.CharField(max_length=255, label="Address") - city = serializers.CharField(max_length=100, label="City") - postal_code = serializers.CharField(max_length=20, label="Postal Code") - country = serializers.CharField(max_length=100, required=False, default="Czech Republic", label="Country") - note = serializers.CharField(required=False, allow_blank=True, label="Note") + # Customer/billing data (optional when authenticated) + first_name = serializers.CharField(required=False) + last_name = serializers.CharField(required=False) + email = serializers.EmailField(required=False) + phone = serializers.CharField(required=False, allow_blank=True) + address = serializers.CharField(required=False) + city = serializers.CharField(required=False) + postal_code = serializers.CharField(required=False) + country = serializers.CharField(required=False, default="Czech Republic") + note = serializers.CharField(required=False, allow_blank=True) - # Nested + # Nested structures + #produkty items = OrderItemCreateSerializer(many=True) - carrier = CarrierCreateSerializer() - payment = PaymentCreateSerializer() + + #doprava/vyzvednutí + zasilkovna input (serializer) + carrier = OrderCarrierSerializer() + + payment = PaymentSerializer() + + #slevové kódy discount_codes = serializers.ListField( - child=serializers.CharField(), required=False, allow_empty=True, label="Discount Codes" + child=serializers.CharField(), required=False, allow_empty=True ) def validate(self, attrs): + request = self.context.get("request") + + #kontrola jestli je uzivatel valid/prihlasen + is_auth = bool(getattr(getattr(request, "user", None), "is_authenticated", False)) + + # pokud není, tak se musí vyplnit povinné údaje + required_fields = [ + "first_name", + "last_name", + "email", + "address", + "city", + "postal_code", + ] + + if not is_auth: + missing_fields = [] + + # přidame fieldy, které nejsou vyplněné + for field in required_fields: + if attrs.get(field) not in required_fields: + missing_fields.append(field) + + if missing_fields: + raise serializers.ValidationError({"billing": f"Missing fields: {', '.join(missing_fields)}"}) + + # pokud chybí itemy: if not attrs.get("items"): raise serializers.ValidationError({"items": "At least one item is required."}) + return attrs + def create(self, validated_data): + items_data = validated_data.pop("items", []) + carrier_data = validated_data.pop("carrier") + payment_data = validated_data.pop("payment") + codes = validated_data.pop("discount_codes", []) + + request = self.context.get("request") + user = getattr(request, "user", None) + is_auth = bool(getattr(user, "is_authenticated", False)) + + with transaction.atomic(): + # Create Order (user data imported on save if user is set) + order = Order( + user=user if is_auth else None, + first_name=validated_data.get("first_name", ""), + last_name=validated_data.get("last_name", ""), + email=validated_data.get("email", ""), + phone=validated_data.get("phone", ""), + address=validated_data.get("address", ""), + city=validated_data.get("city", ""), + postal_code=validated_data.get("postal_code", ""), + country=validated_data.get("country", "Czech Republic"), + note=validated_data.get("note", ""), + ) + + # Order.save se postara o to jestli má doplnit data z usera + order.save() + + # Vytvoření Carrier skrz serializer + carrier = OrderCarrierSerializer(data=carrier_data) + carrier.is_valid(raise_exception=True) + carrier = carrier.save() + order.carrier = carrier + order.save(update_fields=["carrier", "updated_at"]) # will recalc total later + + + # Vytvořit Order Items individualně, aby se spustila kontrola položek na skladu + for item in items_data: + product = item["product"] # OrderItemCreateSerializer.validate + quantity = int(item.get("quantity", 1)) + OrderItem.objects.create(order=order, product=product, quantity=quantity) + + + # -- Slevové kódy -- + if codes: + discounts = list(DiscountCode.objects.filter(code__in=codes)) + if discounts: + order.discount.add(*discounts) + + + + # -- Payment -- + payment_serializer = PaymentSerializer( + data=payment_data, + context={"order": order, "carrier": carrier} + ) + payment_serializer.is_valid(raise_exception=True) + payment = payment_serializer.save() + + # přiřadíme k orderu + order.payment = payment + order.save(update_fields=["payment"]) + + return order + + # ----------------- ADMIN/READ MODELS ----------------- @@ -262,55 +354,102 @@ class ProductImageSerializer(serializers.ModelSerializer): class ProductSerializer(serializers.ModelSerializer): class Meta: model = Product - fields = [ - "id", - "name", - "description", - "code", - "category", - "price", - "url", - "stock", - "is_active", - "limited_to", - "default_carrier", - "created_at", - "updated_at", - ] + fields = "__all__" read_only_fields = ["created_at", "updated_at"] class DiscountCodeSerializer(serializers.ModelSerializer): class Meta: model = DiscountCode - fields = [ - "id", - "code", - "description", - "percent", - "amount", - "valid_from", - "valid_to", - "active", - "usage_limit", - "used_count", - "specific_products", - "specific_categories", - ] + fields = "__all__" read_only_fields = ["used_count"] class RefundSerializer(serializers.ModelSerializer): class Meta: model = Refund - fields = [ - "id", - "order", - "reason_choice", - "reason_text", - "verified", - "created_at", - ] + fields = "__all__" read_only_fields = ["id", "verified", "created_at"] +# ----------------- READ SERIALIZERS USED BY VIEWS ----------------- + +class ZasilkovnaPacketReadSerializer(ZasilkovnaPacketSerializer): + class Meta(ZasilkovnaPacketSerializer.Meta): + fields = getattr(ZasilkovnaPacketSerializer.Meta, "fields", None) + + +class CarrierReadSerializer(serializers.ModelSerializer): + zasilkovna = ZasilkovnaPacketReadSerializer(many=True, read_only=True) + + class Meta: + model = Carrier + fields = ["shipping_method", "state", "zasilkovna", "shipping_price"] + read_only_fields = fields + + +class ProductMiniSerializer(serializers.ModelSerializer): + class Meta: + model = Product + fields = ["id", "name", "price"] + + +class OrderItemReadSerializer(serializers.ModelSerializer): + product = ProductMiniSerializer(read_only=True) + + class Meta: + model = OrderItem + fields = ["id", "product", "quantity"] + read_only_fields = fields + + +class PaymentReadSerializer(serializers.ModelSerializer): + class Meta: + model = Payment + fields = ["payment_method"] + read_only_fields = fields + + +class OrderMiniSerializer(serializers.ModelSerializer): + class Meta: + model = Order + fields = ["id", "status", "total_price", "created_at"] + read_only_fields = fields + + +class OrderReadSerializer(serializers.ModelSerializer): + items = OrderItemReadSerializer(many=True, read_only=True) + carrier = CarrierReadSerializer(read_only=True) + payment = PaymentReadSerializer(read_only=True) + discount_codes = serializers.SerializerMethodField() + + class Meta: + model = Order + fields = [ + "id", + "status", + "total_price", + "currency", + "user", + "first_name", + "last_name", + "email", + "phone", + "address", + "city", + "postal_code", + "country", + "note", + "created_at", + "updated_at", + "items", + "carrier", + "payment", + "discount_codes", + ] + read_only_fields = fields + + def get_discount_codes(self, obj: Order): + return list(obj.discount.values_list("code", flat=True)) + + diff --git a/backend/commerce/views.py b/backend/commerce/views.py index b56ff17..4fe9dd0 100644 --- a/backend/commerce/views.py +++ b/backend/commerce/views.py @@ -42,7 +42,7 @@ from .serializers import ( RefundSerializer, ) - +#FIXME: uravit view na nový order serializer @extend_schema_view( list=extend_schema(tags=["Orders"], summary="List Orders (public)"), retrieve=extend_schema(tags=["Orders"], summary="Retrieve Order (public)"), @@ -63,7 +63,7 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge return OrderReadSerializer @extend_schema( - tags=["Orders"], + tags=["Order"], summary="Create Order (public)", request=OrderCreateSerializer, responses={201: OrderReadSerializer}, @@ -92,87 +92,10 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge ], ) def create(self, request, *args, **kwargs): - serializer = OrderCreateSerializer(data=request.data) + serializer = OrderCreateSerializer(data=request.data, context={"request": request}) serializer.is_valid(raise_exception=True) - data = serializer.validated_data - user = request.user - - #VELMI DŮLEŽITELÉ: vše vytvořit v transakci, aby se nepřidávaly neúplné objednávky - with transaction.atomic(): - - # Create base order (customer details only for now) - order = Order.objects.create( - user=user if getattr(user, "is_authenticated", False) else None, - first_name=data["first_name"], - last_name=data["last_name"], - email=data["email"], - phone=data.get("phone", ""), - address=data["address"], - city=data["city"], - postal_code=data["postal_code"], - country=data.get("country", "Czech Republic"), - note=data.get("note", ""), - ) - - # Carrier - carrier_payload = data["carrier"] - carrier = Carrier.objects.create( - shipping_method=carrier_payload["shipping_method"], - weight=carrier_payload.get("weight"), - ) - order.carrier = carrier - order.save(update_fields=["carrier", "updated_at"]) # recalc later after items - - # Items - items_payload = data["items"] - order_items = [] - for item in items_payload: - product = Product.objects.get(pk=item["product_id"]) # raises 404 if missing - qty = int(item["quantity"]) - order_items.append(OrderItem(order=order, product=product, quantity=qty)) - OrderItem.objects.bulk_create(order_items) - - # Discount codes (optional) - codes = data.get("discount_codes") or [] - if codes: - discounts = list(DiscountCode.objects.filter(code__in=codes)) - order.discount.add(*discounts) - - # Recalculate now that items/discounts/carrier are linked - order.save() - - # Payment and validation - pay_payload = data["payment"] - payment_method = pay_payload["payment_method"] - - # Validate combos (mirror of Payment.save but here we have order) - if payment_method == Payment.PAYMENT.SHOP and order.carrier.shipping_method != Carrier.SHIPPING.STORE: - return Response( - {"payment": "Platba v obchodě je možná pouze pro osobní odběr."}, - status=status.HTTP_400_BAD_REQUEST, - ) - if payment_method == Payment.PAYMENT.CASH_ON_DELIVERY and order.carrier.shipping_method == Carrier.SHIPPING.STORE: - return Response( - {"payment": "Dobírka není možná pro osobní odběr."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Create payment WITHOUT triggering Payment.save (which expects reverse link first) - payment = Payment(payment_method=payment_method) - # Bypass custom save by bulk_create - Payment.objects.bulk_create([payment]) - order.payment = payment - order.save(update_fields=["payment", "updated_at"]) - - # If Stripe, create StripePayment now and attach - if payment_method == Payment.PAYMENT.STRIPE: - from thirdparty.stripe.models import StripePayment - - stripe_obj = StripePayment.objects.create(amount=order.total_price) - payment.stripe = stripe_obj - payment.save(update_fields=["stripe"]) - - out = self.get_serializer(order) + order = serializer.save() + out = OrderReadSerializer(order) return Response(out.data, status=status.HTTP_201_CREATED) # -- List mini orders -- (public) -- diff --git a/backend/configuration/models.py b/backend/configuration/models.py index 9c7c77c..c69de8f 100644 --- a/backend/configuration/models.py +++ b/backend/configuration/models.py @@ -22,7 +22,11 @@ class ShopConfiguration(models.Model): #zasilkovna settings zasilkovna_shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=50) - zasilkovna_address_id = models.CharField(max_length=100, blank=True, null=True, help_text="ID výdejního místa Zásilkovny pro odesílání zásilek") + #FIXME: není implementováno ↓↓↓ + zasilkovna_api_key = models.CharField(max_length=255, blank=True, null=True, help_text="API klíč pro přístup k Zásilkovna API (zatím není využito)") + #FIXME: není implementováno ↓↓↓ + zasilkovna_api_password = models.CharField(max_length=255, blank=True, null=True, help_text="API heslo pro přístup k Zásilkovna API (zatím není využito)") + #FIXME: není implementováno ↓↓↓ free_shipping_over = models.DecimalField(max_digits=10, decimal_places=2, default=2000) #coupon settings diff --git a/backend/configuration/serializers.py b/backend/configuration/serializers.py index e69de29..9979183 100644 --- a/backend/configuration/serializers.py +++ b/backend/configuration/serializers.py @@ -0,0 +1,54 @@ +from rest_framework import serializers +from .models import ShopConfiguration + + +class ShopConfigurationAdminSerializer(serializers.ModelSerializer): + class Meta: + model = ShopConfiguration + fields = [ + "id", + "name", + "logo", + "favicon", + "contact_email", + "contact_phone", + "contact_address", + "opening_hours", + "facebook_url", + "instagram_url", + "youtube_url", + "tiktok_url", + "whatsapp_number", + "zasilkovna_shipping_price", + "zasilkovna_api_key", + "zasilkovna_api_password", + "free_shipping_over", + "multiplying_coupons", + "addition_of_coupons_amount", + "currency", + ] + + +class ShopConfigurationPublicSerializer(serializers.ModelSerializer): + class Meta: + model = ShopConfiguration + # Expose only non-sensitive fields + fields = [ + "id", + "name", + "logo", + "favicon", + "contact_email", + "contact_phone", + "contact_address", + "opening_hours", + "facebook_url", + "instagram_url", + "youtube_url", + "tiktok_url", + # Exclude API keys/passwords + "zasilkovna_shipping_price", + "free_shipping_over", + "currency", + ] + diff --git a/backend/configuration/urls.py b/backend/configuration/urls.py new file mode 100644 index 0000000..c84fcaf --- /dev/null +++ b/backend/configuration/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter +from .views import ShopConfigurationAdminViewSet, ShopConfigurationPublicViewSet + +router = DefaultRouter() +router.register(r"admin/shop-configuration", ShopConfigurationAdminViewSet, basename="shop-config-admin") +router.register(r"public/shop-configuration", ShopConfigurationPublicViewSet, basename="shop-config-public") + +urlpatterns = router.urls diff --git a/backend/configuration/views.py b/backend/configuration/views.py index 1f891af..33be7e9 100644 --- a/backend/configuration/views.py +++ b/backend/configuration/views.py @@ -1,6 +1,25 @@ -from django.shortcuts import render +from rest_framework import viewsets, mixins +from rest_framework.permissions import IsAdminUser, AllowAny +from .models import ShopConfiguration +from .serializers import ( + ShopConfigurationAdminSerializer, + ShopConfigurationPublicSerializer, +) -# Create your views here. -#TODO: dej public tag pro view -# rozdělit fieldy podle práv aby se třeba neexposelo citlivé údaje \ No newline at end of file +class _SingletonQuerysetMixin: + def get_queryset(self): + return ShopConfiguration.objects.filter(pk=1) + + def get_object(self): + return ShopConfiguration.get_solo() + + +class ShopConfigurationAdminViewSet(_SingletonQuerysetMixin, viewsets.ModelViewSet): + permission_classes = [IsAdminUser] + serializer_class = ShopConfigurationAdminSerializer + + +class ShopConfigurationPublicViewSet(_SingletonQuerysetMixin, viewsets.ReadOnlyModelViewSet): + permission_classes = [AllowAny] + serializer_class = ShopConfigurationPublicSerializer \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 336fa4c..d3a732a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -71,6 +71,8 @@ django-cors-headers #csfr celery #slouží k vytvaření asynchoních úkolu (třeba každou hodinu vyčistit cache atd.) django-celery-beat #slouží k plánování úkolů pro Celery +django-silk +django-silk[formatting] # -- EDITING photos, gifs, videos -- diff --git a/backend/thirdparty/zasilkovna/client.py b/backend/thirdparty/zasilkovna/client.py index 9f729ea..0e24a9f 100644 --- a/backend/thirdparty/zasilkovna/client.py +++ b/backend/thirdparty/zasilkovna/client.py @@ -15,11 +15,14 @@ zeepZasClient = Client(wsdl=WSDL_URL) class PacketaAPI: - #TODO: zeptat se jestli nepřidat další checkovací parametry ohledně zásilkovny např: blokování podle nastavení webu + #TODO: zeptat se jestli nepřidat další checkovací parametry ohledně zásilkovny např: blokování podle configurace webu # popřemýšlet, jestli api klíče nenastavit přes configurator webu def __getattribute__(self): - if PACKETA_API_PASSWORD is None: + if PACKETA_API_PASSWORD in [None, ""]: raise Exception("Packeta API password is not set in environment variables.") + + elif zeepZasClient is None: + raise Exception("Packeta SOAP client is not initialized.") # ---------- CREATE PACKET METHODS ---------- diff --git a/backend/thirdparty/zasilkovna/models.py b/backend/thirdparty/zasilkovna/models.py index a3ee53a..9248775 100644 --- a/backend/thirdparty/zasilkovna/models.py +++ b/backend/thirdparty/zasilkovna/models.py @@ -34,23 +34,23 @@ class ZasilkovnaPacket(models.Model): created_at = models.DateTimeField(auto_now_add=True) class STATE(models.TextChoices): - PENDING = "PENDING", "Podáno" - SENDED = "SENDED", "Odesláno" - ARRIVED = "ARRIVED", "Doručeno" - CANCELED = "CANCELED", "Zrušeno" + WAITING_FOR_ORDER = "WAITING_FOR_ORDERING_SHIPMENT", "cz#Čeká na objednání zásilkovny" + PENDING = "PENDING", "cz#Podáno" + SENDED = "SENDED", "cz#Odesláno" + ARRIVED = "ARRIVED", "cz#Doručeno" + CANCELED = "CANCELED", "cz#Zrušeno" - RETURNING = "RETURNING", "Posláno zpátky" - RETURNED = "RETURNED", "Vráceno" + RETURNING = "RETURNING", "cz#Posláno zpátky" + RETURNED = "RETURNED", "cz#Vráceno" state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PENDING) # ------- API ------- - #TODO: změnit na nastavení adresy eshopu/obchodu z modelu konfigurace # https://client.packeta.com/cs/senders (admin rozhraní) - addressId = models.IntegerField(help_text="ID adresy, v Widgetu zásilkovny který si vybere uživatel.") + addressId = models.IntegerField(null=True, blank=True, help_text="ID adresy/pointu, ve Widgetu zásilkovny který si vybere uživatel.") - packet_id = models.IntegerField(help_text="Číslo zásilky v Packetě (api)") - barcode = models.CharField(max_length=64, help_text="Čárový kód zásilky v Packetě") + packet_id = models.IntegerField(null=True, blank=True, help_text="Číslo zásilky v Packetě (vraceno od API od Packety)") + barcode = models.CharField(null=True, blank=True, max_length=64, help_text="Čárový kód zásilky od Packety") weight = models.IntegerField( default=0, @@ -61,6 +61,7 @@ class ZasilkovnaPacket(models.Model): return_routing = models.JSONField( default=list, blank=True, + null=True, help_text="Seznam 2 routing stringů pro vrácení zásilky" ) @@ -73,7 +74,13 @@ class ZasilkovnaPacket(models.Model): size_of_pdf = models.CharField(max_length=20, choices=PDF_SIZE.choices, default=PDF_SIZE.A6_ON_A6) def save(self, *args, **kwargs): - # workaroud to avoid circular import + return super().save(args, **kwargs) + + + def order_shippment(self): + if self.addressId is None: + raise ValidationError("AddressId must be set to order shipping.") + Carrier = apps.get_model('commerce', 'Carrier') Order = apps.get_model('commerce', 'Order') @@ -84,26 +91,27 @@ class ZasilkovnaPacket(models.Model): if not self.packet_id: response = packeta_client.create_packet( - address_id=self.addressId, + addressId=self.addressId, # ID z widgetu weight=self.weight, number=order.id, name=order.first_name, surname=order.last_name, company=order.company, email=order.email, - addressId=ShopConfiguration.get_solo().zasilkovna_address_id, cod=order.total_price if cash_on_delivery else 0, # dobírka value=order.total_price, - currency=ShopConfiguration.get_solo().currency, + currency=ShopConfiguration.get_solo().currency, #CZK eshop= ShopConfiguration.get_solo().name, ) self.packet_id = response['packet_id'] self.barcode = response['barcode'] + else: + raise ValidationError("Přeprava už byla objednana!!!.") - return super().save(args, **kwargs) - + return self.save() + def cancel_packet(self): """Cancel this packet via the Packeta API.""" packeta_client.cancel_packet(self.packet_id) diff --git a/backend/thirdparty/zasilkovna/serializers.py b/backend/thirdparty/zasilkovna/serializers.py index d0fe9b8..e25b206 100644 --- a/backend/thirdparty/zasilkovna/serializers.py +++ b/backend/thirdparty/zasilkovna/serializers.py @@ -15,16 +15,27 @@ class ZasilkovnaPacketSerializer(serializers.ModelSerializer): "weight", "return_routing", ] - read_only_fields = fields + read_only_fields = [ + "id", + "created_at", + "barcode", + "state", + "weight", + "return_routing", + ] +#Just for tracking URL of packet class TrackingURLSerializer(serializers.Serializer): barcode = serializers.CharField(read_only=True) tracking_url = serializers.URLField(read_only=True) + +# -- SHIPMENT -- + class ZasilkovnaShipmentSerializer(serializers.ModelSerializer): - packets = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + packets = serializers.PrimaryKeyRelatedField(many=True) class Meta: model = ZasilkovnaShipment @@ -33,6 +44,11 @@ class ZasilkovnaShipmentSerializer(serializers.ModelSerializer): "created_at", "shipment_id", "barcode", - "packets", + "packets", + ] + read_only_fields = [ + "id", + "created_at", + "shipment_id", + "barcode", ] - read_only_fields = fields diff --git a/backend/thirdparty/zasilkovna/templates/zasilkovna/pickup_point_widget.html b/backend/thirdparty/zasilkovna/templates/zasilkovna/pickup_point_widget.html new file mode 100644 index 0000000..0ba3ab1 --- /dev/null +++ b/backend/thirdparty/zasilkovna/templates/zasilkovna/pickup_point_widget.html @@ -0,0 +1,47 @@ +{% load static %} + + + + + + + +
\ No newline at end of file diff --git a/backend/thirdparty/zasilkovna/views.py b/backend/thirdparty/zasilkovna/views.py index 022316c..333fd28 100644 --- a/backend/thirdparty/zasilkovna/views.py +++ b/backend/thirdparty/zasilkovna/views.py @@ -1,8 +1,11 @@ from rest_framework import viewsets, mixins, status from rest_framework.decorators import action from rest_framework.response import Response +from django.template import loader -from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse, OpenApiParameter, OpenApiTypes + +from backend.configuration.models import ShopConfiguration from .models import ZasilkovnaShipment, ZasilkovnaPacket from .serializers import ( @@ -11,32 +14,37 @@ from .serializers import ( TrackingURLSerializer, ) +# -- SHIPMENT -- @extend_schema_view( list=extend_schema( - tags=["Zásilkovna"], - summary="List shipments", + tags=["Packeta-Shipment"], + summary="Hromadný shipment", description="Returns a paginated list of Packeta (Zásilkovna) shipments.", - responses={200: ZasilkovnaShipmentSerializer}, + responses=ZasilkovnaShipmentSerializer, ), retrieve=extend_schema( - tags=["Zásilkovna"], - summary="Retrieve a shipment", + tags=["Packeta-Shipment"], + summary="Detail hromadné zásilky", description="Returns detail for a single shipment.", - responses={200: ZasilkovnaShipmentSerializer}, + responses=ZasilkovnaShipmentSerializer, ), ) -class ZasilkovnaShipmentViewSet(viewsets.ReadOnlyModelViewSet): +class ZasilkovnaShipmentViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet,): queryset = ZasilkovnaShipment.objects.all().order_by("-created_at") serializer_class = ZasilkovnaShipmentSerializer + + +# -- PACKET -- + @extend_schema_view( retrieve=extend_schema( - tags=["Zásilkovna"], - summary="Retrieve a packet", - description="Returns detail for a single packet.", + tags=["Packet"], + summary="Packet", + description="#TODO: Popis endpointu", responses={200: ZasilkovnaPacketSerializer}, ) ) @@ -45,12 +53,14 @@ class ZasilkovnaPacketViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet serializer_class = ZasilkovnaPacketSerializer @extend_schema( - tags=["Zásilkovna"], + tags=["Packet"], summary="Get public tracking URL", - description=( - "Returns the public Zásilkovna tracking URL derived from the packet's barcode." - ), - responses={200: OpenApiResponse(response=TrackingURLSerializer)}, + description="Returns the public Zásilkovna tracking URL derived from the packet's barcode.", + responses=OpenApiResponse(response=TrackingURLSerializer), + parameters=[ + OpenApiParameter(name="pk", location=OpenApiParameter.PATH, description="Packet ID", required=True, type=int), + ], + request=None, ) @action(detail=True, methods=["get"], url_path="tracking-url") def tracking_url(self, request, pk=None): @@ -60,21 +70,73 @@ class ZasilkovnaPacketViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet "tracking_url": packet.get_tracking_url(), } return Response(data) - + + #HOTOVO @extend_schema( - tags=["Zásilkovna"], + tags=["Packet"], + summary="Order shipping", + description=( + "Objedná přepravu přes API Packety," + "podle existujicího objektu, kde je od uživatele uložený id od místa poslání." + ), + request=None, + responses=OpenApiResponse(response=ZasilkovnaPacketSerializer), + parameters=[ + OpenApiParameter(name="pk", location=OpenApiParameter.PATH, description="Packet ID", required=True, type=int), + ], + ) + @action(detail=True, methods=["patch"], url_path="order-shipping") + def order_shipping(self, request, pk=None): + packet: ZasilkovnaPacket = self.get_object() + packet.order_shipping() + serializer = self.get_serializer(packet) + return Response(serializer.data, status=status.HTTP_200_OK) + + + #HOTOVO + @extend_schema( + tags=["Packet"], summary="Cancel packet", description=( "Cancels the packet through the Packeta API and updates its state to CANCELED. " "No request body is required." ), request=None, - responses={200: OpenApiResponse(response=ZasilkovnaPacketSerializer)}, + responses=OpenApiResponse(response=ZasilkovnaPacketSerializer), + parameters=[ + OpenApiParameter(name="pk", location=OpenApiParameter.PATH, description="Packet ID", required=True, type=int), + ], ) @action(detail=True, methods=["patch"], url_path="cancel") def cancel(self, request, pk=None): - packet: ZasilkovnaPacket = self.get_object() - packet.cancel_packet() - serializer = self.get_serializer(packet) - return Response(serializer.data, status=status.HTTP_200_OK) + packet: ZasilkovnaPacket = self.get_object() + packet.cancel_packet() + serializer = self.get_serializer(packet) + return Response(serializer.data, status=status.HTTP_200_OK) + + + #TODO: dodělat/domluvit se + @extend_schema( + tags=["Packet"], + summary="Get widget for user, to select pickup point.", + description=( + "Returns HTML widget for user to select pickup point. " + "No request body is required." + ), + request=None, + responses={200: OpenApiResponse(response=TrackingURLSerializer)}, + ) + @action(detail=True, methods=["get"], url_path="pickup-point-widget") + def pickup_point_widget(self, request): + + #https://configurator.widget.packeta.com/cs + + widget_html = loader.render_to_string( + "zasilkovna/pickup_point_widget.html", + { + "api_key": ShopConfiguration.get_solo().zasilkovna_widget_api_key, + } + ) + + return Response({"widget_html": widget_html}) \ No newline at end of file diff --git a/backend/vontor_cz/settings.py b/backend/vontor_cz/settings.py index 3eb8ad5..6f3dce0 100644 --- a/backend/vontor_cz/settings.py +++ b/backend/vontor_cz/settings.py @@ -106,6 +106,7 @@ LOGGING = { }, } +# -- PŘÍKLAD POUŽITÍ LOGS -- """ import logging @@ -120,6 +121,9 @@ logger.error("Chyba – něco se pokazilo, ale aplikace jede dál") logger.critical("Kritická chyba – selhání systému, třeba pád služby") """ +# -- SILK -- +SILKY_PYTHON_PROFILER = True + #---------------------------------- END LOGS --------------------------------------- #-------------------------------------SECURITY 🔐------------------------------------ @@ -374,6 +378,8 @@ INSTALLED_APPS = [ #Nastavení stránky #'constance', #'constance.backends.database', + + 'silk', 'django.contrib.sitemaps', @@ -403,6 +409,8 @@ MIDDLEWARE = [ 'whitenoise.middleware.WhiteNoiseMiddleware', + 'silk.middleware.SilkyMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', diff --git a/backend/vontor_cz/urls.py b/backend/vontor_cz/urls.py index fdf069b..b797976 100644 --- a/backend/vontor_cz/urls.py +++ b/backend/vontor_cz/urls.py @@ -29,12 +29,14 @@ urlpatterns = [ path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path("swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), + path('silk/', include('silk.urls', namespace='silk')), path('admin/', admin.site.urls), path("api/choices/", choices, name="choices"), path('api/account/', include('account.urls')), path('api/commerce/', include('commerce.urls')), + path('api/configuration/', include('configuration.urls')), #path('api/advertisments/', include('advertisements.urls')), path('api/stripe/', include('thirdparty.stripe.urls')), diff --git a/frontend/orval.config.ts b/frontend/orval.config.ts index d48d475..e353723 100644 --- a/frontend/orval.config.ts +++ b/frontend/orval.config.ts @@ -1,14 +1,17 @@ import { defineConfig } from "orval"; import "dotenv/config"; +import {process} from "node:process"; const backendUrl = process.env.VITE_API_BASE_URL || "http://localhost:8000"; // může se hodit pokud nechceme při buildu generovat klienta (nechat false pro produkci nebo vynechat) const SKIP_ORVAL = process.env.SKIP_ORVAL === "true"; -if (SKIP_ORVAL){ - +if (SKIP_ORVAL) { + console.log("[ORVAL] Generation skipped."); + process.exit(0); } + export default defineConfig({ public: { input: { @@ -23,6 +26,9 @@ export default defineConfig({ target: "src/api/generated/public.ts", schemas: "src/api/generated/public/models", + mode: "tags", + clean: true, + client: "react-query", httpClient: "axios", @@ -33,6 +39,9 @@ export default defineConfig({ }, }, }, + hooks: { + afterAllFilesWrite: 'prettier --write', + } }, private: { input: { @@ -41,9 +50,11 @@ export default defineConfig({ // No filters, include all endpoints }, output: { - target: "src/api/generated/private.ts", //IMPORTANTE schemas: "src/api/generated/private/models", + + mode: "tags", + clean: true, client: "react-query", httpClient: "axios", @@ -55,5 +66,8 @@ export default defineConfig({ }, }, }, + hooks: { + afterAllFilesWrite: 'prettier --write', + } }, }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cea0fa0..9be471f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.1.17", "@types/axios": "^0.9.36", + "@types/node": "^24.10.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", diff --git a/frontend/package.json b/frontend/package.json index 009a8fa..962373a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,6 @@ "build": "tsc -b && tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "api:gen": "orval --config orval.config.ts" }, "dependencies": { @@ -28,6 +27,7 @@ "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.1.17", "@types/axios": "^0.9.36", + "@types/node": "^24.10.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0",