This commit is contained in:
2025-10-02 00:54:34 +02:00
commit 84b34c9615
200 changed files with 42048 additions and 0 deletions

View File

60
backend/product/admin.py Normal file
View File

@@ -0,0 +1,60 @@
from django.contrib import admin
from trznice.admin import custom_admin_site
from .models import Product, EventProduct
class ProductAdmin(admin.ModelAdmin):
base_list_display = ("id", "name", "code")
admin_extra_display = ("is_deleted",)
list_filter = ("name", "is_deleted")
search_fields = ("name", "code")
ordering = ("name",)
base_fields = ['name', 'code']
def get_fields(self, request, obj=None):
fields = self.base_fields.copy()
if request.user.role == "admin":
fields += ['is_deleted', 'deleted_at']
return fields
def get_queryset(self, request):
# Use the all_objects manager to show even soft-deleted entries
if request.user.role == "admin":
qs = self.model.all_objects.all()
else:
qs = self.model.objects.all()
return qs
def get_list_display(self, request):
if request.user.role == "admin":
return self.base_list_display + self.admin_extra_display
return self.base_list_display
custom_admin_site.register(Product, ProductAdmin)
class EventProductAdmin(admin.ModelAdmin):
list_display = ("id", "event", "product", "start_selling_date", "end_selling_date", "is_deleted")
list_filter = ("event", "product", "start_selling_date", "end_selling_date", "is_deleted")
search_fields = ("product__name", "event__name")
ordering = ("-start_selling_date",)
base_fields = ['product', 'event', 'start_selling_date', 'end_selling_date']
def get_fields(self, request, obj=None):
fields = self.base_fields.copy()
if request.user.role == "admin":
fields += ['is_deleted', 'deleted_at']
return fields
def get_queryset(self, request):
# Use the all_objects manager to show even soft-deleted entries
if request.user.role == "admin":
qs = self.model.all_objects.all()
else:
qs = self.model.objects.all()
return qs
custom_admin_site.register(EventProduct, EventProductAdmin)

6
backend/product/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ProductConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'product'

View File

@@ -0,0 +1,44 @@
# Generated by Django 5.2.4 on 2025-08-07 15:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('booking', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('name', models.CharField(max_length=255, verbose_name='Název produktu')),
('code', models.PositiveIntegerField(unique=True, verbose_name='Unitatní kód produktu')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='EventProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('start_selling_date', models.DateTimeField()),
('end_selling_date', models.DateTimeField()),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_products', to='booking.event')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_products', to='product.product')),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-25 15:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('product', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='product',
name='code',
field=models.PositiveIntegerField(blank=True, null=True, unique=True, verbose_name='Unitatní kód produktu'),
),
]

View File

77
backend/product/models.py Normal file
View File

@@ -0,0 +1,77 @@
from django.db import models
from django.utils import timezone
from django.core.exceptions import ValidationError
from trznice.models import SoftDeleteModel
from booking.models import Event
from trznice.utils import truncate_to_minutes
class Product(SoftDeleteModel):
name = models.CharField(max_length=255, verbose_name="Název produktu")
code = models.PositiveIntegerField(unique=True, verbose_name="Unitatní kód produktu", null=True, blank=True)
def __str__(self):
return f"{self.name} : {self.code}"
def delete(self, *args, **kwargs):
self.event_products.all().update(is_deleted=True, deleted_at=timezone.now())
return super().delete(*args, **kwargs)
class EventProduct(SoftDeleteModel):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="event_products")
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="event_products")
start_selling_date = models.DateTimeField()
end_selling_date = models.DateTimeField()
def clean(self):
if not (self.start_selling_date and self.end_selling_date):
raise ValidationError("Datum začátku a konce musí být neprázné.")
# Vynecháme sekunky, mikrosecundy atd.
self.start_selling_date = truncate_to_minutes(self.start_selling_date)
self.end_selling_date = truncate_to_minutes(self.end_selling_date)
if not self.product_id or not self.event_id:
raise ValidationError("Zadejte Akci a Produkt.")
# Safely get product and event objects for error messages and validation
try:
product_obj = Product.objects.get(pk=self.product_id)
except Product.DoesNotExist:
raise ValidationError("Neplatné ID Zboží (Produktu).")
try:
event_obj = Event.objects.get(pk=self.event_id)
except Event.DoesNotExist:
raise ValidationError("Neplatné ID Akce (Eventu).")
# Overlapping sales window check
overlapping = EventProduct.objects.exclude(id=self.id).filter(
event_id=self.event_id,
product_id=self.product_id,
start_selling_date__lt=self.end_selling_date,
end_selling_date__gt=self.start_selling_date,
)
if overlapping.exists():
raise ValidationError("Toto zboží už se prodává v tomto období na této akci.")
# Ensure sale window is inside event bounds
# Event has DateFields (date), while these are DateTimeFields -> compare by date component
start_date = self.start_selling_date.date()
end_date = self.end_selling_date.date()
if start_date < event_obj.start or end_date > event_obj.end:
raise ValidationError("Prodej zboží musí být v rámci trvání akce.")
# Ensure product+event pair is unique
if EventProduct.objects.exclude(pk=self.pk).filter(product_id=self.product_id, event_id=self.event_id).exists():
raise ValidationError(f"V rámci akce {event_obj} už je {product_obj} zaregistrováno.")
def save(self, *args, **kwargs):
self.full_clean() # This includes clean_fields() + clean() + validate_unique()
super().save(*args, **kwargs)
def __str__(self):
return f"{self.product} at {self.event}"

