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

@@ -0,0 +1 @@
# from . import tasks

135
backend/booking/admin.py Normal file
View File

@@ -0,0 +1,135 @@
from django.contrib import admin
from .models import Event, Reservation, MarketSlot, Square, ReservationCheck
from .forms import ReservationAdminForm
from trznice.admin import custom_admin_site
class SquareAdmin(admin.ModelAdmin):
list_display = ("id", "name", "description", "street", "city", "width", "height", "is_deleted")
list_filter = ("name", "is_deleted")
search_fields = ("name", "description")
ordering = ("name",)
base_fields = ['name', 'description', 'street', 'city', 'psc', 'width', 'height', 'grid_rows', 'grid_cols', 'cellsize', 'image']
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(Square, SquareAdmin)
# @admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ("id", "name", "square", "start", "end", "price_per_m2", "is_deleted")
list_filter = ("start", "end", "is_deleted")
search_fields = ("name", "description")
ordering = ("-start",)
base_fields = ['name', 'description', 'square', 'price_per_m2', 'start', 'end', 'image']
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(Event, EventAdmin)
# @admin.register(Reservation)
class ReservationAdmin(admin.ModelAdmin):
form = ReservationAdminForm
list_display = ("id", "event", "user", "reserved_from", "reserved_to", "status", "created_at", "is_checked", "is_deleted")
list_filter = ("status", "user", "event", "is_deleted")
search_fields = ("user__username", "user__email", "event__name", "note")
ordering = ("-created_at",)
filter_horizontal = ['event_products'] # adds a nice widget for selection
base_fields = ['event', 'market_slot', 'user', 'status', 'used_extension', 'event_products', 'reserved_to', 'reserved_from', 'final_price', 'note', "is_checked", "last_checked_at", "last_checked_by"]
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(Reservation, ReservationAdmin)
class MarketSlotAdmin(admin.ModelAdmin):
list_display = ("id", "event", "number", "status", "base_size", "available_extension", "price_per_m2", "x", "y", "width", "height", "is_deleted")
list_filter = ("status", "event", "is_deleted")
search_fields = ("event__name",)
ordering = ("event", "status")
base_fields = ['event', 'status', 'number', 'base_size', 'available_extension', 'price_per_m2', 'width', 'height', 'x', 'y']
readonly_fields = ("id", "number") # zde
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(MarketSlot, MarketSlotAdmin)
class ReservationCheckAdmin(admin.ModelAdmin):
list_display = ("id", "reservation", "checker", "checked_at", "is_deleted")
list_filter = ("reservation", "checker", "is_deleted")
search_fields = ("checker__email", "reservation__event__name")
ordering = ("-checked_at",)
base_fields = ["reservation", "checker", "checked_at"]
readonly_fields = ("id", "checked_at") # zde
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(ReservationCheck, ReservationCheckAdmin)

9
backend/booking/apps.py Normal file
View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class BookingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'booking'
def ready(self):
import booking.signals # <-- this line is important

View File

@@ -0,0 +1,23 @@
import django_filters
from .models import Event, Reservation
class EventFilter(django_filters.FilterSet):
start_after = django_filters.IsoDateTimeFilter(field_name="start", lookup_expr="gte")
end_before = django_filters.IsoDateTimeFilter(field_name="end", lookup_expr="lte")
city = django_filters.CharFilter(field_name="square__city", lookup_expr="icontains")
square = django_filters.NumberFilter(field_name="square__id") # přidáno filtrování podle ID náměstí
class Meta:
model = Event
fields = ["start_after", "end_before", "city", "square"] # přidáno "square"
class ReservationFilter(django_filters.FilterSet):
event = django_filters.NumberFilter(field_name="event__id")
user = django_filters.NumberFilter(field_name="user__id")
status = django_filters.ChoiceFilter(choices=Reservation.STATUS_CHOICES)
class Meta:
model = Reservation
fields = ["event", "user", "status"]

21
backend/booking/forms.py Normal file
View File

@@ -0,0 +1,21 @@
from django import forms
from django.core.exceptions import ValidationError
from .models import Reservation
class ReservationAdminForm(forms.ModelForm):
class Meta:
model = Reservation
fields = '__all__'
def clean(self):
cleaned_data = super().clean()
event = cleaned_data.get('event')
products = cleaned_data.get('event_products')
if event and products:
invalid_products = [p for p in products if p.event != event]
if invalid_products:
product_names = ', '.join(str(p) for p in invalid_products)
raise ValidationError(f"Některé produkty nepatří k této akci: {product_names}")
return cleaned_data

View File

View File

@@ -0,0 +1,55 @@
# yourapp/management/commands/seed_celery_beat.py
import json
from django.utils import timezone
from django.core.management.base import BaseCommand
from django_celery_beat.models import PeriodicTask, IntervalSchedule, CrontabSchedule
class Command(BaseCommand):
help = "Seeds the database with predefined Celery Beat tasks."
def handle(self, *args, **kwargs):
# # Example 1 — Run every 10 minutes
# schedule, _ = IntervalSchedule.objects.get_or_create(
# every=10,
# period=IntervalSchedule.MINUTES,
# )
# Example 2 — Run each 5 minutes
crontab_delete_unpayed, _ = CrontabSchedule.objects.get_or_create(
minute='*/5',
hour='*',
day_of_week='*',
day_of_month='*',
month_of_year='*',
timezone=timezone.get_current_timezone_name(),
)
PeriodicTask.objects.get_or_create(
name='Zrušení nezaplacených rezervací',
task='booking.tasks.cancel_unpayed_reservations_task',
crontab=crontab_delete_unpayed,
args=json.dumps([]), # Optional arguments
kwargs=json.dumps({"minutes": 30}),
description="Maže Rezervace podle Objednávky, pokud ta nebyla zaplacena v době 30 minut. Tím se uvolní Prodejní Místa pro nové rezervace.\nJako vstupní argument může být zadán počet minut, podle kterého nezaplacená rezervaace bude stornovana."
)
crontab_delete_soft, _ = CrontabSchedule.objects.get_or_create(
minute='0',
hour='1',
day_of_week='*',
day_of_month='1',
month_of_year='*',
timezone=timezone.get_current_timezone_name(),
)
PeriodicTask.objects.get_or_create(
name='Skartace soft-smazaných záznamů',
task='booking.tasks.hard_delete_soft_deleted_records_task',
crontab=crontab_delete_soft,
args=json.dumps([]), # Optional arguments
kwargs=json.dumps({"years": 10, "days": 0}), # Optional kwargs
description="Mazání všech záznamů označených jako smazané v databázi.\nJako vstupní argument lze zadat počet let nebo dnů, podle kterého se určí, jak staré záznamy budou trvale odstraněny."
)
self.stdout.write(self.style.SUCCESS("✅ Celery Beat tasks have been seeded."))

View File

