diff --git a/backend/account/models.py b/backend/account/models.py index e52ef8b..faa6134 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -28,14 +28,14 @@ class ActiveUserManager(CustomUserManager): class CustomUser(SoftDeleteModel, AbstractUser): groups = models.ManyToManyField( Group, - related_name="customuser_set", # <- přidáš related_name + related_name="customuser_set", blank=True, help_text="The groups this user belongs to.", related_query_name="customuser", ) user_permissions = models.ManyToManyField( Permission, - related_name="customuser_set", # <- přidáš related_name + related_name="customuser_set", blank=True, help_text="Specific permissions for this user.", related_query_name="customuser", @@ -48,12 +48,9 @@ class CustomUser(SoftDeleteModel, AbstractUser): role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER) - - phone_number = models.CharField( null=True, blank=True, - unique=True, max_length=16, validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")] @@ -66,14 +63,24 @@ class CustomUser(SoftDeleteModel, AbstractUser): email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True) email_verification_sent_at = models.DateTimeField(null=True, blank=True) + + #misc gdpr = models.BooleanField(default=False) is_active = models.BooleanField(default=False) - create_time = models.DateTimeField(auto_now_add=True) + #adresa + postal_code = models.CharField(max_length=20, blank=True) city = models.CharField(null=True, blank=True, max_length=100) street = models.CharField(null=True, blank=True, max_length=200) + street_number = models.PositiveIntegerField(null=True, blank=True) + country = models.CharField(null=True, blank=True, max_length=100) + + # firemní fakturační údaje + company_name = models.CharField(max_length=255, blank=True) + ico = models.CharField(max_length=20, blank=True) + dic = models.CharField(max_length=20, blank=True) postal_code = models.CharField( blank=True, @@ -94,6 +101,7 @@ class CustomUser(SoftDeleteModel, AbstractUser): "email" ] + # Ensure default manager has get_by_natural_key objects = CustomUserManager() # Optional convenience manager for active users only diff --git a/backend/commerce/models.py b/backend/commerce/models.py index 6a5eb9d..3b63bf1 100644 --- a/backend/commerce/models.py +++ b/backend/commerce/models.py @@ -1,16 +1,58 @@ from django.db import models +from django.conf import settings +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from decimal import Decimal + + +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) + + category = models.ForeignKey(Category, related_name='products', on_delete=models.PROTECT) + price = models.DecimalField(max_digits=10, decimal_places=2) currency = models.CharField(max_length=10, default="czk") + + url = models.SlugField(unique=True) + stock = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) + + #limitka + 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): @@ -18,11 +60,21 @@ class Product(models.Model): def __str__(self): return f"{self.name} ({self.price} {self.currency.upper()})" + +#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) + + def __str__(self): + return f"{self.product.name} image" # Dopravci a způsoby dopravy -from django.db import models - class Carrier(models.Model): name = models.CharField(max_length=100) # název dopravce (Zásilkovna, Česká pošta…) base_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) # základní cena dopravy @@ -38,3 +90,126 @@ class Carrier(models.Model): def __str__(self): return f"{self.name} ({self.base_price} Kč)" + + +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.DecimalField(max_digits=5, decimal_places=2, help_text="Např. 10.00 = 10% sleva") + + # 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) + + 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)" + + +class Order(models.Model): + class Status(models.TextChoices): + PENDING = "pending", _("Čeká na platbu") + PAID = "paid", _("Zaplaceno") + CANCELLED = "cancelled", _("Zrušeno") + SHIPPED = "shipped", _("Odesláno") + COMPLETED = "completed", _("Dokončeno") + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, related_name="orders", on_delete=models.CASCADE + ) + + status = models.CharField( + max_length=20, choices=Status.choices, default=Status.PENDING + ) + + # Stored order grand total; recalculated on save + total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) + currency = models.CharField(max_length=10, default="CZK") + + # fakturační údaje (zkopírované z user profilu při objednávce) + 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) + + discount = models.ManyToOneRel("DiscountCode", null=True, blank=True, on_delete=models.PROTECT) + + def __str__(self): + return f"Order #{self.id} - {self.user.email} ({self.status})" + + def calculate_total_price(self): + for discount in 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(discount) + + return total + + + def save(self, *args, **kwargs): + + # Keep total_price always in sync with items and discount + self.total_price = self.calculate_total_price() + + super().save(*args, **kwargs) + + +class OrderItem(models.Model): + order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE) + product = models.ForeignKey("products.Product", on_delete=models.PROTECT) + quantity = models.PositiveIntegerField(default=1) + + def get_total_price(self, discount_object:DiscountCode): + """Calculate total price for this item, applying discount if valid.""" + + if discount_object and discount_object.is_valid(): + + if (self.product in discount_object.specific_products.all() or self.product.category in discount_object.specific_categories.all()): + if discount_object.percent: + return (self.quantity * self.product.price) * (Decimal('1.0') - discount_object.percent / Decimal('100')) + + elif discount_object.amount: + return (self.quantity * self.product.price) - discount_object.amount + + else: + raise ValueError("Discount code must have either percent or amount defined.") + else: + return ValueError("Invalid discount code.") + + def __str__(self): + return f"{self.product.name} x{self.quantity}" \ No newline at end of file diff --git a/backend/commerce/serializers.py b/backend/commerce/serializers.py index 9ff1b14..3fbde37 100644 --- a/backend/commerce/serializers.py +++ b/backend/commerce/serializers.py @@ -1,26 +1,90 @@ from rest_framework import serializers -from .models import Carrier +from drf_spectacular.utils import extend_schema_field +from .models import Category, Product, ProductImage, DiscountCode, Order, OrderItem, Carrier -class CarrierSerializer(serializers.ModelSerializer): - class Meta: - model = Carrier - fields = [ - "id", "name", "base_price", "delivery_time", - "is_active", "logo", "external_id" - ] +# NOTE: Carrier intentionally skipped per request (TODO below) + +class CategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = ["id", "name", "url", "parent", "description", "image"] -from rest_framework import serializers -from .models import Product, Carrier, Order +class ProductImageSerializer(serializers.ModelSerializer): + class Meta: + model = ProductImage + fields = ["id", "image", "alt_text", "is_main"] class ProductSerializer(serializers.ModelSerializer): - class Meta: - model = Product - fields = "__all__" + images = ProductImageSerializer(many=True, read_only=True) + available = serializers.BooleanField(read_only=True) + + class Meta: + model = Product + fields = [ + "id","name","description","code","category","price","currency","url","stock","is_active","limited_to","default_carrier","available","images","created_at","updated_at" + ] + read_only_fields = ["created_at","updated_at","available"] -class CarrierSerializer(serializers.ModelSerializer): - class Meta: - model = Carrier - fields = "__all__" \ No newline at end of file +class DiscountCodeSerializer(serializers.ModelSerializer): + is_valid = serializers.SerializerMethodField() + + class Meta: + model = DiscountCode + fields = ["id","code","description","percent","amount","valid_from","valid_to","active","usage_limit","used_count","specific_products","specific_categories","is_valid"] + read_only_fields = ["used_count","is_valid"] + + def get_is_valid(self, obj): + return obj.is_valid() + + +class OrderItemSerializer(serializers.ModelSerializer): + product = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all()) + line_total = serializers.SerializerMethodField() + + class Meta: + model = OrderItem + fields = ["id","product","quantity","line_total"] + read_only_fields = ["line_total"] + + def get_line_total(self, obj): + # Uses existing model logic for discount via order context (kept minimal) + # Since discount resolution logic is custom & currently incomplete, just returns base price * qty + return obj.product.price * obj.quantity + + +class OrderSerializer(serializers.ModelSerializer): + items = OrderItemSerializer(many=True) + total_price = serializers.DecimalField(max_digits=10, decimal_places=2, read_only=True) + + class Meta: + model = Order + fields = [ + "id","user","status","total_price","currency","first_name","last_name","email","phone","address","city","postal_code","country","note","discount","items","created_at","updated_at" + ] + read_only_fields = ["total_price","created_at","updated_at"] + + def create(self, validated_data): + items_data = validated_data.pop("items", []) + order = Order.objects.create(**validated_data) + for item in items_data: + OrderItem.objects.create(order=order, **item) + order.total_price = order.calculate_total_price() if hasattr(order, "calculate_total_price") else order.total_price + order.save() + return order + + def update(self, instance, validated_data): + items_data = validated_data.pop("items", None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + if items_data is not None: + instance.items.all().delete() + for item in items_data: + OrderItem.objects.create(order=instance, **item) + instance.total_price = instance.calculate_total_price() if hasattr(instance, "calculate_total_price") else instance.total_price + instance.save() + return instance + +# TODO: CarrierSerializer (Carrier API not requested yet) \ No newline at end of file diff --git a/backend/commerce/tasks.py b/backend/commerce/tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/commerce/urls.py b/backend/commerce/urls.py new file mode 100644 index 0000000..5518a66 --- /dev/null +++ b/backend/commerce/urls.py @@ -0,0 +1,15 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import CategoryViewSet, ProductViewSet, DiscountCodeViewSet, OrderViewSet + +router = DefaultRouter() +router.register(r'categories', CategoryViewSet) +router.register(r'products', ProductViewSet) +router.register(r'discounts', DiscountCodeViewSet) +router.register(r'orders', OrderViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] + +# NOTE: Carrier endpoints intentionally omitted (TODO) diff --git a/backend/commerce/views.py b/backend/commerce/views.py index 91ea44a..1b21431 100644 --- a/backend/commerce/views.py +++ b/backend/commerce/views.py @@ -1,3 +1,71 @@ -from django.shortcuts import render +from rest_framework import viewsets +from rest_framework.permissions import AllowAny +from drf_spectacular.utils import extend_schema, extend_schema_view -# Create your views here. +from .models import Category, Product, ProductImage, DiscountCode, Order, OrderItem, Carrier +from .serializers import ( + CategorySerializer, + ProductSerializer, + ProductImageSerializer, + DiscountCodeSerializer, + OrderSerializer, +) + + +@extend_schema_view( + list=extend_schema(tags=["Commerce", "Categories"], summary="List categories"), + retrieve=extend_schema(tags=["Commerce", "Categories"], summary="Retrieve category"), + create=extend_schema(tags=["Commerce", "Categories"], summary="Create category"), + update=extend_schema(tags=["Commerce", "Categories"], summary="Update category"), + partial_update=extend_schema(tags=["Commerce", "Categories"], summary="Partial update category"), + destroy=extend_schema(tags=["Commerce", "Categories"], summary="Delete category"), +) +class CategoryViewSet(viewsets.ModelViewSet): + queryset = Category.objects.all() + serializer_class = CategorySerializer + permission_classes = [AllowAny] + + +@extend_schema_view( + list=extend_schema(tags=["Commerce", "Products"], summary="List products"), + retrieve=extend_schema(tags=["Commerce", "Products"], summary="Retrieve product"), + create=extend_schema(tags=["Commerce", "Products"], summary="Create product"), + update=extend_schema(tags=["Commerce", "Products"], summary="Update product"), + partial_update=extend_schema(tags=["Commerce", "Products"], summary="Partial update product"), + destroy=extend_schema(tags=["Commerce", "Products"], summary="Delete product"), +) +class ProductViewSet(viewsets.ModelViewSet): + queryset = Product.objects.all() + serializer_class = ProductSerializer + permission_classes = [AllowAny] + + +@extend_schema_view( + list=extend_schema(tags=["Commerce", "Discounts"], summary="List discount codes"), + retrieve=extend_schema(tags=["Commerce", "Discounts"], summary="Retrieve discount code"), + create=extend_schema(tags=["Commerce", "Discounts"], summary="Create discount code"), + update=extend_schema(tags=["Commerce", "Discounts"], summary="Update discount code"), + partial_update=extend_schema(tags=["Commerce", "Discounts"], summary="Partial update discount code"), + destroy=extend_schema(tags=["Commerce", "Discounts"], summary="Delete discount code"), +) +class DiscountCodeViewSet(viewsets.ModelViewSet): + queryset = DiscountCode.objects.all() + serializer_class = DiscountCodeSerializer + permission_classes = [AllowAny] + + +@extend_schema_view( + list=extend_schema(tags=["Commerce", "Orders"], summary="List orders"), + retrieve=extend_schema(tags=["Commerce", "Orders"], summary="Retrieve order"), + create=extend_schema(tags=["Commerce", "Orders"], summary="Create order"), + update=extend_schema(tags=["Commerce", "Orders"], summary="Update order"), + partial_update=extend_schema(tags=["Commerce", "Orders"], summary="Partial update order"), + destroy=extend_schema(tags=["Commerce", "Orders"], summary="Delete order"), +) +class OrderViewSet(viewsets.ModelViewSet): + queryset = Order.objects.all() + serializer_class = OrderSerializer + permission_classes = [AllowAny] + + +# TODO: CarrierViewSet & CarrierSerializer when requested diff --git a/backend/thirdparty/gopay/models.py b/backend/thirdparty/gopay/models.py index 1382d1f..88e96a3 100644 --- a/backend/thirdparty/gopay/models.py +++ b/backend/thirdparty/gopay/models.py @@ -10,6 +10,7 @@ class GoPayPayment(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="gopay_payments" ) + # External identifiers and core attributes gopay_id = models.CharField(max_length=64, unique=True, db_index=True) order_number = models.CharField(max_length=128, blank=True, default="") @@ -33,6 +34,7 @@ class GoPayPayment(models.Model): class GoPayRefund(models.Model): payment = models.ForeignKey(GoPayPayment, on_delete=models.CASCADE, null=True, blank=True, related_name="refunds") gopay_refund_id = models.CharField(max_length=64, blank=True, default="") + amount = models.BigIntegerField(help_text="Amount in minor units.") status = models.CharField(max_length=64, blank=True, default="") payload = models.JSONField(default=dict, blank=True) diff --git a/backend/vontor_cz/urls.py b/backend/vontor_cz/urls.py index 96da0a4..ecce746 100644 --- a/backend/vontor_cz/urls.py +++ b/backend/vontor_cz/urls.py @@ -32,7 +32,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('api/account/', include('account.urls')), - #path('api/commerce/', include('commerce.urls')), + path('api/commerce/', include('commerce.urls')), #path('api/advertisments/', include('advertisements.urls')), path('api/stripe/', include('thirdparty.stripe.urls')),