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",