init
This commit is contained in:
0
backend/product/__init__.py
Normal file
0
backend/product/__init__.py
Normal file
60
backend/product/admin.py
Normal file
60
backend/product/admin.py
Normal 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
6
backend/product/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProductConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'product'
|
||||
44
backend/product/migrations/0001_initial.py
Normal file
44
backend/product/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
18
backend/product/migrations/0002_alter_product_code.py
Normal file
18
backend/product/migrations/0002_alter_product_code.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
backend/product/migrations/__init__.py
Normal file
0
backend/product/migrations/__init__.py
Normal file
77
backend/product/models.py
Normal file
77
backend/product/models.py
Normal 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}"
|
||||
155
backend/product/serializers.py
Normal file
155
backend/product/serializers.py
Normal 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
66
backend/product/tests.py
Normal 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
12
backend/product/urls.py
Normal 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
50
backend/product/views.py
Normal 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")]
|
||||
Reference in New Issue
Block a user