@@ -0,0 +1,111 @@
# Generated by Django 5.2.4 on 2025-08-07 15:13
import django.core.validators
import django.db.models.deletion
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Event',
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)),
('description', models.TextField(blank=True, null=True)),
('start', models.DateField()),
('end', models.DateField()),
('price_per_m2', models.DecimalField(decimal_places=2, help_text='Cena za m² pro rezervaci', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])),
('image', models.ImageField(blank=True, null=True, upload_to='squares-imgs/')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ReservationCheck',
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)),
('checked_at', models.DateTimeField(auto_now_add=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Square',
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(default='', max_length=255)),
('description', models.TextField(blank=True, null=True)),
('street', models.CharField(default='Ulice není zadaná', max_length=255)),
('city', models.CharField(default='Město není zadané', max_length=255)),
('psc', models.PositiveIntegerField(default=12345, help_text='Zadejte platné PSČ (5 číslic)', validators=[django.core.validators.MaxValueValidator(99999), django.core.validators.MinValueValidator(10000)])),
('width', models.PositiveIntegerField(default=10)),
('height', models.PositiveIntegerField(default=10)),
('grid_rows', models.PositiveSmallIntegerField(default=60)),
('grid_cols', models.PositiveSmallIntegerField(default=45)),
('cellsize', models.PositiveIntegerField(default=10)),
('image', models.ImageField(blank=True, null=True, upload_to='squares-imgs/')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MarketSlot',
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)),
('status', models.CharField(choices=[('allowed', 'Povoleno'), ('blocked', 'Zablokováno')], default='allowed', max_length=20)),
('number', models.PositiveSmallIntegerField(default=1, editable=False, help_text='Pořadové číslo prodejního místa na svém Eventu')),
('base_size', models.FloatField(default=0, help_text='Základní velikost (m²)', validators=[django.core.validators.MinValueValidator(0.0)])),
('available_extension', models.FloatField(default=0, help_text='Možnost rozšíření (m²)', validators=[django.core.validators.MinValueValidator(0.0)])),
('x', models.SmallIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
('y', models.SmallIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
('width', models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
('height', models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
('price_per_m2', models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Cena za m² pro toto prodejní místo. Neuvádět, pokud chcete nechat výchozí cenu za m² na tomto Eventu.', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_marketSlots', to='booking.event')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Reservation',
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)),
('used_extension', models.FloatField(default=0, help_text='Použité rozšíření (m2)', validators=[django.core.validators.MinValueValidator(0.0)])),
('reserved_from', models.DateField()),
('reserved_to', models.DateField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(choices=[('reserved', 'Zarezervováno'), ('cancelled', 'Zrušeno')], default='reserved', max_length=20)),
('note', models.TextField(blank=True, null=True)),
('final_price', models.DecimalField(decimal_places=2, default=0, help_text='Cena vypočtena automaticky na zakladě ceny za m² prodejního místa a počtu dní rezervace.', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])),
('price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('is_checked', models.BooleanField(default=False)),
('last_checked_at', models.DateTimeField(blank=True, null=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_reservations', to='booking.event')),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.2.4 on 2025-08-07 15:13
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('booking', '0001_initial'),
('product', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='reservation',
name='event_products',
field=models.ManyToManyField(blank=True, related_name='reservations', to='product.eventproduct'),
),
migrations.AddField(
model_name='reservation',
name='last_checked_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reservations_checker', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='reservation',
name='market_slot',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='booking.marketslot'),
),
migrations.AddField(
model_name='reservation',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_reservations', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='reservationcheck',
name='checker',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='performed_checks', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='reservationcheck',
name='reservation',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checks', to='booking.reservation'),
),
migrations.AddField(
model_name='event',
name='square',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='square_events', to='booking.square'),
),
]

View File

395
backend/booking/models.py Normal file
View File

@@ -0,0 +1,395 @@
from decimal import Decimal
from django.db import models
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.conf import settings
from django.db.models import Max
from django.utils import timezone
from trznice.models import SoftDeleteModel
from trznice.utils import truncate_to_minutes
#náměstí
class Square(SoftDeleteModel):
name = models.CharField(max_length=255, default="", null=False, blank=False)
description = models.TextField(null=True, blank=True)
street = models.CharField(max_length=255, default="Ulice není zadaná", null=False, blank=False)
city = models.CharField(max_length=255, default="Město není zadané", null=False, blank=False)
psc = models.PositiveIntegerField(
default=12345,
validators=[
MaxValueValidator(99999),
MinValueValidator(10000)
],
help_text="Zadejte platné PSČ (5 číslic)",
null=False, blank=False,
)
width = models.PositiveIntegerField(default=10)
height = models.PositiveIntegerField(default=10)
#Grid Parameters
grid_rows = models.PositiveSmallIntegerField(default=60)
grid_cols = models.PositiveSmallIntegerField(default=45)
cellsize = models.PositiveIntegerField(default=10)
image = models.ImageField(upload_to="squares-imgs/", blank=True, null=True)
def clean(self):
if self.width <= 0 :
raise ValidationError("Šířka náměstí nemůže být menší nebo rovna nule.")
if self.height <= 0:
raise ValidationError("Výška náměstí nemůže být menší nebo rovna nule.")
if self.grid_rows <= 0:
raise ValidationError("Počet řádků mapy nemůže být menší nebo rovna nule.")
if self.grid_cols <= 0:
raise ValidationError("Počet sloupců mapy nemůže být menší nebo rovna nule.")
if self.cellsize <= 0:
raise ValidationError("Velikost mapové buňky nemůže být menší nebo rovna nule.")
return super().clean()
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
def __str__(self):
return self.name
def delete(self, *args, **kwargs):
for event in self.square_events.all():
event.delete() # ✅ This triggers Event.delete()
super().delete(*args, **kwargs)
class Event(SoftDeleteModel):
"""Celé náměstí
Args:
models (args): w,h skutečné rozměry náměstí | x,y souřadnice levého horního rohu
"""
name = models.CharField(max_length=255, null=False, blank=False)
description = models.TextField(blank=True, null=True)
square = models.ForeignKey(Square, on_delete=models.CASCADE, related_name="square_events", null=False, blank=False)
start = models.DateField()
end = models.DateField()
price_per_m2 = models.DecimalField(max_digits=8, decimal_places=2, help_text="Cena za m² pro rezervaci", validators=[MinValueValidator(0)], null=False, blank=False)
image = models.ImageField(upload_to="squares-imgs/", blank=True, null=True)
def clean(self):
if not (self.start and self.end):
raise ValidationError("Datum začátku a konce musí být neprázdné.")
# Remove truncate_to_minutes and timezone logic
if self.start >= self.end:
raise ValidationError("Datum začátku musí být před datem konce.")
overlapping = Event.objects.exclude(id=self.id).filter(
square=self.square,
start__lt=self.end,
end__gt=self.start,
)
if overlapping.exists():
raise ValidationError("V tomto termínu už na daném náměstí probíhá jiná událost.")
return super().clean()
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
def __str__(self):
return self.name
def delete(self, *args, **kwargs):
for market_slot in self.event_marketSlots.all():
market_slot.delete()
# self.event_marketSlots.all().update(is_deleted=True, deleted_at=timezone.now())
# self.event_reservations.all().update(is_deleted=True, deleted_at=timezone.now())
self.event_products.all().update(is_deleted=True, deleted_at=timezone.now())
return super().delete(*args, **kwargs)
class MarketSlot(SoftDeleteModel):
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="event_marketSlots", null=False, blank=False)
STATUS_CHOICES = [
("allowed", "Povoleno"),
("blocked", "Zablokováno"),
]
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="allowed")
number = models.PositiveSmallIntegerField(default=1, help_text="Pořadové číslo prodejního místa na svém Eventu", editable=False)
base_size = models.FloatField(default=0, help_text="Základní velikost (m²)", validators=[MinValueValidator(0.0)], null=False, blank=False)
available_extension = models.FloatField(default=0, help_text="Možnost rozšíření (m²)", validators=[MinValueValidator(0.0)], null=False, blank=False)
x = models.SmallIntegerField(default=0, blank=False, validators=[MinValueValidator(0)])
y = models.SmallIntegerField(default=0, blank=False, validators=[MinValueValidator(0)])
width = models.PositiveIntegerField(default=0, blank=False, validators=[MinValueValidator(0)])
height = models.PositiveIntegerField(default=0, blank=False, validators=[MinValueValidator(0)])
price_per_m2 = models.DecimalField(
default=Decimal("0.00"),
max_digits=8,
decimal_places=2,
validators=[MinValueValidator(0)],
help_text="Cena za m² pro toto prodejní místo. Neuvádět, pokud chcete nechat výchozí cenu za m² na tomto Eventu."
)
def clean(self):
if self.base_size <= 0:
raise ValidationError("Základní velikost prodejního místa musí být větší než nula.")
return super().clean()
def save(self, *args, **kwargs):
self.full_clean()
# TODO: Fix this hovno logic, kdy uyivatel zada 0, se nastavi cena. Vymyslet neco noveho
# If price_per_m2 is 0, use the event default
# if self.event and hasattr(self.event, 'price_per_m2'):
if self.price_per_m2 == 0 and self.event and hasattr(self.event, 'price_per_m2'):
self.price_per_m2 = self.event.price_per_m2
# Automatically assign next available number within the same event
if self._state.adding:
max_number = MarketSlot.objects.filter(event=self.event).aggregate(Max('number'))['number__max'] or 0
self.number = max_number + 1
super().save(*args, **kwargs)
def __str__(self):
return f"Prodejní místo {self.number} na {self.event}"
def delete(self, *args, **kwargs):
for reservation in self.reservations.all():
reservation.delete()
# self.marketslot_reservations.all().update(is_deleted=True, deleted_at=timezone.now())
return super().delete(*args, **kwargs)
class Reservation(SoftDeleteModel):
STATUS_CHOICES = [
("reserved", "Zarezervováno"),
("cancelled", "Zrušeno"),
]
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="event_reservations", null=False, blank=False)
market_slot = models.ForeignKey(
'MarketSlot',
on_delete=models.CASCADE,
related_name='reservations',
null=True,
blank=True
)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="user_reservations", null=False, blank=False)
used_extension = models.FloatField(default=0 ,help_text="Použité rozšíření (m2)", validators=[MinValueValidator(0.0)])
reserved_from = models.DateField(null=False, blank=False)
reserved_to = models.DateField(null=False, blank=False)
created_at = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="reserved")
note = models.TextField(blank=True, null=True)
final_price = models.DecimalField(
default=0,
blank=False,
null=False,
max_digits=8,
decimal_places=2,
validators=[MinValueValidator(0)],
help_text="Cena vypočtena automaticky na zakladě ceny za m² prodejního místa a počtu dní rezervace."
)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
null=False,
blank=False
)
event_products = models.ManyToManyField("product.EventProduct", related_name="reservations", blank=True)
# Datails about checking
#TODO: Dodelat frontend
is_checked = models.BooleanField(default=False)
last_checked_at = models.DateTimeField(null=True, blank=True)
last_checked_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="reservations_checker"
)
def update_check_status(self):
last_check = self.checks.filter(is_deleted=False).order_by("-checked_at").first()
if last_check:
self.is_checked = True
self.last_checked_at = last_check.checked_at
self.last_checked_by = last_check.checker
else:
self.is_checked = False
self.last_checked_at = None
self.last_checked_by = None
def calculate_price(self):
# Use market_slot width and height for area
if not self.event or not self.event.square:
raise ValidationError("Rezervace musí mít přiřazenou akci s náměstím.")
if not self.market_slot:
raise ValidationError("Rezervace musí mít přiřazené prodejní místo.")
area = self.market_slot.width * self.market_slot.height
price_per_m2 = None
if self.market_slot.price_per_m2 and self.market_slot.price_per_m2 > 0:
price_per_m2 = self.market_slot.price_per_m2
else:
price_per_m2 = self.event.price_per_m2
if not price_per_m2 or price_per_m2 < 0:
raise ValidationError("Cena za m² není dostupná nebo je záporná.")
# Calculate number of days
days = (self.reserved_to - self.reserved_from).days + 1
# Calculate final price using slot area and reserved days
final_price = Decimal(area) * Decimal(price_per_m2) * Decimal(days)
final_price = final_price.quantize(Decimal("0.01"))
return final_price
def clean(self):
if not self.reserved_from or not self.reserved_to:
raise ValidationError("Datum rezervace nemůže být prázdný.")
# Remove truncate_to_minutes and timezone logic
if self.reserved_from > self.reserved_to:
raise ValidationError("Datum začátku rezervace musí být dříve než její konec.")
if self.reserved_from == self.reserved_to:
raise ValidationError("Začátek a konec rezervace nemohou být stejné.")
# Only check for overlapping reservations on the same market_slot
if self.market_slot:
overlapping = Reservation.objects.exclude(id=self.id).filter(
market_slot=self.market_slot,
status="reserved",
reserved_from__lt=self.reserved_to,
reserved_to__gt=self.reserved_from,
)
else:
raise ValidationError("Rezervace musí mít v sobě prodejní místo (MarketSlot).")
if overlapping.exists():
raise ValidationError("Rezervace se překrývá s jinou rezervací na stejném místě.")
# Check event bounds (date only)
if self.event:
event_start = self.event.start
event_end = self.event.end
if self.reserved_from < event_start or self.reserved_to > event_end:
raise ValidationError("Rezervace musí být v rámci trvání akce.")
if self.used_extension > self.market_slot.available_extension:
raise ValidationError("Požadované rozšíření je větší než možné rožšíření daného prodejního místa.")
if self.market_slot and self.event != self.market_slot.event:
raise ValidationError(f"Prodejní místo {self.market_slot} není část této akce, musí být ze stejné akce jako rezervace.")
if self.user:
if self.user.user_reservations.all().count() > 5:
raise ValidationError(f"{self.user} už má 5 rezervací, víc není možno rezervovat pro jednoho uživatele.")
else:
raise ValidationError("Rezervace musí mít v sobě uživatele.")
if self.final_price == 0 or self.final_price is None:
self.final_price = self.calculate_price()
elif self.final_price < 0:
raise ValidationError("Cena nemůže být záporná.")
return super().clean()
def save(self, *args, validate=True, **kwargs):
if validate:
self.full_clean()
super().save(*args, **kwargs)
def __str__(self):
return f"Rezervace {self.user} na event {self.event.name}"
def delete(self, *args, **kwargs):
order = getattr(self, "order", None)
if order is not None:
order.delete()
# Fix: Use a valid status value for MarketSlot
if self.market_slot:
event_end_date = self.market_slot.event.end
now_date = timezone.now().date()
if event_end_date > now_date:
self.market_slot.status = "allowed"
self.market_slot.save()
self.checks.all().update(is_deleted=True, deleted_at=timezone.now())
return super().delete(*args, **kwargs)
class ReservationCheck(SoftDeleteModel):
reservation = models.ForeignKey(
Reservation,
on_delete=models.CASCADE,
related_name="checks"
)
checker = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="performed_checks"
)
checked_at = models.DateTimeField(auto_now_add=True)
def clean(self):
# Check checker role
if not self.checker or not hasattr(self.checker, "role") or self.checker.role not in ["admin", "checker"]:
raise ValidationError("Uživatel není Kontrolor.")
# Validate reservation existence (safe check)
if not Reservation.objects.filter(pk=self.reservation_id).exists():
raise ValidationError("Neplatné ID Rezervace.")
super().clean()
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
self.is_deleted = True
self.deleted_at = timezone.now()
self.save()
from .signals import update_reservation_check_status
# Simulate post_delete behavior
update_reservation_check_status(sender=ReservationCheck, instance=self)