View File

@@ -0,0 +1,155 @@
from rest_framework import serializers
from rest_framework.validators import UniqueValidator
from trznice.utils import RoundedDateTimeField
from .models import Product, EventProduct
from booking.models import Event
# from booking.serializers import EventSerializer
class ProductSerializer(serializers.ModelSerializer):
code = serializers.CharField(
required=False,
allow_null=True,
allow_blank=True,
help_text="Unikátní číselný kód produktu (volitelné)",
)
events = serializers.SerializerMethodField(help_text="Seznam akcí (eventů), ve kterých se tento produkt prodává.")
class Meta:
model = Product
fields = ["id", "name", "code", "events"]
read_only_fields = ["id"]
extra_kwargs = {
"name": {
"help_text": "Název zboží (max. 255 znaků).",
"required": True,
},
"code": {
"help_text": "Unikátní kód zboží (např. 'FOOD-001'). Volitelné; pokud vyplněno, musí být jedinečný.",
"required": False,
"allow_null": True,
"allow_blank": True,
},
}
def validate_name(self, value):
value = value.strip()
if not value:
raise serializers.ValidationError("Název Zboží (Produktu) nemůže být prázdný.")
if len(value) > 255:
raise serializers.ValidationError("Název nesmí být delší než 255 znaků.")
return value
def validate_code(self, value):
# Accept empty/null
if value in (None, ""):
return None
# Uniqueness manual check (since we removed built-in validator to permit null/blank)
qs = Product.objects.filter(code=value)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise serializers.ValidationError("Produkt s tímto kódem už existuje.")
return value
def get_events(self, obj):
# Expect prefetch: event_products__event
events = []
# Access prefetched related if available to avoid N+1
event_products = getattr(obj, 'event_products_all', None)
if event_products is None:
# Fallback query (should be avoided if queryset is optimized)
event_products = obj.event_products.select_related('event').all()
for ep in event_products:
if ep.event_id and hasattr(ep, 'event'):
events.append({"id": ep.event_id, "name": ep.event.name})
return events
class EventProductSerializer(serializers.ModelSerializer):
product = ProductSerializer(read_only=True)
product_id = serializers.PrimaryKeyRelatedField(
queryset=Product.objects.all(), write_only=True
)
start_selling_date = RoundedDateTimeField()
end_selling_date = RoundedDateTimeField()
class Meta:
model = EventProduct
fields = [
'id',
'product', # nested read-only
'product_id', # required in POST/PUT
'event',
'start_selling_date',
'end_selling_date',
]
read_only_fields = ["id", "product"]
extra_kwargs = {
"product": {
"help_text": "Detail zboží (jen pro čtení).",
"required": False,
"read_only": True,
},
"product_id": {
"help_text": "ID zboží, které je povoleno prodávat na akci.",
"required": True,
"write_only": True,
},
"event": {
"help_text": "ID akce (Event), pro kterou je zboží povoleno.",
"required": True,
},
"start_selling_date": {
"help_text": "Začátek prodeje v rámci akce (musí spadat do [event.start, event.end]).",
"required": True,
},
"end_selling_date": {
"help_text": "Konec prodeje v rámci akce (po start_selling_date, také v rámci [event.start, event.end]).",
"required": True,
},
}
def create(self, validated_data):
validated_data["product"] = validated_data.pop("product_id")
return super().create(validated_data)
def validate(self, data):
product = data.get("product_id")
event = data.get("event")
start = data.get("start_selling_date")
end = data.get("end_selling_date")
if start >= end:
raise serializers.ValidationError("Datum začátku prodeje musí být dříve než jeho konec.")
if event and (start < event.start or end > event.end):
raise serializers.ValidationError("Prodej zboží musí být v rámci trvání akce.")
# When updating, exclude self instance
instance_id = self.instance.id if self.instance else None
# Check for overlapping EventProducts for the same product/event
overlapping = EventProduct.objects.exclude(id=instance_id).filter(
event=event,
product_id=product,
start_selling_date__lt=end,
end_selling_date__gt=start,
)
if overlapping.exists():
raise serializers.ValidationError("Toto zboží už se prodává v tomto období na této akci.")
# # Check for duplicate product-event pair
# duplicate = EventProduct.objects.exclude(id=instance_id).filter(
# event=event,
# product_id=product,
# )
# if duplicate.exists():
# raise serializers.ValidationError(f"V rámci akce {event} už je {product} zaregistrováno.")
return data

