Introduces .dockerignore, production Dockerfile and nginx config for frontend, and refactors docker-compose.yml for multi-service deployment. Updates backend and frontend code to support public API tagging, improves refund handling, adds test email endpoint, and migrates Orval config to TypeScript. Removes unused frontend Dockerfile and updates dependencies for React Query and Orval.
151 lines
5.9 KiB
Python
151 lines
5.9 KiB
Python
"""Models for integration with Packeta (Zásilkovna) API.
|
|
|
|
These models wrap calls to the SOAP API via the immutable client defined in
|
|
`client.py` (PacketaAPI). DO NOT modify the client; we only consume it here.
|
|
|
|
Workflow:
|
|
- Create a `PacketaPacket` instance locally with required recipient data.
|
|
- On first save (when `packet_id` is empty) call the remote API to create
|
|
the packet and persist the returned identifier + metadata.
|
|
- Use helper methods to refresh remote info, fetch label PDF, cancel packet.
|
|
- Group packets into a `PacketaShipment` and create shipment remotely.
|
|
|
|
Edge cases handled:
|
|
- API faults raise exceptions (to be surfaced by serializer validation).
|
|
- Missing remote fields are stored in `metadata` JSON.
|
|
- Label PDF stored as binary field; can be re-fetched if empty.
|
|
"""
|
|
|
|
import json
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
from django.core.validators import RegexValidator
|
|
from django.core.files.base import ContentFile
|
|
from django.apps import apps
|
|
|
|
from .client import PacketaAPI
|
|
|
|
from configuration.models import ShopConfiguration
|
|
|
|
packeta_client = PacketaAPI() # single reusable instance
|
|
|
|
|
|
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"
|
|
|
|
RETURNING = "RETURNING", "Posláno zpátky"
|
|
RETURNED = "RETURNED", "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.")
|
|
|
|
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ě")
|
|
|
|
weight = models.IntegerField(
|
|
default=0,
|
|
help_text="Hmotnost zásilky v gramech"
|
|
)
|
|
|
|
# 🚚 návratové směrovací kódy (pro vrácení zásilky)
|
|
return_routing = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text="Seznam 2 routing stringů pro vrácení zásilky"
|
|
)
|
|
|
|
class PDF_SIZE(models.TextChoices):
|
|
A6_ON_A6 = ("A6 on A6", "105x148 mm (A6) label on a page of the same size")
|
|
A7_ON_A7 = ("A7 on A7", "105x74 mm (A7) label on a page of the same size")
|
|
A6_ON_A4 = ("A6 on A4", "105x148 mm (A6) label on a page of size 210x297 mm (A4)")
|
|
A7_ON_A4 = ("A7 on A4", "105x74 mm (A7) label on a page of size 210x297 mm (A4)")
|
|
A8_ON_A8 = ("A8 on A8", "50x74 mm (A8) label on a page of the same size")
|
|
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
|
|
Carrier = apps.get_model('commerce', 'Carrier')
|
|
Order = apps.get_model('commerce', 'Order')
|
|
|
|
carrier = Carrier.objects.get(zasilkovna=self)
|
|
order = Order.objects.get(carrier=carrier)
|
|
|
|
cash_on_delivery = order.payment.payment_method == order.payment.PAYMENT.CASH_ON_DELIVERY
|
|
|
|
if not self.packet_id:
|
|
response = packeta_client.create_packet(
|
|
address_id=self.addressId,
|
|
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,
|
|
eshop= ShopConfiguration.get_solo().name,
|
|
)
|
|
self.packet_id = response['packet_id']
|
|
self.barcode = response['barcode']
|
|
|
|
return super().save(args, **kwargs)
|
|
|
|
def cancel_packet(self):
|
|
"""Cancel this packet via the Packeta API."""
|
|
packeta_client.cancel_packet(self.packet_id)
|
|
self.state = self.STATE.CANCELED
|
|
self.save() # persist state change
|
|
|
|
def get_tracking_url(self):
|
|
"""Vrátí veřejnou URL pro sledování zásilky (Zásilkovna.cz)."""
|
|
base_url = "https://www.zasilkovna.cz/vyhledavani?query="
|
|
return f"{base_url}{self.barcode}"
|
|
|
|
def returning_packet(self):
|
|
"""Mark this packet as returning via the Packeta API."""
|
|
response = packeta_client.get_return_routing(self.packet_id)
|
|
|
|
self.return_routing = json.loads(response)
|
|
self.state = self.STATE.RETURNING
|
|
|
|
self.save() # persist state change
|
|
|
|
|
|
class ZasilkovnaShipment(models.Model):
|
|
created_at = models.DateTimeField(auto_now_add=True, editable=False)
|
|
shipment_id = models.CharField(max_length=255, unique=True, help_text="ID zásilky v Packetě", editable=False)
|
|
|
|
barcode = models.CharField(
|
|
max_length=64,
|
|
help_text="Čárový kód zásilky v Packetě (format: )",
|
|
validators=[
|
|
RegexValidator(r'D-***-XM-<id>', message="Neplatný formát čárového kódu.")
|
|
]
|
|
)
|
|
|
|
packets = models.ManyToManyField(ZasilkovnaPacket, related_name="shipments", help_text="Seznam zásilek v této zásilce (packet_id)")
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.shipment_id:
|
|
response = packeta_client.create_shipment(
|
|
packet_ids=[packet.packet_id for packet in self.packets.all()]
|
|
)
|
|
self.shipment_id = response['shipment_id']
|
|
self.barcode = response['barcode']
|
|
|
|
return super().save(args, **kwargs)
|