View File

@@ -0,0 +1,602 @@
from rest_framework import serializers
from datetime import timedelta
from booking.models import Event, MarketSlot
import logging
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
try:
from commerce.serializers import PriceCalculationSerializer
except ImportError:
PriceCalculationSerializer = None
from trznice.utils import RoundedDateTimeField
from .models import Event, MarketSlot, Reservation, Square, ReservationCheck
from account.models import CustomUser
from product.serializers import EventProductSerializer
logger = logging.getLogger(__name__)
#----------------------SHORT SERIALIZERS---------------------------------
class EventShortSerializer(serializers.ModelSerializer):
class Meta:
model = Square
fields = ["id", "name"]
extra_kwargs = {
"id": {"read_only": True},
"name": {"read_only": True, "help_text": "Název náměstí"}
}
class UserShortSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
fields = ["id", "username"]
extra_kwargs = {
"id": {"read_only": True},
"username": {"read_only": True, "help_text": "username uživatele"}
}
class SquareShortSerializer(serializers.ModelSerializer):
class Meta:
model = Square
fields = ["id", "name"]
extra_kwargs = {
"id": {"read_only": True},
"name": {"read_only": True, "help_text": "Název náměstí"}
}
class ReservationShortSerializer(serializers.ModelSerializer):
user = UserShortSerializer(read_only=True)
event = EventShortSerializer(read_only=True)
class Meta:
model = Reservation
fields = ["id", "user", "event"]
extra_kwargs = {
"id": {"read_only": True},
"user": {"read_only": True, "help_text": "Majitel rezervace"},
"event": {"read_only": True, "help_text": "Akce na které je vytvořena rezervace"}
}
#------------------------------------------------------------------------
#------------------------NORMAL SERIALIZERS------------------------------
class ReservationCheckSerializer(serializers.ModelSerializer):
reservation = serializers.PrimaryKeyRelatedField(
queryset=Reservation.objects.all(),
write_only=True,
help_text="ID rezervace, která se kontroluje."
)
reservation_info = ReservationShortSerializer(source="reservation", read_only=True)
checker = serializers.HiddenField(default=serializers.CurrentUserDefault())
checker_info = UserShortSerializer(source="checker", read_only=True)
class Meta:
model = ReservationCheck
fields = [
"id", "reservation", "reservation_info",
"checker", "checker_info", "checked_at"
]
read_only_fields = ["id", "checked_at"]
def validate_reservation(self, value):
if value.status != "reserved":
raise serializers.ValidationError("Rezervaci lze kontrolovat pouze pokud je ve stavu 'reserved'.")
return value
def validate_checker(self, value):
user = self.context["request"].user
if not user.is_staff and value != user:
raise serializers.ValidationError("Pouze administrátor může nastavit jiného uživatele jako kontrolora.")
return value
class ReservationSerializer(serializers.ModelSerializer):
reserved_from = serializers.DateField()
reserved_to = serializers.DateField()
event = EventShortSerializer(read_only=True)
user = UserShortSerializer(read_only=True)
market_slot = serializers.PrimaryKeyRelatedField(
queryset=MarketSlot.objects.filter(is_deleted=False), required=True
)
last_checked_by = UserShortSerializer(read_only=True)
class Meta:
model = Reservation
fields = [
"id", "market_slot",
"used_extension", "reserved_from", "reserved_to",
"created_at", "status", "note", "final_price",
"event", "user", "is_checked", "last_checked_by", "last_checked_at"
]
read_only_fields = ["id", "created_at", "is_checked", "last_checked_by", "last_checked_at"]
extra_kwargs = {
"event": {"help_text": "ID (Event), ke které rezervace patří", "required": True},
"market_slot": {"help_text": "ID konkrétního prodejního místa (MarketSlot)", "required": True},
"user": {"help_text": "ID a název uživatele, který rezervaci vytváří", "required": True},
"used_extension": {"help_text": "Velikost rozšíření v m², které chce uživatel využít", "required": True},
"reserved_from": {"help_text": "Datum a čas začátku rezervace", "required": True},
"reserved_to": {"help_text": "Datum a čas konce rezervace", "required": True},
"status": {"help_text": "Stav rezervace (reserved / cancelled)", "required": False, "default": "reserved"},
"note": {"help_text": "Poznámka k rezervaci (volitelné)", "required": False},
"final_price": {"help_text": "Cena za Rezervaci, počítá se podle plochy prodejního místa a počtů dní.", "required": False, "default": 0},
"is_checked": {"help_text": "Stav je True, pokud již byla provedena aspoň jedna kontrola.", "required": False, "read_only": True},
"last_checked_by": {"help_text": "Kontrolor, který provedl poslední kontrolu.", "required": False, "read_only": True},
"last_checked_at": {"help_text": "Čas kdy byla provedena poslední kontrola.", "required": False, "read_only": True}
}
def to_internal_value(self, data):
# Accept both "market_slot" and legacy "marketSlot" keys for compatibility
if "marketSlot" in data and "market_slot" not in data:
data["market_slot"] = data["marketSlot"]
# Debug: log incoming data for troubleshooting
logger.debug(f"ReservationSerializer.to_internal_value input data: {data}")
return super().to_internal_value(data)
def to_internal_value(self, data):
# Accept both "market_slot" and legacy "marketSlot" keys for compatibility
if "marketSlot" in data and "market_slot" not in data:
data["market_slot"] = data["marketSlot"]
# Debug: log incoming data for troubleshooting
logger.debug(f"ReservationSerializer.to_internal_value input data: {data}")
return super().to_internal_value(data)
def validate(self, data):
logger.debug(f"ReservationSerializer.validate market_slot: {data.get('market_slot')}, event: {data.get('event')}")
# Get the event object from the provided event id (if present)
event_id = self.initial_data.get("event")
if event_id:
try:
event = Event.objects.get(pk=event_id)
data["event"] = event
except Event.DoesNotExist:
raise serializers.ValidationError({"event": "Zadaná akce (event) neexistuje."})
else:
event = data.get("event")
market_slot = data.get("market_slot")
# --- FIX: Ensure event is set before permission check in views ---
if event is None and market_slot is not None:
event = market_slot.event
data["event"] = event
logger.debug(f"ReservationSerializer.validate auto-filled event from market_slot: {event}")
user = data.get("user")
request_user = self.context["request"].user if "request" in self.context else None
# If user is not specified, use the logged-in user
if user is None and request_user is not None:
user = request_user
data["user"] = user
# If user is specified and differs from logged-in user, check permissions
if user is not None and request_user is not None and user != request_user:
if request_user.role not in ["admin", "cityClerk", "squareManager"]:
raise serializers.ValidationError("Pouze administrátor, úředník nebo správce tržiště může vytvářet rezervace pro jiné uživatele.")
if user is None:
raise serializers.ValidationError("Rezervace musí mít přiřazeného uživatele.")
if user.user_reservations.filter(status="reserved").count() >= 5:
raise serializers.ValidationError("Uživatel už má 5 aktivních rezervací.")
reserved_from = data.get("reserved_from")
reserved_to = data.get("reserved_to")
used_extension = data.get("used_extension", 0)
final_price = data.get("final_price", 0)
if "status" in data:
if self.instance: # update
if data["status"] != self.instance.status and user.role not in ["admin", "cityClerk"]:
raise serializers.ValidationError({
"status": "Pouze administrátor nebo úředník může upravit status rezervace."
})
else:
data["status"] = "reserved"
privileged_roles = ["admin", "cityClerk"]
# Define max allowed price based on model's decimal constraints (8 digits, 2 decimal places)
MAX_FINAL_PRICE = Decimal("999999.99")
if user and getattr(user, "role", None) in privileged_roles:
# 🧠 Automatický výpočet ceny rezervace pokud není zadána
if not final_price or final_price == 0:
market_slot = data.get("market_slot")
event = data.get("event")
reserved_from = data.get("reserved_from")
reserved_to = data.get("reserved_to")
used_extension = data.get("used_extension", 0)
# --- Prefer PriceCalculationSerializer if available ---
if PriceCalculationSerializer:
try:
price_serializer = PriceCalculationSerializer(data={
"market_slot": market_slot.id if market_slot else None,
"used_extension": used_extension,
"reserved_from": reserved_from,
"reserved_to": reserved_to,
"event": event.id if event else None,
"user": user.id if user else None,
})
price_serializer.is_valid(raise_exception=True)
calculated_price = price_serializer.validated_data.get("final_price")
if calculated_price is not None:
try:
# Always quantize to two decimals
decimal_price = Decimal(str(calculated_price)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Clamp value to max allowed and raise error if exceeded
if decimal_price > MAX_FINAL_PRICE:
logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})")
data["final_price"] = MAX_FINAL_PRICE
raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."})
else:
data["final_price"] = decimal_price
except (InvalidOperation, TypeError, ValueError):
raise serializers.ValidationError("Výsledná cena není platné číslo.")
else:
raise serializers.ValidationError("Výpočet ceny selhal.")
except Exception as e:
logger.error(f"PriceCalculationSerializer failed: {e}", exc_info=True)
market_slot = data.get("market_slot")
event = data.get("event")
reserved_from = data.get("reserved_from")
reserved_to = data.get("reserved_to")
used_extension = data.get("used_extension", 0)
price_per_m2 = data.get("price_per_m2")
if price_per_m2 is None:
if market_slot and hasattr(market_slot, "price_per_m2"):
price_per_m2 = market_slot.price_per_m2
elif event and hasattr(event, "price_per_m2"):
price_per_m2 = event.price_per_m2
else:
raise serializers.ValidationError("Cena za m² není dostupná.")
base_size = getattr(market_slot, "base_size", None)
if base_size is None:
raise serializers.ValidationError("Základní velikost (base_size) není dostupná.")
duration_days = (reserved_to - reserved_from).days
base_size_decimal = Decimal(str(base_size))
used_extension_decimal = Decimal(str(used_extension))
duration_days_decimal = Decimal(str(duration_days))
price_per_m2_decimal = Decimal(str(price_per_m2))
calculated_price = duration_days_decimal * (price_per_m2_decimal * (base_size_decimal + used_extension_decimal))
try:
decimal_price = Decimal(str(calculated_price)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Clamp value to max allowed and raise error if exceeded
if decimal_price > MAX_FINAL_PRICE:
logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})")
data["final_price"] = MAX_FINAL_PRICE
raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."})
else:
data["final_price"] = decimal_price
except (InvalidOperation, TypeError, ValueError):
raise serializers.ValidationError("Výsledná cena není platné číslo.")
else:
price_per_m2 = data.get("price_per_m2")
if price_per_m2 is None:
if market_slot and hasattr(market_slot, "price_per_m2"):
price_per_m2 = market_slot.price_per_m2
elif event and hasattr(event, "price_per_m2"):
price_per_m2 = event.price_per_m2
else:
raise serializers.ValidationError("Cena za m² není dostupná.")
resolution = event.square.cellsize if event and hasattr(event, "square") else 1
width = getattr(market_slot, "width", 1)
height = getattr(market_slot, "height", 1)
# If you want to include used_extension, add it to area
area_m2 = Decimal(width) * Decimal(height) * Decimal(resolution) * Decimal(resolution)
duration_days = (reserved_to - reserved_from).days
price_per_m2_decimal = Decimal(str(price_per_m2))
calculated_price = Decimal(duration_days) * area_m2 * price_per_m2_decimal
try:
decimal_price = Decimal(str(calculated_price)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Clamp value to max allowed and raise error if exceeded
if decimal_price > MAX_FINAL_PRICE:
logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})")
data["final_price"] = MAX_FINAL_PRICE
raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."})
else:
data["final_price"] = decimal_price
except (InvalidOperation, TypeError, ValueError):
raise serializers.ValidationError("Výsledná cena není platné číslo.")
else:
if self.instance: # update
if final_price != self.instance.final_price and (not user or user.role not in privileged_roles):
raise serializers.ValidationError({
"final_price": "Pouze administrátor nebo úředník může upravit finální cenu."
})
else: # create
if not user or user.role not in privileged_roles:
raise serializers.ValidationError({
"final_price": "Pouze administrátor nebo úředník může nastavit finální cenu."
})
if data.get("final_price") is not None:
try:
decimal_price = Decimal(str(data["final_price"])).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Clamp value to max allowed and raise error if exceeded
if decimal_price > MAX_FINAL_PRICE:
logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})")
data["final_price"] = MAX_FINAL_PRICE
raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."})
data["final_price"] = decimal_price
except (InvalidOperation, TypeError, ValueError):
raise serializers.ValidationError("Výsledná cena není platné číslo.")
if data.get("final_price") < 0:
raise serializers.ValidationError("Cena za m² nemůže být záporná.")
else:
# Remove final_price if not privileged
data.pop("final_price", None)
if reserved_from >= reserved_to:
raise serializers.ValidationError("Datum začátku rezervace musí být dříve než její konec.")
if reserved_from < event.start or reserved_to > event.end:
raise serializers.ValidationError("Rezervace musí být v rámci trvání akce.")
overlapping = None
if market_slot:
if market_slot.event != event:
raise serializers.ValidationError("Prodejní místo nepatří do dané akce.")
if used_extension > market_slot.available_extension:
raise serializers.ValidationError("Požadované rozšíření překračuje dostupné rozšíření.")
overlapping = Reservation.objects.exclude(id=self.instance.id if self.instance else None).filter(
event=event,
market_slot=market_slot,
reserved_from__lt=reserved_to,
reserved_to__gt=reserved_from,
status="reserved"
)
if overlapping is not None and overlapping.exists():
logger.debug(f"ReservationSerializer.validate: Found overlapping reservations for market_slot {market_slot.id} in event {event.id}")
raise serializers.ValidationError("Rezervace se překrývá s jinou rezervací na stejném místě.")
return data
class ReservationAvailabilitySerializer(serializers.Serializer):
event_id = serializers.IntegerField()
market_slot_id = serializers.IntegerField()
reserved_from = serializers.DateField()
reserved_to = serializers.DateField()
class Meta:
model = Reservation
fields = ["event", "market_slot", "reserved_from", "reserved_to"]
extra_kwargs = {
"event": {"help_text": "ID of the event"},
"market_slot": {"help_text": "ID of the market slot"},
"reserved_from": {"help_text": "Start date of the reservation"},
"reserved_to": {"help_text": "End date of the reservation"},
}
def validate(self, data):
event_id = data.get("event_id")
market_slot_id = data.get("market_slot_id")
reserved_from = data.get("reserved_from")
reserved_to = data.get("reserved_to")
if reserved_from >= reserved_to:
raise serializers.ValidationError("Konec rezervace musí být po začátku.")
# Zkontroluj existenci Eventu a Slotu
try:
event = Event.objects.get(id=event_id)
except Event.DoesNotExist:
raise serializers.ValidationError("Událost neexistuje.")
try:
market_slot = MarketSlot.objects.get(id=market_slot_id)
except MarketSlot.DoesNotExist:
raise serializers.ValidationError("Slot neexistuje.")
# Zkontroluj status slotu
if market_slot.status == "blocked":
raise serializers.ValidationError("Tento slot je zablokovaný správcem.")
# Zkontroluj, že datumy spadají do rozsahu události
if reserved_from < event.date_from or reserved_to > event.date_to:
raise serializers.ValidationError("Vybrané datumy nespadají do trvání akce.")
# Zkontroluj, jestli už neexistuje kolizní rezervace
conflict = Reservation.objects.filter(
event=event,
market_slot=market_slot,
reserved_from__lt=reserved_to,
reserved_to__gt=reserved_from,
status="reserved"
).exists()
if conflict:
raise serializers.ValidationError("Tento slot je v daném termínu již rezervován.")
return data
#--- Reservation end ----
class MarketSlotSerializer(serializers.ModelSerializer):
class Meta:
model = MarketSlot
fields = [
"id", "event", "number", "status",
"base_size", "available_extension",
"x", "y", "width", "height",
"price_per_m2"
]
read_only_fields = ["id", "number"]
extra_kwargs = {
"event": {"help_text": "ID akce (Event), ke které toto místo patří", "required": True},
"number": {"help_text": "Pořadové číslo prodejního místa u Akce, ke které toto místo patří", "required": False},
"status": {"help_text": "Stav prodejního místa", "required": False},
"base_size": {"help_text": "Základní velikost (m²)", "required": True},
"available_extension": {"help_text": "Možnost rozšíření (m²)", "required": False, "default": 0},
"x": {"help_text": "X souřadnice levého horního rohu", "required": True},
"y": {"help_text": "Y souřadnice levého horního rohu", "required": True},
"width": {"help_text": "Šířka Slotu", "required": True},
"height": {"help_text": "Výška Slotu", "required": True},
"price_per_m2": {"help_text": "Cena za m² tohoto místa", "required": False, "default": 0},
}
def validate_base_size(self, value):
if value <= 0:
raise serializers.ValidationError("Základní velikost musí být větší než nula.")
return value
def validate(self, data):
price_per_m2 = data.setdefault("price_per_m2", 0)
if price_per_m2 < 0:
raise serializers.ValidationError("Cena za m² nemůže být záporná.")
if data.setdefault("available_extension", 0) < 0:
raise serializers.ValidationError("Velikost možného rozšíření musí být větší než nula.")
if data.get("width", 0) <= 0 or data.get("height", 0) <= 0:
raise serializers.ValidationError("Šířka a výška místa musí být větší než nula.")
return data
class EventSerializer(serializers.ModelSerializer):
square = SquareShortSerializer(read_only=True)
square_id = serializers.PrimaryKeyRelatedField(
queryset=Square.objects.all(), source="square", write_only=True
)
market_slots = MarketSlotSerializer(many=True, read_only=True, source="event_marketSlots")
event_products = EventProductSerializer(many=True, read_only=True)
start = serializers.DateField()
end = serializers.DateField()
class Meta:
model = Event
fields = [
"id", "name", "description", "start", "end", "price_per_m2", "image", "market_slots", "event_products",
"square", # nested read-only
"square_id" # required in POST/PUT
]
read_only_fields = ["id"]
extra_kwargs = {
"name": {"help_text": "Název události", "required": True},
"description": {"help_text": "Popis události", "required": False},
"start": {"help_text": "Datum a čas začátku události", "required": True},
"end": {"help_text": "Datum a čas konce události", "required": True},
"price_per_m2": {"help_text": "Cena za m² pro rezervaci", "required": True},
"image": {"help_text": "Obrázek nebo plán náměstí", "required": False, "allow_null": True},
"market_slots": {"help_text": "Seznam prodejních míst vytvořených v rámci této události", "required": False},
"event_products": {"help_text": "Seznam povolených zboží k prodeji v rámci této události", "required": False},
"square": {"help_text": "Náměstí, na kterém se akce koná (jen ke čtení)", "required": False},
"square_id": {"help_text": "ID Náměstí, na kterém se akce koná (jen ke zápis)", "required": True},
}
def validate(self, data):
start = data.get("start")
end = data.get("end")
square = data.get("square")
if not start or not end or not square:
raise serializers.ValidationError("Pole start, end a square musí být vyplněné.")
if start >= end:
raise serializers.ValidationError("Datum začátku musí být před datem konce.")
if data.get("price_per_m2", 0) <= 0:
raise serializers.ValidationError("Cena za m² plochy pro rezervaci musí být větší než 0.")
overlapping = Event.objects.exclude(id=self.instance.id if self.instance else None).filter(
square=square,
start__lt=end,
end__gt=start,
)
if overlapping.exists():
raise serializers.ValidationError("V tomto termínu už na daném náměstí probíhá jiná událost.")
return data
class SquareSerializer(serializers.ModelSerializer):
image = serializers.ImageField(required=False, allow_null=True) # Ensure DRF handles image upload
class Meta:
model = Square
fields = [
"id", "name", "description", "street", "city", "psc",
"width", "height", "grid_rows", "grid_cols", "cellsize",
"image"
]
read_only_fields = ["id"]
extra_kwargs = {
"name": {"help_text": "Název náměstí", "required": True},
"description": {"help_text": "Popis náměstí", "required": False},
"street": {"help_text": "Ulice, kde se náměstí nachází", "required": False},
"city": {"help_text": "Město, kde se náměstí nachází", "required": False},
"psc": {"help_text": "PSČ (5 číslic)", "required": False},
"width": {"help_text": "Šířka náměstí v metrech", "required": True},
"height": {"help_text": "Výška náměstí v metrech", "required": True},
"grid_rows": {"help_text": "Počet řádků gridu", "required": True},
"grid_cols": {"help_text": "Počet sloupců gridu", "required": True},
"cellsize": {"help_text": "Velikost buňky gridu v pixelech", "required": True},
"image": {"help_text": "Obrázek / mapa náměstí", "required": False},
}
#-----------------------------------------------------------------------
class ReservedDaysSerializer(serializers.Serializer):
market_slot_id = serializers.IntegerField()
reserved_days = serializers.ListField(child=serializers.DateField(), read_only=True)
def to_representation(self, instance):
# Accept instance as dict or int
if isinstance(instance, dict):
market_slot_id = instance.get("market_slot_id")
else:
market_slot_id = instance # assume int
try:
market_slot = MarketSlot.objects.get(id=market_slot_id)
except MarketSlot.DoesNotExist:
return {"market_slot_id": market_slot_id, "reserved_days": []}
# Get all reserved days for this slot, return each day individually
reservations = Reservation.objects.filter(
market_slot_id=market_slot_id,
status="reserved"
)
reserved_days = set()
for reservation in reservations:
current = reservation.reserved_from
end = reservation.reserved_to
# Convert to date if it's a datetime
if hasattr(current, "date"):
current = current.date()
if hasattr(end, "date"):
end = end.date()
# Include both start and end dates
while current <= end:
reserved_days.add(current)
current += timedelta(days=1)
# Return reserved days as a sorted list of individual dates
return {
"market_slot_id": market_slot_id,
"reserved_days": sorted(reserved_days)
}

