from ast import Or import dis from django.db import models from django.conf import settings from django.utils import timezone from django.core.exceptions import ValidationError from decimal import Decimal from django.template.loader import render_to_string from django.core.files.base import ContentFile from django.core.validators import MaxValueValidator, MinValueValidator, validate_email try: from weasyprint import HTML except ImportError: HTML = None import os from configuration.models import SiteConfiguration from thirdparty.zasilkovna.models import ZasilkovnaPacket from thirdparty.stripe.models import StripeModel from .tasks import notify_refund_accepted, notify_Ready_to_pickup, notify_zasilkovna_sended #FIXME: přidat soft delete pro všchny modely !!!! class Category(models.Model): name = models.CharField(max_length=100) #adresa kategorie např: /category/elektronika/mobily/ url = models.SlugField(unique=True) #kategorie se můžou skládat pod sebe parent = models.ForeignKey( 'self', null=True, blank=True, on_delete=models.CASCADE, related_name='subcategories' ) description = models.TextField(blank=True) #ikona image = models.ImageField(upload_to='categories/', blank=True) class Meta: verbose_name_plural = "Categories" def __str__(self): return self.name #TODO: přidate brand model pro produkty (značky) class Product(models.Model): name = models.CharField(max_length=200) description = models.TextField(blank=True) code = models.CharField(max_length=100, unique=True, blank=True, null=True) variants = models.ManyToManyField( "self", symmetrical=True, blank=True, help_text=( "Symetrické varianty produktu: pokud přidáte variantu A → B, " "Django automaticky přidá i variantu B → A. " "Všechny varianty jsou rovnocenné a zobrazí se vzájemně." ), ) category = models.ForeignKey(Category, related_name='products', on_delete=models.PROTECT) # -- CENA -- price = models.DecimalField(max_digits=10, decimal_places=2, help_text="Net price (without VAT)") # Currency is now global from SiteConfiguration, not per-product # VAT rate - configured by business owner in configuration app!!! vat_rate = models.ForeignKey( 'configuration.VATRate', on_delete=models.PROTECT, null=True, blank=True, help_text="VAT rate for this product. Leave empty to use default rate." ) url = models.SlugField(unique=True) stock = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) #časový limit (volitelné) limited_to = models.DateTimeField(null=True, blank=True) #TODO: delete default_carrier = models.ForeignKey( "Carrier", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_products" ) include_in_week_summary_email = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @property def available(self): return self.is_active and self.stock > 0 def get_vat_rate(self): """Get the VAT rate for this product (from configuration or default)""" if self.vat_rate: return self.vat_rate # Import here to avoid circular imports from configuration.models import VATRate return VATRate.get_default() def get_price_with_vat(self): """Get price including VAT""" vat_rate = self.get_vat_rate() if not vat_rate: return self.price # No VAT configured return self.price * (Decimal('1') + vat_rate.rate_decimal) def get_vat_amount(self): """Get the VAT amount for this product""" vat_rate = self.get_vat_rate() if not vat_rate: return Decimal('0') return self.price * vat_rate.rate_decimal def __str__(self): config = SiteConfiguration.get_solo() return f"{self.name} ({self.get_price_with_vat()} {config.currency} inkl. MwSt)" #obrázek pro produkty class ProductImage(models.Model): product = models.ForeignKey(Product, related_name='images', on_delete=models.CASCADE) image = models.ImageField(upload_to='products/') alt_text = models.CharField(max_length=150, blank=True) is_main = models.BooleanField(default=False) order = models.PositiveIntegerField(default=0, help_text="Display order (lower numbers first)") class Meta: ordering = ['order', '-is_main', 'id'] def __str__(self): return f"{self.product.name} image" # ------------------ OBJEDNÁVKY ------------------ class Order(models.Model): class OrderStatus(models.TextChoices): CREATED = "created", "Vytvořeno" CANCELLED = "cancelled", "Zrušeno" COMPLETED = "completed", "Dokončeno" REFUNDING = "refunding", "Vrácení v procesu" REFUNDED = "refunded", "Vráceno" status = models.CharField( max_length=20, choices=OrderStatus.choices, null=True, blank=True, default=OrderStatus.CREATED ) # Stored order grand total; recalculated on save total_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00')) # Currency - captured from site configuration at creation time, never changes currency = models.CharField(max_length=10, default="", help_text="Order currency - captured from site configuration at order creation and never changes") # 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() phone = models.CharField(max_length=20, blank=True) address = models.CharField(max_length=255) city = models.CharField(max_length=100) postal_code = models.CharField(max_length=20) country = models.CharField(max_length=100, default="Czech Republic") note = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) carrier = models.OneToOneField( "Carrier", on_delete=models.CASCADE, related_name="order", null=True, blank=True ) payment = models.OneToOneField( "Payment", on_delete=models.CASCADE, related_name="order", null=True, blank=True ) invoice = models.OneToOneField("Invoice", on_delete=models.CASCADE, related_name="order", null=True, blank=True) #FIXME: změnnit název na discount_code discount = models.ManyToManyField("DiscountCode", blank=True, related_name="orders") def calculate_total_price(self): carrier_price = self.carrier.get_price() if self.carrier else Decimal("0.0") if self.discount.exists(): discounts = list(self.discount.all()) total = Decimal('0.0') # getting all prices from order items (with discount applied if valid) for item in self.items.all(): total = total + item.get_total_price(discounts) return total + carrier_price else: total = Decimal('0.0') # getting all prices from order items (without discount) - using VAT-inclusive prices for item in self.items.all(): total = total + (item.product.get_price_with_vat() * item.quantity) return total + carrier_price 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 clean(self): """Validate order data""" # Validate required fields required_fields = ['first_name', 'last_name', 'email', 'address', 'city', 'postal_code'] for field in required_fields: if not getattr(self, field): raise ValidationError(f"{field.replace('_', ' ').title()} is required.") # Validate email format try: validate_email(self.email) except ValidationError: raise ValidationError("Invalid email format.") # Validate order has items if self.pk and not self.items.exists(): raise ValidationError("Order must have at least one item.") def get_currency(self): """Get order currency - falls back to site configuration if not set""" if self.currency: return self.currency config = SiteConfiguration.get_solo() return config.currency def save(self, *args, **kwargs): is_new = self.pk is None # CRITICAL: Set currency from site configuration ONLY at creation time # Once set, currency should NEVER change to maintain order integrity if is_new and not self.currency: config = SiteConfiguration.get_solo() self.currency = config.currency # Keep total_price always in sync with items and discount self.total_price = self.calculate_total_price() if self.user and is_new: self.import_data_from_user() super().save(*args, **kwargs) # Send email notification for new orders if is_new and self.user: from .tasks import notify_order_successfuly_created notify_order_successfuly_created.delay(order=self, user=self.user) def cancel_order(self): """Cancel the order if possible""" if self.status == self.OrderStatus.CREATED: self.status = self.OrderStatus.CANCELLED self.save() #TODO: udělat ještě kontrolu jestli už nebyla odeslána zásilka a pokud bude už zaplacena tak se uděla refundace a pokud nebude zaplacena tak se zruší brána. else: raise ValidationError("Only orders in 'created' status can be cancelled.") # ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------ class Carrier(models.Model): class SHIPPING(models.TextChoices): ZASILKOVNA = "packeta", "Zásilkovna" DEUTSCHEPOST = "deutschepost", "Deutsche Post" STORE = "store", "Osobní odběr" shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE) class STATE(models.TextChoices): PREPARING = "ordered", "Objednávka se připravuje" SHIPPED = "shipped", "Odesláno" DELIVERED = "delivered", "Doručeno" READY_TO_PICKUP = "ready_to_pickup", "Připraveno k vyzvednutí" #RETURNING = "returning", "Vracení objednávky" state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING) # prodejce to přidá později zasilkovna = models.ManyToManyField( ZasilkovnaPacket, blank=True, related_name="carriers" ) # Deutsche Post integration (same pattern as zasilkovna) deutschepost = models.ManyToManyField( "deutschepost.DeutschePostOrder", blank=True, related_name="carriers" ) weight = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Hmotnost zásilky v kg") 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=Decimal('0.00')) def save(self, *args, **kwargs): # Set shipping price for new carriers if self.pk is None and self.shipping_price is None: # For new carriers, we might not have an order yet self.shipping_price = self.get_price(order=None) # Check if state changed to ready for pickup old_state = None if self.pk: old_carrier = Carrier.objects.filter(pk=self.pk).first() old_state = old_carrier.state if old_carrier else None super().save(*args, **kwargs) # Send notification if state changed to ready for pickup if (old_state != self.STATE.READY_TO_PICKUP and self.state == self.STATE.READY_TO_PICKUP and self.shipping_method == self.SHIPPING.STORE): if hasattr(self, 'order') and self.order: notify_Ready_to_pickup.delay(order=self.order, user=self.order.user) def get_price(self, order=None): if self.shipping_method == self.SHIPPING.ZASILKOVNA: return SiteConfiguration.get_solo().zasilkovna_shipping_price elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST: return SiteConfiguration.get_solo().deutschepost_shipping_price elif self.shipping_method == self.SHIPPING.STORE: # Store pickup is always free return Decimal('0.0') else: # Check for free shipping based on order total if order is None: order = Order.objects.filter(carrier=self).first() if order and order.total_price >= SiteConfiguration.get_solo().free_shipping_over: return Decimal('0.0') else: return SiteConfiguration.get_solo().default_shipping_price or Decimal('50.0') # fallback price #tohle bude vyvoláno pomocí admina přes api!!! def start_ordering_shipping(self): if self.shipping_method == self.SHIPPING.ZASILKOVNA: # Uživatel může objednat více zásilek pokud potřeba self.zasilkovna.add(ZasilkovnaPacket.objects.create()) self.returning = False self.save() notify_zasilkovna_sended.delay(order=self.order, user=self.order.user) elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST: # Import here to avoid circular imports from thirdparty.deutschepost.models import DeutschePostOrder # Create new Deutsche Post order and add to carrier (same pattern as zasilkovna) dp_order = DeutschePostOrder.objects.create() self.deutschepost.add(dp_order) self.returning = False self.save() # Order shipping through Deutsche Post API dp_order.order_shippment() elif self.shipping_method == self.SHIPPING.STORE: self.state = self.STATE.READY_TO_PICKUP self.save() notify_Ready_to_pickup.delay(order=self.order, user=self.order.user) else: raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.") #... další logika pro jiné způsoby dopravy (do budoucna!) def ready_to_pickup(self): if self.shipping_method == self.SHIPPING.STORE: self.state = self.STATE.READY_TO_PICKUP self.save() else: raise ValidationError("Tato metoda dopravy nepodporuje připravení k vyzvednutí.") # def returning_shipping(self, int:id): # self.returning = True # if self.shipping_method == self.SHIPPING.ZASILKOVNA: # #volá se na api Zásilkovny # self.zasilkovna.get(id=id).returning_packet() # ------------------ PLATEBNÍ MODELY ------------------ class Payment(models.Model): class PAYMENT(models.TextChoices): SHOP = "shop", "Platba v obchodě" STRIPE = "stripe", "Platební Brána" CASH_ON_DELIVERY = "cash_on_delivery", "Dobírka" payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP) #FIXME: potvrdit že logika platby funguje správně #veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.) stripe = models.OneToOneField( StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment" ) payed_at_shop = models.BooleanField(default=False, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def clean(self): """Validate payment and shipping method combinations""" # Validate payment method consistency if self.payment_method == self.PAYMENT.STRIPE and not self.stripe: raise ValidationError("Stripe payment method requires a linked StripeModel instance.") elif self.payment_method == self.PAYMENT.SHOP and self.stripe: raise ValidationError("Shop payment method should not have a linked StripeModel instance.") # Validate payment and shipping compatibility if self.payment_method == self.PAYMENT.SHOP: # SHOP payment only works with STORE pickup - customer pays at physical store if Order.objects.filter(payment=self).exists(): order = Order.objects.get(payment=self) if order.carrier and order.carrier.shipping_method != Carrier.SHIPPING.STORE: raise ValidationError( "Shop payment is only compatible with store pickup. " "For shipping orders, use Stripe or Cash on Delivery payment methods." ) elif self.payment_method == self.PAYMENT.CASH_ON_DELIVERY: # Cash on delivery only works with shipping methods (not store pickup) if Order.objects.filter(payment=self).exists(): order = Order.objects.get(payment=self) if order.carrier and order.carrier.shipping_method == Carrier.SHIPPING.STORE: raise ValidationError( "Cash on delivery is not compatible with store pickup. " "For store pickup, use shop payment method." ) # STRIPE payment works with all shipping methods - no additional validation needed super().clean() def payed_manually(self): """Mark payment as completed""" if self.payment_method == self.PAYMENT.SHOP: self.payed_at_shop = True self.save() else: raise ValidationError("Manuální platba je povolena pouze pro platbu v obchodě.") # ------------------ SLEVOVÉ KÓDY ------------------ class DiscountCode(models.Model): code = models.CharField(max_length=50, unique=True) description = models.CharField(max_length=255, blank=True) # sleva v procentech (0–100) percent = models.PositiveIntegerField( validators=[MinValueValidator(0), MaxValueValidator(100)], help_text="Procento sleva 0-100", null=True, blank=True ) # nebo fixní částka amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Fixed discount amount in site currency") valid_from = models.DateTimeField(default=timezone.now) valid_to = models.DateTimeField(null=True, blank=True) active = models.BooleanField(default=True) #max počet použití usage_limit = models.PositiveIntegerField(null=True, blank=True) used_count = models.PositiveIntegerField(default=0) specific_products = models.ManyToManyField( Product, blank=True, related_name="discount_codes" ) specific_categories = models.ManyToManyField( Category, blank=True, related_name="discount_codes" ) def is_valid(self): now = timezone.now() if not self.active: return False if self.valid_to and self.valid_to < now: return False if self.usage_limit and self.used_count >= self.usage_limit: return False return True def __str__(self): return f"{self.code} ({self.percent}% or {self.amount} CZK)" # ------------------ OBJEDNANÉ POLOŽKY ------------------ class OrderItem(models.Model): order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE) product = models.ForeignKey("commerce.Product", on_delete=models.PROTECT) quantity = models.PositiveIntegerField(default=1) def get_total_price(self, discounts: list[DiscountCode] = list()): """Vrátí celkovou cenu položky po aplikaci relevantních kupónů. Logika dle SiteConfiguration: - multiplying_coupons=True: procentuální slevy se násobí (sekvenčně) P * (1 - p1) -> výsledné * (1 - p2) ... jinak se použije pouze nejlepší (nejvyšší procento). - addition_of_coupons_amount=True: fixní částky (amount) se sčítají, jinak se použije pouze nejvyšší částka. - Kombinace: nejprve procentuální část, poté odečtení fixní částky. - Sleva se nikdy nesmí dostat pod 0. """ # Use VAT-inclusive price for customer-facing calculations base_price = self.product.get_price_with_vat() * self.quantity if not discounts or discounts == []: return base_price config = SiteConfiguration.get_solo() #seznám slev applicable_percent_discounts: list[int] = [] applicable_amount_discounts: list[Decimal] = [] #procházení kupónů a určení, které se aplikují for discount in set(discounts): if not discount: continue if not discount.is_valid(): raise ValueError("Invalid discount code.") #defaulting applies = False # Určení, zda kupon platí pro produkt/kategorii # prázdný produkt a kategorie = globální kupon if discount.specific_products.exists() or discount.specific_categories.exists(): if (self.product in discount.specific_products.all() or self.product.category in discount.specific_categories.all()): applies = True else: applies = True #global if not applies: continue if discount.percent is not None: applicable_percent_discounts.append(discount.percent) elif discount.amount is not None: applicable_amount_discounts.append(discount.amount) final_price = base_price # Procentuální slevy if applicable_percent_discounts: if config.multiplying_coupons: for pct in applicable_percent_discounts: factor = (Decimal('1') - (Decimal(pct) / Decimal('100'))) final_price = final_price * factor else: best_pct = max(applicable_percent_discounts) factor = (Decimal('1') - (Decimal(best_pct) / Decimal('100'))) final_price = final_price * factor # Fixní částky if applicable_amount_discounts: if config.addition_of_coupons_amount: total_amount = sum(applicable_amount_discounts) else: total_amount = max(applicable_amount_discounts) final_price = final_price - total_amount if final_price < Decimal('0'): final_price = Decimal('0') return final_price.quantize(Decimal('0.01')) def __str__(self): return f"{self.product.name} x{self.quantity}" def save(self, *args, **kwargs): if self.pk is None: # Check if order already has a processed payment if (self.order.payment and self.order.payment.payment_method and self.order.payment.payment_method != Payment.PAYMENT.SHOP): raise ValueError("Cannot modify items from order with processed payment method.") # Validate stock availability if self.product.stock < self.quantity: raise ValueError(f"Insufficient stock for product {self.product.name}. Available: {self.product.stock}") # Reduce stock self.product.stock -= self.quantity self.product.save(update_fields=["stock"]) super().save(*args, **kwargs) class Refund(models.Model): order = models.ForeignKey(Order, related_name="refunds", on_delete=models.CASCADE) class Reason(models.TextChoices): RETUNING_PERIOD = "retuning_before_fourteen_day_period", "Vrácení před uplynutím 14-ti denní lhůty" DAMAGED_PRODUCT = "damaged_product", "Poškozený produkt" WRONG_ITEM = "wrong_item", "Špatná položka" OTHER = "other", "Jiný důvod" reason_choice = models.CharField(max_length=40, choices=Reason.choices) reason_text = models.TextField(blank=True) verified = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) #VRACENÍ ZÁSILKY, LOGIKA (DISABLED FOR NOW) # def save(self, *args, **kwargs): # # Automaticky aktualizovat stav objednávky na "vráceno" # if self.pk is None: # self.order.status = Order.Status.REFUNDING # self.order.save(update_fields=["status", "updated_at"]) # shipping_method = self.order.carrier.shipping_method # if shipping_method == Carrier.SHIPPING.ZASILKOVNA: # carrier = self.order.carrier; # # poslední odeslána/vytvořená zásilka # # Iniciovat vrácení přes Zásilkovnu # carrier.zasilkovna.latest('created_at').returning_packet() # carrier.save() # else: # # Logika pro jiné způsoby dopravy # pass # super().save(*args, **kwargs) def save(self, *args, **kwargs): # Automaticky aktualizovat stav objednávky na "vráceno" if self.pk is None: if self.order.status != Order.OrderStatus.REFUNDING: self.order.status = Order.OrderStatus.REFUNDING self.order.save(update_fields=["status", "updated_at"]) super().save(*args, **kwargs) def refund_completed(self): # Aktualizovat stav objednávky na "vráceno" if self.order.payment and self.order.payment.payment_method == Payment.PAYMENT.STRIPE: self.order.payment.stripe.refund() # Vrácení pěnez přes stripe self.order.status = Order.OrderStatus.REFUNDED self.order.save(update_fields=["status", "updated_at"]) notify_refund_accepted.delay(order=self.order, user=self.order.user) def generate_refund_pdf_for_customer(self): """Vygeneruje PDF formulář k vrácení zboží pro zákazníka. Šablona refund/customer_in_package_returning_form.html očekává: - order: objekt objednávky - items: seznam položek (dict) s klíči product_name, sku, quantity, variant, options, reason - return_reason: textový důvod vrácení (kombinace reason_text / reason_choice) Návratová hodnota: bytes (PDF obsah). Uložení necháváme na volající logice. """ order = self.order # Připravíme položky pro šablonu (důvody per položku zatím None – lze rozšířit) prepared_items: list[dict] = [] for item in order.items.select_related('product'): prepared_items.append({ "product_name": getattr(item.product, "name", "Item"), "name": getattr(item.product, "name", "Item"), # fallbacky pro různé názvy v šabloně "sku": getattr(item.product, "code", None), "quantity": item.quantity, "variant": None, # lze doplnit pokud existují varianty "options": None, # lze doplnit pokud existují volby "reason": None, # per-item reason (zatím nepodporováno) }) return_reason = self.reason_text or self.get_reason_choice_display() context = { "order": order, "items": prepared_items, "return_reason": return_reason, } html_string = render_to_string("refund/customer_in_package_returning_form.html", context) # Import WeasyPrint lazily to avoid startup failures when system # libraries (Pango/GObject) are not present on Windows. if HTML is None: raise RuntimeError( "WeasyPrint is not available. Install its system dependencies (Pango/GTK) or run the backend in Docker." ) pdf_bytes = HTML(string=html_string).write_pdf() return pdf_bytes class Invoice(models.Model): invoice_number = models.CharField(max_length=50, unique=True) issued_at = models.DateTimeField(auto_now_add=True) due_date = models.DateTimeField() pdf_file = models.FileField(upload_to='invoices/') def __str__(self): return f"Invoice {self.invoice_number} for Order {self.order.id}" def generate_invoice_pdf(self): order = Order.objects.get(invoice=self) # Render HTML html_string = render_to_string("invoice/Order.html", {"invoice": self, "order": order}) # Import WeasyPrint lazily to avoid startup failures when system # libraries (Pango/GObject) are not present on Windows. if HTML is None: raise RuntimeError( "WeasyPrint is not available. Install its system dependencies (Pango/GTK) or run the backend in Docker." ) pdf_bytes = HTML(string=html_string).write_pdf() # Save directly to FileField self.pdf_file.save(f"{self.invoice_number}.pdf", ContentFile(pdf_bytes)) self.save() class Review(models.Model): product = models.ForeignKey(Product, related_name="reviews", on_delete=models.CASCADE) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews" ) rating = models.PositiveIntegerField( validators=[MinValueValidator(1), MaxValueValidator(5)] ) comment = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: unique_together = ('product', 'user') # Prevent multiple reviews per user per product indexes = [ models.Index(fields=['product', 'rating']), models.Index(fields=['created_at']), ] def clean(self): """Validate that user hasn't already reviewed this product""" if self.pk is None: # Only for new reviews if Review.objects.filter(product=self.product, user=self.user).exists(): raise ValidationError("User has already reviewed this product.") def __str__(self): return f"Review for {self.product.name} by {self.user.username}" # ------------------ SHOPPING CART ------------------ class Cart(models.Model): """Shopping cart for both authenticated and anonymous users""" user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True, related_name="cart" ) session_key = models.CharField( max_length=40, null=True, blank=True, help_text="Session key for anonymous users" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = "Cart" verbose_name_plural = "Carts" def __str__(self): if self.user: return f"Cart for {self.user.email}" return f"Anonymous cart ({self.session_key})" def get_total(self): """Calculate total price of all items in cart including VAT""" total = Decimal('0.0') for item in self.items.all(): total += item.get_subtotal() return total def get_items_count(self): """Get total number of items in cart""" return sum(item.quantity for item in self.items.all()) class CartItem(models.Model): """Individual items in a shopping cart""" cart = models.ForeignKey(Cart, related_name='items', on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) quantity = models.PositiveIntegerField(default=1) added_at = models.DateTimeField(auto_now_add=True) class Meta: verbose_name = "Cart Item" verbose_name_plural = "Cart Items" unique_together = ('cart', 'product') # Prevent duplicate products in same cart def __str__(self): return f"{self.quantity}x {self.product.name} in cart" def get_subtotal(self): """Calculate subtotal for this cart item including VAT""" return self.product.get_price_with_vat() * self.quantity def clean(self): """Validate that product has enough stock""" if self.product.stock < self.quantity: raise ValidationError(f"Not enough stock for {self.product.name}. Available: {self.product.stock}") def save(self, *args, **kwargs): self.clean() super().save(*args, **kwargs) # ------------------ WISHLIST ------------------ class Wishlist(models.Model): """User's wishlist for saving favorite products""" user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="wishlist" ) products = models.ManyToManyField( Product, blank=True, related_name="wishlisted_by", help_text="Products saved by the user" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = "Wishlist" verbose_name_plural = "Wishlists" def __str__(self): return f"Wishlist for {self.user.email}" def add_product(self, product): """Add a product to wishlist""" self.products.add(product) def remove_product(self, product): """Remove a product from wishlist""" self.products.remove(product) def has_product(self, product): """Check if product is in wishlist""" return self.products.filter(pk=product.pk).exists() def get_products_count(self): """Get count of products in wishlist""" return self.products.count()