diff --git a/backend/account/apps.py b/backend/account/apps.py index 2b08f1a..f613e58 100644 --- a/backend/account/apps.py +++ b/backend/account/apps.py @@ -1,6 +1,7 @@ from django.apps import AppConfig +from django.contrib.auth import get_user_model class AccountConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'account' + name = 'account' \ No newline at end of file diff --git a/backend/account/models.py b/backend/account/models.py index faa6134..9228c5a 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -165,4 +165,10 @@ class CustomUser(SoftDeleteModel, AbstractUser): if save: self.save(update_fields=["email_verified", "email_verification_token", "email_verification_sent_at"]) return True + + def get_anonymous_user(): + """Return the singleton anonymous user.""" + User = CustomUser + return User.objects.get(username="anonymous") + diff --git a/backend/commerce/models.py b/backend/commerce/models.py index 19343a7..8a189bd 100644 --- a/backend/commerce/models.py +++ b/backend/commerce/models.py @@ -89,7 +89,7 @@ class Carrier(models.Model): choice = models.CharField(max_length=20, choices=Role.choices, default=Role.STORE) - + # prodejce to přidá později zasilkovna = models.ForeignKey( 'thirdparty.zasilkovna.Zasilkovna', on_delete=models.DO_NOTHING, null=True, blank=True, related_name="carriers" ) @@ -98,13 +98,6 @@ class Carrier(models.Model): return f"{self.name} ({self.base_price} Kč)" def save(self, *args, **kwargs): - - #zásilkovna instance je vytvořena - if self.choice == self.Role.ZASILKOVNA: - self.packeta = PacketaShipment.objects.create() - - elif self.choice == self.Role.STORE: - self.packeta = None super().save(*args, **kwargs) @@ -161,10 +154,6 @@ class Order(models.Model): max_length=20, choices=Status.choices, default=Status.PENDING ) - user = models.ForeignKey( - settings.AUTH_USER_MODEL, related_name="orders", on_delete=models.CASCADE - ) - carrier = models.ForeignKey( Carrier, on_delete=models.CASCADE, null=True, blank=True, related_name="orders" ) @@ -179,6 +168,10 @@ class Order(models.Model): currency = models.CharField(max_length=10, default="CZK") # fakturační údaje (zkopírované z user profilu při objednávce) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, related_name="orders", null=True, blank=True + ) + first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) email = models.EmailField() @@ -218,10 +211,18 @@ class Order(models.Model): return total - + def import_data_from_user(self): + """Import user data into order for billing purposes.""" + self.first_name = self.user.first_name + self.last_name = self.user.last_name + self.email = self.user.email + self.phone = self.user.phone + self.address = f"{self.user.street} {self.user.street_number}" + self.city = self.user.city + self.postal_code = self.user.postal_code + self.country = self.user.country def save(self, *args, **kwargs): - # Keep total_price always in sync with items and discount self.total_price = self.calculate_total_price() @@ -255,4 +256,17 @@ class OrderItem(models.Model): return ValueError("Invalid discount code.") def __str__(self): - return f"{self.product.name} x{self.quantity}" \ No newline at end of file + return f"{self.product.name} x{self.quantity}" + + + + + + class Returning_order(models.Model): + #FIXME: dodělat !!! + order = models.ForeignKey(Order, related_name="returning_orders", on_delete=models.CASCADE) + reason = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Returning Order #{self.order.id} - {self.created_at.strftime('%Y-%m-%d')}" \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 97ed758..336fa4c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -86,6 +86,8 @@ weasyprint #tvoření PDFek z html dokumentu + css styly faker #generates fake data for testing purposes +zeep #SOAP tool + ## -- api -- stripe gopay \ No newline at end of file diff --git a/backend/thirdparty/gopay/models.py b/backend/thirdparty/gopay/models.py index 88e96a3..ce548ab 100644 --- a/backend/thirdparty/gopay/models.py +++ b/backend/thirdparty/gopay/models.py @@ -8,7 +8,7 @@ from django.utils import timezone class GoPayPayment(models.Model): # Optional user association user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="gopay_payments" + settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, null=True, blank=True, related_name="gopay_payments" ) # External identifiers and core attributes diff --git a/backend/thirdparty/stripe/models.py b/backend/thirdparty/stripe/models.py index 61a83c1..7f31142 100644 --- a/backend/thirdparty/stripe/models.py +++ b/backend/thirdparty/stripe/models.py @@ -13,8 +13,10 @@ class Order(models.Model): amount = models.DecimalField(max_digits=10, decimal_places=2) currency = models.CharField(max_length=10, default="czk") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending") + stripe_session_id = models.CharField(max_length=255, blank=True, null=True) stripe_payment_intent = models.CharField(max_length=255, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) def __str__(self): diff --git a/backend/thirdparty/zasilkovna/client.py b/backend/thirdparty/zasilkovna/client.py new file mode 100644 index 0000000..98f8305 --- /dev/null +++ b/backend/thirdparty/zasilkovna/client.py @@ -0,0 +1,174 @@ +from zeep import Client +from zeep.exceptions import Fault + +import base64 +import logging +import os + +logger = logging.getLogger(__name__) + +WSDL_URL = os.getenv("PACKETA_WSDL_URL", "https://soap.api.packeta.com/api/soap-bugfix.wsdl") +PACKETA_API_PASSWORD = os.getenv("PACKETA_API_PASSWORD") + +zeepZasClient = Client(wsdl=WSDL_URL) + + +class PacketaAPI: + + # ---------- CREATE PACKET METHODS ---------- + + def create_packet(self, number: str, name: str, surname: str, email: str, phone: str, + address_id: int, value: float, currency: str = "CZK", + weight: float = 1.0, eshop: str = "MyEshop", send_email_to_customer: bool = False): + """ + Jednoduché uložení balíku. Vrací packetId, pod kterým lze později tahat veškeré info. + + Parametry: + number: číslo objednávky + name, surname, email, phone: údaje zákazníka + address_id: ID výdejního místa + value: cena/částka zásilky + currency: měna + weight: hmotnost balíku + eshop: název e-shopu + send_email_to_customer: jestli poslat potvrzovací email + + Návrat: + dict: { + "packetId": str, + "number": str, + "email": str, + "phone": str, + "value": float, + "currency": str + } + """ + attributes = { + "number": number, + "name": name, + "surname": surname, + "email": email, + "phone": phone, + "addressId": address_id, + "value": value, + "currency": currency, + "weight": weight, + "eshop": eshop, + "sendEmailToCustomer": send_email_to_customer + } + + try: + # Použijeme createPacketClaimWithPassword, protože umožňuje ukládat email a telefon + result = zeepZasClient.service.createPacketClaimWithPassword(PACKETA_API_PASSWORD, attributes) + return result + + except Fault as e: + logger.error(f"Packeta store_packet error: {e}") + raise Exception(f"Chyba při ukládání balíku: {e}") + + # ---------- CANCEL SENDING FROM STORE PACKET METHODS ---------- + + def cancel_packet(self, packet_id: int): + """ + Zrušení zásilky (pokud ještě nebyla fyzicky odevzdána). + packet_id = 1234567890 + """ + try: + zeepZasClient.service.cancelPacket(PACKETA_API_PASSWORD, packet_id) + + return {"status": "ok", "message": f"Zásilka {packet_id} byla zrušena."} + + except Fault as e: + logger.error(f"Packeta cancelPacket error: {e}") + raise Exception(f"Chyba při rušení zásilky: {e}") + + + # ---------- INFO PACKET METHODS ---------- + + def packet_state_ready_to_pickup(self, packet_id: int): + """ + Vrací datum do kdy je uložená zásilka. + """ + try: + request = zeepZasClient.service.packetGetStoredUntil(PACKETA_API_PASSWORD, packet_id) + if request is None: + raise Exception(f"Zásilka {packet_id} nebyla ještě doručena.") + + else: + return request # vrací datum do kdy je zásilka uložena + + except Fault as e: + logger.error(f"Packeta packetGetStoredUntil error: {e}") + raise Exception(f"Chyba při získávání data uložení zásilky: {e}") + + def get_packet_label_pdf(self, packet_id: int, format: str = "A6 on A6", offset: int = 0): + """ + Získání PDF štítku k zásilce. + + Parametry: + packet_id (int): ID zásilky (např. 1234567890) + format (str): jeden z formátů: + - "A6 on A6" + - "A7 on A7" + - "A6 on A4" + - "A7 on A4" + - "A8 on A8" + offset (int): pozice na stránce (0 = vlevo nahoře) + + Návrat: + bytes: PDF soubor (base64 dekódovaný) + """ + try: + pdf_base64 = zeepZasClient.service.packetLabelPdf( + PACKETA_API_PASSWORD, packet_id, format, offset + ) + + return base64.b64decode(pdf_base64) + + except Fault as e: + logger.error(f"Packeta packetLabelPdf error: {e}") + raise Exception(f"Chyba při získávání štítku: {e}") + + + # ---------- RETURNING PACKET METHODS ---------- + + def get_return_routing(self, sender_label: str): + """ + Získá dva návratové routing stringy pro vrácení zásilky. + + Args: + sender_label (str): Čárový kód původní zásilky (např. 'Z123456789') + + Returns: + list[str]: Dva řetězce, které se mají vytisknout pro návratovou zásilku. + """ + response = self.client.service.senderGetReturnRouting(sender_label) + return list(response) + + # --------- SHIPMENT METHODS --------- + + def create_shipment(self, packet_ids: list): + """ + Vytvoření zásilky (shipment) z více balíků. + packet_ids = ["1234567890", "1234567891", "1234567892"] + """ + try: + result = zeepZasClient.service.createShipment(PACKETA_API_PASSWORD, packet_ids) + + return result + + except Fault as e: + logger.error(f"Packeta createShipment error: {e}") + raise Exception(f"Chyba při vytváření shipmentu: {e}") + + def get_shipment_packets(self, shipment_id: str): + """ + Získá seznam balíků ve shipmentu podle jeho shipmentId. + """ + try: + result = zeepZasClient.service.shipmentPackets(PACKETA_API_PASSWORD, shipment_id) + return result + except Fault as e: + logger.error(f"Packeta shipmentPackets error: {e}") + raise Exception(f"Chyba při získávání balíků ve shipmentu: {e}") + diff --git a/backend/thirdparty/zasilkovna/models.py b/backend/thirdparty/zasilkovna/models.py index 4d8fd61..4b381bc 100644 --- a/backend/thirdparty/zasilkovna/models.py +++ b/backend/thirdparty/zasilkovna/models.py @@ -1,18 +1,124 @@ +"""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 -# Create your models here. +from .client import PacketaAPI -class Zasilkovna(models.Model): +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" - packet_id = models.CharField(max_length=255, unique=True) # identifikátor od Packety - label_pdf = models.BinaryField(null=True, blank=True) # uložit PDF binárně nebo odkaz + RETURNING = "RETURNING", "Posláno zpátky" + RETURNED = "RETURNED", "Vráceno" + state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PENDING) - status = models.CharField(max_length=50) # např. „created“, „in_transit“, „delivered“ - cancelled = models.BooleanField(default=False) - metadata = models.JSONField(default=dict, blank=True) # pro případná extra data + # ------- API ------- + class BUISSNESS_ADDRESS_ID(models.IntegerChoices): + SHOP = 1, "address of buissnes" + addressId = models.IntegerField(help_text="ID adresy, v API rozhraní", choices=BUISSNESS_ADDRESS_ID.choices, default=BUISSNESS_ADDRESS_ID.SHOP) + + 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" + ) + + 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) + + + # 🚚 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" + ) def save(self, *args, **kwargs): + # On first save, create the packet remotely if packet_id is not set + if not self.packet_id: + response = packeta_client.create_packet(**kwargs) + self.packet_id = response['packet_id'] + self.barcode = response['barcode'] - # případná logika před uložením - super().save(*args, **kwargs) \ No newline at end of file + 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-', 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) diff --git a/backend/thirdparty/zasilkovna/serializers.py b/backend/thirdparty/zasilkovna/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/thirdparty/zasilkovna/tests.py b/backend/thirdparty/zasilkovna/tests.py index 7ce503c..e69de29 100644 --- a/backend/thirdparty/zasilkovna/tests.py +++ b/backend/thirdparty/zasilkovna/tests.py @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/thirdparty/zasilkovna/urls.py b/backend/thirdparty/zasilkovna/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/thirdparty/zasilkovna/views.py b/backend/thirdparty/zasilkovna/views.py index 27cdb63..e69de29 100644 --- a/backend/thirdparty/zasilkovna/views.py +++ b/backend/thirdparty/zasilkovna/views.py @@ -1,4 +0,0 @@ -from django.shortcuts import render - -# Create your views here. -