Refactor order creation and add configuration endpoints

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.
This commit is contained in:
David Bruno Vontor
2025-12-08 18:19:20 +01:00
parent 5b066e2770
commit 946f86db7e
18 changed files with 606 additions and 309 deletions

View File

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

View File

@@ -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
# 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.")
# ----------------- CREATE PAYLOAD SERIALIZERS (PUBLIC) -----------------
if payment.payment_method == Payment.PAYMENT.STRIPE:
session = StripeClient.create_checkout_session(order)
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'",
)
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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,12 +15,15 @@ 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 ----------
def create_packet(self, number: str, name: str, surname: str, email: str, phone: str,

View File

@@ -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,25 +91,26 @@ 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."""

View File

@@ -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
@@ -35,4 +46,9 @@ class ZasilkovnaShipmentSerializer(serializers.ModelSerializer):
"barcode",
"packets",
]
read_only_fields = fields
read_only_fields = [
"id",
"created_at",
"shipment_id",
"barcode",
]

View File

@@ -0,0 +1,47 @@
{% load static %}
<script src="https://widget.packeta.com/v6/www/js/library.js"></script>
<input type="hidden" id="packetaApiKey" value="{{ packeta_Api_Key }}">
<script>
const packetaApiKey = document.querySelector('#packetaApiKey').value;
const packetaOptions = {
country: "cz,sk",
language: "cs",
valueFormat: "\"Packeta\",id,carrierId,carrierPickupPointId,name,city,street",
view: "modal",
vendors: [
{
country: "cz",
group: "zbox",
price: 45,
selected: true
},
{
country: "cz",
price: 45,
selected: true
}
],
defaultCurrency: "CZK",
defaultPrice: "45"
};
function showSelectedPickupPoint(point) {
const saveElement = document.querySelector(".packeta-selector-value");
// Add here an action on pickup point selection
saveElement.innerText = '';
if (point) {
console.log("Selected point", point);
saveElement.innerText = "point: " + JSON.stringify(point, null, 4); // DŮLEŽITÉ PRO DALŠÍ ZPRACOVÁNÍ (jenom potřebuji ID)
}
}
</script>
<button class="packeta-selector-open"
onclick="Packeta.Widget.pick(packetaApiKey, showSelectedPickupPoint, packetaOptions)">Select pick-up point</button>
<div class="packeta-selector-value"></div>

View File

@@ -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):
@@ -62,19 +72,71 @@ class ZasilkovnaPacketViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet
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})

View File

@@ -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 🔐------------------------------------
@@ -375,6 +379,8 @@ INSTALLED_APPS = [
#'constance',
#'constance.backends.database',
'silk',
'django.contrib.sitemaps',
'tinymce',
@@ -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',

View File

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

View File

@@ -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,10 +50,12 @@ 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',
}
},
});

View File

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

View File

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