View File

@@ -0,0 +1,9 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from booking.models import ReservationCheck
@receiver([post_save, post_delete], sender=ReservationCheck)
def update_reservation_check_status(sender, instance, **kwargs):
reservation = instance.reservation
reservation.update_check_status()
reservation.save(update_fields=["is_checked", "last_checked_at", "last_checked_by"])

116
backend/booking/tasks.py Normal file
View File

@@ -0,0 +1,116 @@
from celery import shared_task
from celery.utils.log import get_task_logger
from django.conf import settings
from rest_framework.response import Response
from django.utils import timezone
from datetime import timedelta, datetime
from django.apps import apps
from trznice.models import SoftDeleteModel
from booking.models import Reservation, MarketSlot
from commerce.models import Order
from account.tasks import send_email_with_context
logger = get_task_logger(__name__)
@shared_task
def test_celery_task():
logger.info("✅ Test task executed successfully!")
return "Hello from Celery!"
def _validate_days_input(years=None, days=None):
if years is not None:
return years * 365 if years > 0 else 365
if days is not None:
return days if days > 0 else 365
return 365 # default fallback
@shared_task
def hard_delete_soft_deleted_records_task(years=None, days=None):
"""
Hard delete všech objektů, které jsou soft-deleted (is_deleted=True)
a zároveň byly označeny jako smazané (deleted_at) před více než zadaným časovým obdobím.
Jako vstupní argument může být zadán počet let nebo dnů, podle kterého se data skartují.
"""
total_days = _validate_days_input(years, days)
time_period = timezone.now() - timedelta(days=total_days)
# Pro všechny modely, které dědí z SoftDeleteModel, smaž staré smazané záznamy
for model in apps.get_models():
if not issubclass(model, SoftDeleteModel):
continue
if not model._meta.managed or model._meta.abstract:
continue
if not hasattr(model, "all_objects"):
continue
# Filtrování soft-deleted a starých
deleted_qs = model.all_objects.filter(is_deleted=True, deleted_at__lt=time_period)
count = deleted_qs.count()
# Pokud budeme chtit použit custom logiku
# for obj in deleted_qs:
# obj.hard_delete()
deleted_qs.delete()
if count > 0:
logger.info(f"Hard deleted {count} records from {model.__name__}")
return "Successfully completed hard_delete_soft_deleted_records_task"
@shared_task
def cancel_unpayed_reservations_task(minutes=30):
"""
Smaže Rezervace podle Objednávky, pokud ta nebyla zaplacena v době 30 minut. Tím se uvolní Prodejní Místa pro nové rezervace.
Jako vstupní argument může být zadán počet minut, podle kterého nezaplacená rezervaace bude stornovana.
"""
if minutes <= 0:
minutes = 30
cutoff_time = timezone.now() - timedelta(minutes=minutes)
orders_qs = Order.objects.select_related("user", "reservation__event").filter(
status="pending",
created_at__lte=cutoff_time,
payed_at__isnull=True
)
count = orders_qs.count()
for order in orders_qs:
order.status = "cancelled"
send_email_with_context(
recipients=order.user.email,
subject="Stornování objednávky",
message=(
f"Vaše objednávka {order.order_number} má rezervaci prodejního místa "
f"na akci {order.reservation.event} a byla stornována po {minutes} minutách nezaplacení."
)
)
order.save()
if count > 0:
logger.info(f"Canceled {count} unpaid orders and released their slots.")
return "Successfully completed delete_unpayed_reservations_task"
# @shared_task
# def delete_old_reservations_task():
# """
# Smaže rezervace starší než 10 let počítané od začátku příštího roku.
# """
# now = timezone.now()
# next_january_1 = datetime(year=now.year + 1, month=1, day=1, tzinfo=timezone.get_current_timezone())
# cutoff_date = next_january_1 - timedelta(days=365 * 10)
# deleted, _ = Reservation.objects.filter(created__lt=cutoff_date).delete()
# print(f"Deleted {deleted} old reservations.")
# return "Successfully completed delete_old_reservations_task"

