init
This commit is contained in:
1
backend/booking/__init__.py
Normal file
1
backend/booking/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# from . import tasks
|
||||
135
backend/booking/admin.py
Normal file
135
backend/booking/admin.py
Normal 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
9
backend/booking/apps.py
Normal 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
|
||||
23
backend/booking/filters.py
Normal file
23
backend/booking/filters.py
Normal 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
21
backend/booking/forms.py
Normal 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
|
||||
0
backend/booking/management/__init__.py
Normal file
0
backend/booking/management/__init__.py
Normal file
0
backend/booking/management/commands/__init__.py
Normal file
0
backend/booking/management/commands/__init__.py
Normal file
55
backend/booking/management/commands/seed_celery_beat.py
Normal file
55
backend/booking/management/commands/seed_celery_beat.py
Normal 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."))
|
||||
111
backend/booking/migrations/0001_initial.py
Normal file
111
backend/booking/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
54
backend/booking/migrations/0002_initial.py
Normal file
54
backend/booking/migrations/0002_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
backend/booking/migrations/__init__.py
Normal file
0
backend/booking/migrations/__init__.py
Normal file
395
backend/booking/models.py
Normal file
395
backend/booking/models.py
Normal 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)
|
||||
602
backend/booking/serializers.py
Normal file
602
backend/booking/serializers.py
Normal 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)
|
||||
}
|
||||
9
backend/booking/signals.py
Normal file
9
backend/booking/signals.py
Normal 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
116
backend/booking/tasks.py
Normal 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
3
backend/booking/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
16
backend/booking/urls.py
Normal file
16
backend/booking/urls.py
Normal 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
257
backend/booking/views.py
Normal 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()
|
||||
Reference in New Issue
Block a user