66
backend/product/tests.py Normal file
View File

@@ -0,0 +1,66 @@
from django.test import TestCase
from django.utils import timezone
from datetime import timedelta
from django.core.exceptions import ValidationError
from booking.models import Square, Event
from .models import Product, EventProduct
class EventProductDateComparisonTests(TestCase):
def setUp(self):
self.square = Square.objects.create(
name="Test Square",
street="Test Street",
city="Test City",
psc=12345,
width=10,
height=10,
grid_rows=10,
grid_cols=10,
cellsize=10,
)
today = timezone.now().date()
self.event = Event.objects.create(
name="Test Event",
square=self.square,
start=today,
end=today + timedelta(days=2),
price_per_m2=10,
)
self.product = Product.objects.create(name="Prod 1")
def test_event_product_inside_event_range_passes(self):
now = timezone.now()
ep = EventProduct(
product=self.product,
event=self.event,
start_selling_date=now,
end_selling_date=now + timedelta(hours=2),
)
# Should not raise (specifically regression for datetime.date vs datetime comparison)
ep.full_clean() # Will call clean()
ep.save()
self.assertIsNotNone(ep.id)
def test_event_product_outside_event_range_fails(self):
now = timezone.now()
ep = EventProduct(
product=self.product,
event=self.event,
start_selling_date=now - timedelta(days=1), # before event start
end_selling_date=now,
)
with self.assertRaises(ValidationError):
ep.full_clean()
def test_event_product_end_after_event_range_fails(self):
now = timezone.now()
ep = EventProduct(
product=self.product,
event=self.event,
start_selling_date=now,
end_selling_date=now + timedelta(days=5), # after event end
)
with self.assertRaises(ValidationError):
ep.full_clean()

12
backend/product/urls.py Normal file
View File

@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ProductViewSet, EventProductViewSet
router = DefaultRouter()
router.register(r'products', ProductViewSet, basename='products')
router.register(r'event-products', EventProductViewSet, basename='event-products')
urlpatterns = [
path('', include(router.urls)),
]

50
backend/product/views.py Normal file
View File

@@ -0,0 +1,50 @@
from rest_framework import viewsets
from django.db import models
from .models import Product, EventProduct
from .serializers import ProductSerializer, EventProductSerializer
from rest_framework.permissions import IsAuthenticated
from account.permissions import RoleAllowed
from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema
@extend_schema(
tags=["Product"],
description="Seznam produktů, jejich vytváření a úprava. Produkty lze filtrovat a třídit dle názvu nebo kódu."
)
class ProductViewSet(viewsets.ModelViewSet):
queryset = (
Product.objects.all()
.prefetch_related(
models.Prefetch(
'event_products',
queryset=EventProduct.objects.select_related('event').all(),
to_attr='event_products_all'
)
)
.order_by("name")
)
serializer_class = ProductSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
filterset_fields = ["code"]
ordering_fields = ["name", "code"]
search_fields = ["name", "code", "event_products__event__name"]
permission_classes = [RoleAllowed("admin", "squareManager")]
@extend_schema(
tags=["EventProduct"],
description="Propojení produktů s událostmi. Zde se nastavují data prodeje konkrétního produktu na konkrétní události."
)
class EventProductViewSet(viewsets.ModelViewSet):
# queryset = EventProduct.objects.select_related("product", "event").all().order_by("start_selling_date")
queryset = EventProduct.objects.select_related("product").order_by("start_selling_date")
serializer_class = EventProductSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
filterset_fields = ["product", "event"]
ordering_fields = ["start_selling_date", "end_selling_date"]
search_fields = ["product__name", "event__name"]
permission_classes = [RoleAllowed("admin", "squareManager")]