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

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

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

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

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