commerce logika
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
@@ -19,10 +61,20 @@ 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}"
|
||||
@@ -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):
|
||||
# NOTE: Carrier intentionally skipped per request (TODO below)
|
||||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Carrier
|
||||
fields = [
|
||||
"id", "name", "base_price", "delivery_time",
|
||||
"is_active", "logo", "external_id"
|
||||
]
|
||||
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):
|
||||
images = ProductImageSerializer(many=True, read_only=True)
|
||||
available = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = "__all__"
|
||||
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 DiscountCodeSerializer(serializers.ModelSerializer):
|
||||
is_valid = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Carrier
|
||||
fields = "__all__"
|
||||
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)
|
||||
0
backend/commerce/tasks.py
Normal file
0
backend/commerce/tasks.py
Normal file
15
backend/commerce/urls.py
Normal file
15
backend/commerce/urls.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
2
backend/thirdparty/gopay/models.py
vendored
2
backend/thirdparty/gopay/models.py
vendored
@@ -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)
|
||||
|
||||
@@ -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')),
|
||||
|
||||
Reference in New Issue
Block a user