Files
vontor-cz/backend/commerce/models.py
Brunobrno 2a26edac80 Add comprehensive analytics and VAT rate management
Introduced a full-featured analytics module for e-commerce business intelligence, including sales, product, customer, shipping, and review analytics, with API endpoints for dashboard and custom reports. Added VAT rate management: new VATRate model, admin interface, serializer, and API endpoints, and integrated VAT logic into Product and pricing calculations. Refactored configuration and admin code to support VAT rates, improved email notification tasks, and updated related serializers, views, and URLs for unified configuration and VAT management.
2026-01-19 02:13:47 +01:00

809 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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
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 = models.CharField(max_length=3, default="CZK")
# 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)
default_carrier = models.ForeignKey(
"Carrier", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_products"
)
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):
return f"{self.name} ({self.get_price_with_vat()} {self.currency.upper()} 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", "cz#Vytvořeno"
CANCELLED = "cancelled", "cz#Zrušeno"
COMPLETED = "completed", "cz#Dokončeno"
REFUNDING = "refunding", "cz#Vrácení v procesu"
REFUNDED = "refunded", "cz#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 = 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()
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)
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)
for item in self.items.all():
total = total + (item.product.price * 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 save(self, *args, **kwargs):
# Keep total_price always in sync with items and discount
self.total_price = self.calculate_total_price()
is_new = self.pk is None
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)
# ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------
class Carrier(models.Model):
class SHIPPING(models.TextChoices):
ZASILKOVNA = "packeta", "cz#Zásilkovna"
DEUTSCHEPOST = "deutschepost", "cz#Deutsche Post"
STORE = "store", "cz#Osobní odběr"
shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE)
class STATE(models.TextChoices):
PREPARING = "ordered", "cz#Objednávka se připravuje"
SHIPPED = "shipped", "cz#Odesláno"
DELIVERED = "delivered", "cz#Doručeno"
READY_TO_PICKUP = "ready_to_pickup", "cz#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):
if self.pk is None:
if self.shipping_price is None:
self.shipping_price = self.get_price()
if self.pk:
if self.STATE == self.STATE.READY_TO_PICKUP and self.shipping_method == self.SHIPPING.STORE:
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
pass
else:
pass
super().save(*args, **kwargs)
def get_price(self):
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
else:
return Decimal('0.0')
#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", "cz#Platba v obchodě", "de#Bezahlung im Geschäft"
STRIPE = "stripe", "cz#Platební Brána", "de#Zahlungsgateway"
CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka", "de#Nachnahme"
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"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def clean(self):
"""Validate payment and shipping method combinations"""
# TODO: Add validation logic for invalid payment/shipping combinations
# TODO: Skip GoPay integration for now
super().clean()
# ------------------ 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 (0100)
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="Fixní sleva v CZK")
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] = None):
"""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.
"""
base_price = self.product.price * self.quantity
if not 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:
if self.order.payment.payment_method:
raise ValueError("Nelze upravit položky z objednávky s již zvolenou platební metodou.")
else:
#nová položka objednávky, snížit skladové zásoby
if self.product.stock < self.quantity:
raise ValueError("Nedostatečný skladový zásob pro produkt.")
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", "cz#Vrácení před uplynutím 14-ti denní lhůty"
DAMAGED_PRODUCT = "damaged_product", "cz#Poškozený produkt"
WRONG_ITEM = "wrong_item", "cz#Špatná položka"
OTHER = "other", "cz#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,
}
carrier = models.OneToOneField(
"Carrier",
on_delete=models.CASCADE,
related_name="refund",
null=True,
blank=True
)
html_string = render_to_string("refund/customer_in_package_returning_form.html", context)
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)
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"""
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"""
return self.product.price * 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()