3
backend/booking/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

16
backend/booking/urls.py Normal file
View File

@@ -0,0 +1,16 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import EventViewSet, ReservationViewSet, SquareViewSet, MarketSlotViewSet, ReservationAvailabilityCheckView, ReservedDaysView, ReservationCheckViewSet
router = DefaultRouter()
router.register(r'events', EventViewSet, basename='event')
router.register(r'reservations', ReservationViewSet, basename='reservation')
router.register(r'squares', SquareViewSet, basename='square')
router.register(r'market-slots', MarketSlotViewSet, basename='market-slot')
router.register(r'checks', ReservationCheckViewSet, basename='reservation-checks')
urlpatterns = [
path('', include(router.urls)),
path('reservations/check', ReservationAvailabilityCheckView.as_view(), name='event-reservation-check'),
path('reserved-days-check/', ReservedDaysView.as_view(), name='reserved-days'),
]

257
backend/booking/views.py Normal file
View File

@@ -0,0 +1,257 @@
from rest_framework import viewsets, filters
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse, OpenApiExample
from .models import Event, Reservation, MarketSlot, Square, ReservationCheck
from .serializers import EventSerializer, ReservationSerializer, MarketSlotSerializer, SquareSerializer, ReservationAvailabilitySerializer, ReservedDaysSerializer, ReservationCheckSerializer
from .filters import EventFilter, ReservationFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import PermissionDenied
from django.core.exceptions import ObjectDoesNotExist
from rest_framework.views import APIView
from account.permissions import *
import logging
import logging
from account.tasks import send_email_verification_task
@extend_schema(
tags=["Square"],
description=(
"Správa náměstí vytvoření, aktualizace a výpis s doplňkovými informacemi (`quarks`) "
"a připojenými eventy. Možno filtrovat podle města, PSČ a velikosti.\n\n"
"🔍 **Fulltextové vyhledávání (`?search=`)** prohledává následující pole:\n"
"- název náměstí (`name`)\n"
"- popis (`description`)\n"
"- ulice (`street`)\n"
"- město (`city`)\n\n"
"**Příklady:** `?search=Ostrava`, `?search=Hlavní třída`"
)
)
class SquareViewSet(viewsets.ModelViewSet):
queryset = Square.objects.prefetch_related("square_events").all().order_by("name")
serializer_class = SquareSerializer
parser_classes = [MultiPartParser, FormParser] # Accept image uploads
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
filterset_fields = ["city", "psc", "width", "height"]
ordering_fields = ["name", "width", "height"]
search_fields = [
"name", # název náměstí
"description", # popis
"street", # ulice
"city", # město
# "psc" je číslo, obvykle do search_fields nepatří, ale můžeš ho filtrovat přes filterset_fields
]
permission_classes = [RoleAllowed("admin", "squareManager")]
def get_queryset(self):
return super().get_queryset()
@extend_schema(
tags=["Event"],
description=(
"Základní operace pro správu událostí (Event). Lze filtrovat podle času, města a velikosti náměstí.\n\n"
"🔍 **Fulltextové vyhledávání (`?search=`)** prohledává:\n"
"- název události (`name`)\n"
"- popis (`description`)\n"
"- název náměstí (`square.name`)\n"
"- město (`square.city`)\n"
"- popis náměstí (`square.description`)\n"
"- ulice (`square.street`)\n\n"
"**Příklady:** `?search=Jarmark`, `?search=Ostrava`, `?search=Masarykovo`"
)
)
class EventViewSet(viewsets.ModelViewSet):
queryset = Event.objects.prefetch_related("event_marketSlots", "event_products").all().order_by("start")
serializer_class = EventSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
filterset_class = EventFilter
ordering_fields = ["start", "end", "price_per_m2"]
search_fields = [
"name", # název události
"description", # popis události
"square__name", # název náměstí
"square__city", # město
"square__description", # popis náměstí (volitelný)
"square__street", # ulice
]
permission_classes = [RoleAllowed("admin", "squareManager")]
@extend_schema(
tags=["MarketSlot"],
description="Vytváření, aktualizace a mazání konkrétních prodejních míst pro události."
)
class MarketSlotViewSet(viewsets.ModelViewSet):
# queryset = MarketSlot.objects.select_related("event").all().order_by("event")
queryset = MarketSlot.objects.all().order_by("event")
serializer_class = MarketSlotSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["event", "status"]
ordering_fields = ["price_per_m2", "x", "y"]
permission_classes = [RoleAllowed("admin", "squareManager")]
@extend_schema(
tags=["Reservation"],
description=(
"Správa rezervací vytvoření, úprava a výpis. Filtrování podle eventu, statusu, uživatele atd."
)
)
class ReservationViewSet(viewsets.ModelViewSet):
queryset = Reservation.objects.all()
serializer_class = ReservationSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_class = ReservationFilter
ordering_fields = ["reserved_from", "reserved_to", "created_at"]
search_fields = [
"event__name",
"event__square__name",
"event__square__city",
"note",
"user__email",
"user__first_name",
"user__last_name",
]
permission_classes = [RoleAllowed("admin", "squareManager", "seller")]
def get_queryset(self):
# queryset = Reservation.objects.select_related("event", "marketSlot", "user").prefetch_related("event_products").order_by("-created_at")
queryset = Reservation.objects.all().order_by("-created_at")
user = self.request.user
if hasattr(user, "role") and user.role == "seller":
return queryset.filter(user=user)
return queryset
# Optionally, override create() to add logging or debug info
def create(self, request, *args, **kwargs):
logger = logging.getLogger(__name__)
logger.debug(f"Reservation create POST data: {request.data}")
try:
return super().create(request, *args, **kwargs)
except Exception as e:
logger.error(f"Error in ReservationViewSet.create: {e}", exc_info=True)
raise
def perform_create(self, serializer):
self._check_blocked_permission(serializer.validated_data)
serializer.save()
def perform_update(self, serializer):
self._check_blocked_permission(serializer.validated_data)
serializer.save()
def _check_blocked_permission(self, data):
# FIX: Always get the MarketSlot instance, not just the ID
# Accept both "market_slot" (object or int) and "marketSlot" (legacy)
slot = data.get("market_slot") or data.get("marketSlot")
# If slot is a MarketSlot instance, get its id
if hasattr(slot, "id"):
slot_id = slot.id
else:
slot_id = slot
if not isinstance(slot_id, int):
raise PermissionDenied("Neplatné ID prodejního místa.")
try:
market_slot = MarketSlot.objects.get(pk=slot_id)
except ObjectDoesNotExist:
raise PermissionDenied("Prodejní místo nebylo nalezeno.")
if market_slot.status == "blocked":
user = self.request.user
if getattr(user, "role", None) not in ["admin", "clerk"]:
raise PermissionDenied("Toto prodejní místo je zablokované.")
@extend_schema(
tags=["Reservation"],
summary="Check reservation availability",
request=ReservationAvailabilitySerializer,
responses={200: OpenApiExample(
'Availability Response',
value={"available": True},
response_only=True
)}
)
class ReservationAvailabilityCheckView(APIView):
def post(self, request):
serializer = ReservationAvailabilitySerializer(data=request.data)
if serializer.is_valid():
return Response({"available": True}, status=status.HTTP_200_OK)
return Response({"available": False}, status=status.HTTP_200_OK)
logger = logging.getLogger(__name__)
@extend_schema(
tags=["Reservation"],
summary="Get reserved days for a market slot in an event",
description=(
"Returns a list of reserved days for a given event and market slot. "
"Useful for visualizing slot occupancy and preventing double bookings. "
"Provide `event_id` and `market_slot_id` as query parameters."
),
parameters=[
OpenApiParameter(
name="market_slot_id",
type=int,
location=OpenApiParameter.QUERY,
required=True,
description="ID of the market slot"
),
],
responses={200: ReservedDaysSerializer}
)
class ReservedDaysView(APIView):
"""
Returns reserved days for a given event and market slot.
GET params: event_id, market_slot_id
"""
def get(self, request, *args, **kwargs):
market_slot_id = request.query_params.get("market_slot_id")
if not market_slot_id:
return Response(
{"detail": "market_slot_id is required."},
status=status.HTTP_400_BAD_REQUEST
)
serializer = ReservedDaysSerializer({
"market_slot_id": market_slot_id
})
logger.debug(f"ReservedDaysView GET market_slot_id={market_slot_id}")
return Response(serializer.data)
@extend_schema(
tags=["Reservation Checks"],
description="Správa kontrol rezervací vytváření záznamů o kontrole a jejich výpis."
)
class ReservationCheckViewSet(viewsets.ModelViewSet):
queryset = ReservationCheck.objects.select_related("reservation", "checker").all().order_by("-checked_at")
serializer_class = ReservationCheckSerializer
permission_classes = [OnlyRolesAllowed("admin", "checker")] # Only checkers & admins can use it
def get_queryset(self):
user = self.request.user
if hasattr(user, "role") and user.role == "checker":
return self.queryset.filter(checker=user) # Checkers only see their own logs
return self.queryset
def perform_create(self, serializer):
serializer.save()