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

20
backend/.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
__pycache__/
*.pyc
*.pyo
*.pyd
*.sqlite3
*.log
*.env
.env.*
*.db
node_modules/
*.tgz
dist/
build/
.mypy_cache/
.vscode/
.idea/
db.sqlite3
celerybeat-schedule-shm
celerybeat-schedule-wal

BIN
backend/.gitignore vendored Normal file

Binary file not shown.

View File

105
backend/account/admin.py Normal file
View File

@@ -0,0 +1,105 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import CustomUser
from trznice.admin import custom_admin_site
from django.core.exceptions import PermissionDenied
from .forms import CustomUserCreationForm
from django.db.models import Q
# @admin.register(CustomUser)
class CustomUserAdmin(UserAdmin):
model = CustomUser
add_form = CustomUserCreationForm
list_display = (
"id", "username", "first_name", "last_name", "email", "role",
"create_time", "account_type", "is_active", "is_staff", "email_verified", "is_deleted"
)
list_filter = ("role", "account_type", "is_deleted", "is_active", "is_staff", "email_verified")
search_fields = ("username", "email", "phone_number")
ordering = ("-create_time",)
readonly_fields = ("create_time", "id") # zde
fieldsets = (
(None, {"fields": ("username", "first_name", "last_name", "email", "password")}),
("Osobní údaje", {"fields": ("role", "account_type", "phone_number", "var_symbol", "bank_account", "ICO", "city", "street", "PSC")}),
("Práva a stav", {"fields": ("is_active", "is_staff", "is_superuser", "email_verified", "is_deleted", "deleted_at", "groups", "user_permissions")}),
("Důležité časy", {"fields": ("last_login",)}), # create_time vyjmuto odsud
)
add_fieldsets = (
(None, {
"classes": ("wide",),
"fields": (
"username", "email", "role", "account_type",
"password1", "password2", # ✅ REQUIRED!
),
}),
)
def get_form(self, request, obj=None, **kwargs):
if not obj and getattr(request.user, "role", None) == "cityClerk":
form = CustomUserCreationForm
# Modify choices of the role field in the form class itself
form.base_fields["role"].choices = [
("", "---------"),
("seller", "Prodejce"),
]
return form
return super().get_form(request, obj, **kwargs)
def formfield_for_choice_field(self, db_field, request, **kwargs):
if db_field.name == "role" and request.user.role == "cityClerk":
# Restrict choices to only blank and "seller"
kwargs["choices"] = [
("", "---------"),
("seller", "Prodejce"),
]
return super().formfield_for_choice_field(db_field, request, **kwargs)
def get_list_display(self, request):
if request.user.role == "cityClerk":
return ("email", "username", "role", "account_type", "email_verified") # Keep it minimal
return super().get_list_display(request)
def get_fieldsets(self, request, obj=None):
# "add" view = creating a new user
if obj is None and request.user.role == "cityClerk":
return (
(None, {
"classes": ("wide",),
"fields": ("username", "email", "role", "account_type", "password1", "password2"),
}),
)
# "change" view
if request.user.role == "cityClerk":
return (
(None, {"fields": ("email", "username", "password")}),
("Osobní údaje", {"fields": ("role", "account_type", "phone_number", "var_symbol", "bank_account", "ICO", "city", "street", "PSC")}),
)
# Default for other users
return super().get_fieldsets(request, obj)
def get_queryset(self, request):
qs = self.model.all_objects.all()
if request.user.role == "cityClerk":
return qs.filter(
Q(role__in=["seller", ""]) | (Q(role__isnull=True)) & Q(is_superuser=False) | Q(is_deleted=False))
return qs
def save_model(self, request, obj, form, change):
if request.user.role == "cityClerk":
if obj.role not in ["", None, "seller"]:
raise PermissionDenied("City clerk can't assign this role.")
super().save_model(request, obj, form, change)
custom_admin_site.register(CustomUser, CustomUserAdmin)

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

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

108
backend/account/email.py Normal file
View File

@@ -0,0 +1,108 @@
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes
from django.urls import reverse
from django.core.mail import send_mail
from .tokens import *
from django.contrib.auth import get_user_model
User = get_user_model()
from django.conf import settings
from rest_framework.response import Response
import logging
logger = logging.getLogger(__name__)
# This function sends a password reset email to the user.
def send_password_reset_email(user, request):
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = password_reset_token.make_token(user)
url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
send_email_with_context(
subject="Obnova hesla",
message=f"Pro obnovu hesla klikni na následující odkaz:\n{url}",
recipients=[user.email],
)
# This function sends an email to the user for email verification after registration.
def send_email_verification(user):
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = account_activation_token.make_token(user)
url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
message = f"Ověřte svůj e-mail kliknutím na odkaz:\n{url}"
logger.debug(f"\nEMAIL OBSAH:\n {message}\nKONEC OBSAHU")
send_email_with_context(
recipients=user.email,
subject="Ověření e-mailu",
message=f"{message}"
)
def send_email_clerk_add_var_symbol(user):
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = account_activation_token.make_token(user)
# url = f"http://localhost:5173/clerk/add-var-symbol/{uid}/" # NEVIM
url = f"URL"
message = f"Byl vytvořen nový uživatel:\n {user.firstname} {user.secondname} {user.email} .\n Doplňte variabilní symbol {url} ."
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.debug("\nEMAIL OBSAH:\n",message, "\nKONEC OBSAHU")
send_email_with_context(
recipients=user.email,
subject="Doplnění variabilního symbolu",
message=message
)
def send_email_clerk_accepted(user):
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = account_activation_token.make_token(user)
message = f"Úředník potvrdil vaší registraci. Můžete se přihlásit."
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.debug("\nEMAIL OBSAH:\n",message, "\nKONEC OBSAHU")
send_email_with_context(
recipients=user.email,
subject="Úředník potvrdil váší registraci",
message=message
)
def send_email_with_context(recipients, subject, message):
"""
General function to send emails with a specific context.
"""
if isinstance(recipients, str):
recipients = [recipients]
try:
send_mail(
subject=subject,
message=message,
from_email=None,
recipient_list=recipients,
fail_silently=False,
)
return True
except Exception as e:
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.error(f"email se neodeslal... DEBUG: {e}")
pass
else:
return Response({"error": f"E-mail se neodeslal, důvod: {e}"}, status=500)

View File

@@ -0,0 +1,30 @@
import django_filters
from django.contrib.auth import get_user_model
User = get_user_model()
class UserFilter(django_filters.FilterSet):
role = django_filters.CharFilter(field_name="role", lookup_expr="exact")
account_type = django_filters.CharFilter(field_name="account_type", lookup_expr="exact")
email = django_filters.CharFilter(field_name="email", lookup_expr="icontains")
phone_number = django_filters.CharFilter(field_name="phone_number", lookup_expr="icontains")
city = django_filters.CharFilter(field_name="city", lookup_expr="icontains")
street = django_filters.CharFilter(field_name="street", lookup_expr="icontains")
PSC = django_filters.CharFilter(field_name="PSC", lookup_expr="exact")
ICO = django_filters.CharFilter(field_name="ICO", lookup_expr="exact")
RC = django_filters.CharFilter(field_name="RC", lookup_expr="exact")
var_symbol = django_filters.NumberFilter(field_name="var_symbol")
bank_account = django_filters.CharFilter(field_name="bank_account", lookup_expr="icontains")
GDPR = django_filters.BooleanFilter(field_name="GDPR")
is_active = django_filters.BooleanFilter(field_name="is_active")
email_verified = django_filters.BooleanFilter(field_name="email_verified")
create_time_after = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="gte")
create_time_before = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="lte")
class Meta:
model = User
fields = [
"role", "account_type", "email", "phone_number", "city", "street", "PSC",
"ICO", "RC", "var_symbol", "bank_account", "GDPR", "is_active", "email_verified",
"create_time_after", "create_time_before"
]

16
backend/account/forms.py Normal file
View File

@@ -0,0 +1,16 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm
from .models import CustomUser # adjust import to your app
#using: admin.py
class CustomUserCreationForm(UserCreationForm):
class Meta:
model = CustomUser
fields = ("username", "email", "role", "account_type", "password1", "password2")
def save(self, commit=True):
user = super().save(commit=False)
# Optional logic: assign role-based permissions here if needed
if commit:
user.save()
return user

View File

@@ -0,0 +1,40 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from getpass import getpass
class Command(BaseCommand):
help = 'Vytvoří superuživatele s is_active=True a potvrzením hesla'
def handle(self, *args, **kwargs):
User = get_user_model()
# Zadání údajů
username = input("Username: ").strip()
email = input("Email: ").strip()
# Heslo s potvrzením
while True:
password = getpass("Password: ")
password2 = getpass("Confirm password: ")
if password != password2:
self.stdout.write(self.style.ERROR("❌ Hesla se neshodují. Zkus to znovu."))
else:
break
# Kontrola duplicity
if User.objects.filter(username=username).exists():
self.stdout.write(self.style.ERROR("⚠️ Uživatel s tímto username už existuje."))
return
# Vytvoření uživatele
user = User.objects.create_superuser(
username=username,
email=email,
password=password
)
user.is_active = True
if hasattr(user, 'email_verified'):
user.email_verified = True
user.save()
self.stdout.write(self.style.SUCCESS(f"✅ Superuživatel '{username}' úspěšně vytvořen."))

View File

@@ -0,0 +1,59 @@
# Generated by Django 5.2.4 on 2025-08-07 15:13
import account.models
import django.contrib.auth.validators
import django.core.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('role', models.CharField(blank=True, choices=[('admin', 'Administrátor'), ('seller', 'Prodejce'), ('squareManager', 'Správce tržiště'), ('cityClerk', 'Úředník'), ('checker', 'Kontrolor')], max_length=32, null=True)),
('account_type', models.CharField(blank=True, choices=[('company', 'Firma'), ('individual', 'Fyzická osoba')], max_length=32, null=True)),
('email_verified', models.BooleanField(default=False)),
('phone_number', models.CharField(blank=True, max_length=16, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])),
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
('create_time', models.DateTimeField(auto_now_add=True)),
('var_symbol', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(9999999999), django.core.validators.MinValueValidator(0)])),
('bank_account', models.CharField(blank=True, max_length=255, null=True, validators=[django.core.validators.RegexValidator(code='invalid_bank_account', message='Zadejte platné číslo účtu ve formátu [prefix-]číslo_účtu/kód_banky, např. 1234567890/0100 nebo 123-4567890/0100.', regex='^(\\d{0,6}-)?\\d{10}/\\d{4}$')])),
('ICO', models.CharField(blank=True, max_length=8, null=True, validators=[django.core.validators.RegexValidator(code='invalid_ico', message='IČO musí obsahovat přesně 8 číslic.', regex='^\\d{8}$')])),
('RC', models.CharField(blank=True, max_length=11, null=True, validators=[django.core.validators.RegexValidator(code='invalid_rc', message='Rodné číslo musí být ve formátu 123456/7890.', regex='^\\d{6}\\/\\d{3,4}$')])),
('city', models.CharField(blank=True, max_length=100, null=True)),
('street', models.CharField(blank=True, max_length=200, null=True)),
('PSC', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_psc', message='PSČ musí obsahovat přesně 5 číslic.', regex='^\\d{5}$')])),
('GDPR', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=False)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')),
],
options={
'abstract': False,
},
managers=[
('objects', account.models.CustomUserActiveManager()),
('all_objects', account.models.CustomUserAllManager()),
],
),
]

View File

199
backend/account/models.py Normal file
View File

@@ -0,0 +1,199 @@
import uuid
from django.db import models
from django.contrib.auth.models import AbstractUser, Group, Permission
from django.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator
from django.conf import settings
from django.db import models
from django.utils import timezone
from datetime import timedelta
from trznice.models import SoftDeleteModel
from django.contrib.auth.models import UserManager
import logging
logger = logging.getLogger(__name__)
# Custom User Manager to handle soft deletion
class CustomUserActiveManager(UserManager):
def get_queryset(self):
return super().get_queryset().filter(is_deleted=False)
# Custom User Manager to handle all users, including soft deleted
class CustomUserAllManager(UserManager):
def get_queryset(self):
return super().get_queryset()
class CustomUser(SoftDeleteModel, AbstractUser):
groups = models.ManyToManyField(
Group,
related_name="customuser_set", # <- přidáš related_name
blank=True,
help_text="The groups this user belongs to.",
related_query_name="customuser",
)
user_permissions = models.ManyToManyField(
Permission,
related_name="customuser_set", # <- přidáš related_name
blank=True,
help_text="Specific permissions for this user.",
related_query_name="customuser",
)
ROLE_CHOICES = (
('admin', 'Administrátor'),
('seller', 'Prodejce'),
('squareManager', 'Správce tržiště'),
('cityClerk', 'Úředník'),
('checker', 'Kontrolor'),
)
role = models.CharField(max_length=32, choices=ROLE_CHOICES, null=True, blank=True)
ACCOUNT_TYPES = (
('company', 'Firma'),
('individual', 'Fyzická osoba')
)
account_type = models.CharField(max_length=32, choices=ACCOUNT_TYPES, null=True, blank=True)
email_verified = models.BooleanField(default=False)
phone_number = models.CharField(
unique=True,
max_length=16,
blank=True,
validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")]
)
email = models.EmailField(unique=True, db_index=True)
create_time = models.DateTimeField(auto_now_add=True)
var_symbol = models.PositiveIntegerField(null=True, blank=True, validators=[
MaxValueValidator(9999999999),
MinValueValidator(0)
],
)
bank_account = models.CharField(
max_length=255,
null=True,
blank=True,
validators=[
RegexValidator(
regex=r'^(\d{0,6}-)?\d{10}/\d{4}$', # r'^(\d{0,6}-)?\d{2,10}/\d{4}$' for range 2-10 digits
message="Zadejte platné číslo účtu ve formátu [prefix-]číslo_účtu/kód_banky, např. 1234567890/0100 nebo 123-4567890/0100.",
code='invalid_bank_account'
)
],
)
ICO = models.CharField(
max_length=8,
blank=True,
null=True,
validators=[
RegexValidator(
regex=r'^\d{8}$',
message="IČO musí obsahovat přesně 8 číslic.",
code='invalid_ico'
)
]
)
RC = models.CharField(
max_length=11,
blank=True,
null=True,
validators=[
RegexValidator(
regex=r'^\d{6}\/\d{3,4}$',
message="Rodné číslo musí být ve formátu 123456/7890.",
code='invalid_rc'
)
]
)
city = models.CharField(null=True, blank=True, max_length=100)
street = models.CharField(null=True, blank=True, max_length=200)
PSC = models.CharField(
max_length=5,
blank=True,
null=True,
validators=[
RegexValidator(
regex=r'^\d{5}$',
message="PSČ musí obsahovat přesně 5 číslic.",
code='invalid_psc'
)
]
)
GDPR = models.BooleanField(default=False)
is_active = models.BooleanField(default=False)
objects = CustomUserActiveManager()
all_objects = CustomUserAllManager()
REQUIRED_FIELDS = ['email']
def __str__(self):
return f"{self.email} at {self.create_time.strftime('%d-%m-%Y %H:%M:%S')}"
def generate_login(self, first_name, last_name):
"""
Vygeneruje login ve formátu: prijmeni + 2 písmena jména bez diakritiky.
Přidá číslo pokud už login existuje.
"""
from django.utils.text import slugify
base_login = slugify(f"{last_name}{first_name[:2]}")
login = base_login
counter = 1
while CustomUser.objects.filter(username=login).exists():
login = f"{base_login}{counter}"
counter += 1
return login
def delete(self, *args, **kwargs):
self.is_active = False
self.tickets.all().update(is_deleted=True, deleted_at=timezone.now())
self.user_reservations.all().update(is_deleted=True, deleted_at=timezone.now())
self.orders.all().update(is_deleted=True, deleted_at=timezone.now())
return super().delete(*args, **kwargs)
def save(self, *args, **kwargs):
is_new = self.pk is None # check BEFORE saving
if is_new:
# Ensure first_name and last_name are provided before generating login
if self.first_name and self.last_name:
self.username = self.generate_login(self.first_name, self.last_name)
if self.is_superuser or self.role in ["admin", "cityClerk", "squareManager"]:
# self.is_staff = True
self.is_active = True
if self.role == 'admin':
self.is_staff = True
self.is_superuser = True
if self.is_superuser:
self.role = 'admin'
else:
self.is_staff = False
return super().save(*args, **kwargs)
# NEMAZAT prozatim to nechame, kdybychom to potrebovali
# Now assign permissions after user exists
# if is_new and self.role:
if self.role:
from account.utils import assign_permissions_based_on_role
logger.debug(f"Assigning permissions to: {self.email} with role {self.role}")
assign_permissions_based_on_role(self)
# super().save(*args, **kwargs) # save once, after prep

View File

@@ -0,0 +1,72 @@
from rest_framework.permissions import BasePermission, SAFE_METHODS
from rest_framework.permissions import IsAuthenticated
from rest_framework_api_key.permissions import HasAPIKey
#Podle svého uvážení (NEPOUŽÍVAT!!!)
class RolePermission(BasePermission):
allowed_roles = []
def has_permission(self, request, view):
# Je uživatel přihlášený a má roli z povolených?
user_has_role = (
request.user and
request.user.is_authenticated and
getattr(request.user, "role", None) in self.allowed_roles
)
# Má API klíč?
has_api_key = HasAPIKey().has_permission(request, view)
return user_has_role or has_api_key
#TOHLE POUŽÍT!!!
#Prostě stačí vložit: RoleAllowed('seller','cityClerk')
def RoleAllowed(*roles):
class SafeOrRolePermission(BasePermission):
"""
Allows safe methods for any authenticated user.
Allows unsafe methods only for users with specific roles.
Args:
RolerAllowed('seller', 'cityClerk')
"""
def has_permission(self, request, view):
# Allow safe methods for any authenticated user
if request.method in SAFE_METHODS:
return IsAuthenticated().has_permission(request, view)
# Otherwise, check the user's role
user = request.user
return user and user.is_authenticated and getattr(user, "role", None) in roles
return SafeOrRolePermission
# FIXME: je tohle nutné???
def OnlyRolesAllowed(*roles):
class SafeOrRolePermission(BasePermission):
"""
Allows all methods only for users with specific roles.
"""
def has_permission(self, request, view):
# Otherwise, check the user's role
user = request.user
return user and user.is_authenticated and getattr(user, "role", None) in roles
return SafeOrRolePermission
# For Settings.py
class AdminOnly(BasePermission):
""" Allows access only to users with the 'admin' role.
Args:
BasePermission (rest_framework.permissions.BasePermission): Base class for permission classes.
"""
def has_permission(self, request, view):
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'

View File

@@ -0,0 +1,224 @@
import re
from django.utils.text import slugify
from django.core.validators import MinValueValidator, MaxValueValidator
from rest_framework import serializers
from rest_framework.exceptions import NotFound
from django.contrib.auth import get_user_model
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from django.utils.translation import gettext_lazy as _
from django.utils.text import slugify
from .permissions import *
from .email import *
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework.exceptions import PermissionDenied
User = get_user_model()
class CustomUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = [
"id",
"username",
"first_name",
"last_name",
"email",
"role",
"account_type",
"email_verified",
"phone_number",
"create_time",
"var_symbol",
"bank_account",
"ICO",
"RC",
"city",
"street",
"PSC",
"GDPR",
"is_active",
]
read_only_fields = ["id", "create_time", "GDPR", "username"] # <-- removed "account_type"
def update(self, instance, validated_data):
user = self.context["request"].user
staff_only_fields = ["role", "email_verified", "var_symbol", "is_active"]
if user.role not in ["admin", "cityClerk"]:
unauthorized = [f for f in staff_only_fields if f in validated_data]
if unauthorized:
raise PermissionDenied(f"You are not allowed to modify: {', '.join(unauthorized)}")
return super().update(instance, validated_data)
# Token obtaining Default Serializer
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
username_field = User.USERNAME_FIELD
def validate(self, attrs):
login = attrs.get("username")
password = attrs.get("password")
# Allow login by username or email
user = User.objects.filter(email__iexact=login).first() or \
User.objects.filter(username__iexact=login).first()
if user is None or not user.check_password(password):
raise serializers.ValidationError(_("No active account found with the given credentials"))
# Call the parent validation to create token
data = super().validate({
self.username_field: user.username,
"password": password
})
data["user_id"] = user.id
data["username"] = user.username
data["email"] = user.email
return data
# user creating section start ------------------------------------------
class UserRegistrationSerializer(serializers.ModelSerializer):
password = serializers.CharField(
write_only=True,
help_text="Heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici."
)
class Meta:
model = User
fields = [
'first_name', 'last_name', 'email', 'phone_number', 'account_type',
'password','city', 'street', 'PSC', 'bank_account', 'RC', 'ICO', 'GDPR'
]
extra_kwargs = {
'first_name': {'required': True, 'help_text': 'Křestní jméno uživatele'},
'last_name': {'required': True, 'help_text': 'Příjmení uživatele'},
'email': {'required': True, 'help_text': 'Emailová adresa uživatele'},
'phone_number': {'required': True, 'help_text': 'Telefonní číslo uživatele'},
'account_type': {'required': True, 'help_text': 'Typ účtu'},
'city': {'required': True, 'help_text': 'Město uživatele'},
'street': {'required': True, 'help_text': 'Ulice uživatele'},
'PSC': {'required': True, 'help_text': 'Poštovní směrovací číslo'},
'bank_account': {'required': True, 'help_text': 'Číslo bankovního účtu'},
'RC': {'required': True, 'help_text': 'Rodné číslo'},
'ICO': {'required': True, 'help_text': 'Identifikační číslo organizace'},
'GDPR': {'required': True, 'help_text': 'Souhlas se zpracováním osobních údajů'},
}
def validate_password(self, value):
if len(value) < 8:
raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.")
if not re.search(r"[A-Z]", value):
raise serializers.ValidationError("Heslo musí obsahovat alespoň jedno velké písmeno.")
if not re.search(r"[a-z]", value):
raise serializers.ValidationError("Heslo musí obsahovat alespoň jedno malé písmeno.")
if not re.search(r"\d", value):
raise serializers.ValidationError("Heslo musí obsahovat alespoň jednu číslici.")
return value
def validate(self, data):
email = data.get("email")
phone = data.get("phone_number")
dgpr = data.get("GDPR")
if not dgpr:
raise serializers.ValidationError({"GDPR": "Pro registraci musíte souhlasit s GDPR"})
if User.objects.filter(email=email).exists():
raise serializers.ValidationError({"email": "Účet s tímto emailem již existuje."})
if phone and User.objects.filter(phone_number=phone).exists():
raise serializers.ValidationError({"phone_number": "Účet s tímto telefonem již existuje."})
return data
def generate_username(self, first_name, last_name):
# Převod na ascii (bez diakritiky)
base_login = slugify(f"{last_name}{first_name[:2]}")
login = base_login
counter = 1
while User.objects.filter(username=login).exists():
login = f"{base_login}{counter}"
counter += 1
return login
def create(self, validated_data):
password = validated_data.pop("password")
first_name = validated_data.get("first_name", "")
last_name = validated_data.get("last_name", "")
username = self.generate_username(first_name, last_name)
user = User.objects.create(
username=username,
is_active=False, #uživatel je defaultně deaktivovaný
**validated_data
)
user.set_password(password)
user.save()
return user
class UserActivationSerializer(serializers.Serializer):
user_id = serializers.IntegerField()
var_symbol = serializers.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(9999999999)])
def save(self, **kwargs):
try:
user = User.objects.get(pk=self.validated_data['user_id'])
except User.DoesNotExist:
raise NotFound("Uživatel s tímto ID neexistuje.")
user.var_symbol = self.validated_data['var_symbol']
user.is_active = True
user.save()
return user
def to_representation(self, instance):
return {
"id": instance.id,
"email": instance.email,
"var_symbol": instance.var_symbol,
"is_active": instance.is_active,
}
class Meta:
model = User
fields = [
'user_id', 'var_symbol'
]
extra_kwargs = {
'user_id': {'required': True, 'help_text': 'ID uživatele'},
'var_symbol': {'required': True, 'help_text': 'Variablní symbol, zadán úředníkem'},
}
# user creating section end --------------------------------------------
class PasswordResetRequestSerializer(serializers.Serializer):
email = serializers.EmailField(
help_text="E-mail registrovaného a aktivního uživatele, na který bude zaslán reset hesla."
)
def validate_email(self, value):
if not User.objects.filter(email=value, is_active=True).exists():
raise serializers.ValidationError("Účet s tímto emailem neexistuje nebo není aktivní.")
return value
class PasswordResetConfirmSerializer(serializers.Serializer):
password = serializers.CharField(
write_only=True,
help_text="Nové heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici."
)
def validate_password(self, value):
import re
if len(value) < 8:
raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.")
if not re.search(r"[A-Z]", value):
raise serializers.ValidationError("Musí obsahovat velké písmeno.")
if not re.search(r"[a-z]", value):
raise serializers.ValidationError("Musí obsahovat malé písmeno.")
if not re.search(r"\d", value):
raise serializers.ValidationError("Musí obsahovat číslici.")
return value

130
backend/account/tasks.py Normal file
View File

@@ -0,0 +1,130 @@
from celery import shared_task
from celery.utils.log import get_task_logger
from django.core.mail import send_mail
from django.conf import settings
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes
from .tokens import *
from .models import CustomUser
logger = get_task_logger(__name__)
# This function sends a password reset email to the user.
@shared_task
def send_password_reset_email_task(user_id):
try:
user = CustomUser.objects.get(pk=user_id)
except user.DoesNotExist:
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
return 0
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = password_reset_token.make_token(user)
url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
send_email_with_context(
subject="Obnova hesla",
message=f"Pro obnovu hesla klikni na následující odkaz:\n{url}",
recipients=[user.email],
)
# This function sends an email to the user for email verification after registration.
@shared_task
def send_email_verification_task(user_id):
try:
user = CustomUser.objects.get(pk=user_id)
except user.DoesNotExist:
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
return 0
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = account_activation_token.make_token(user)
url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
message = f"Ověřte svůj e-mail kliknutím na odkaz:\n{url}"
logger.debug(f"\nEMAIL OBSAH:\n {message}\nKONEC OBSAHU")
send_email_with_context(
recipients=user.email,
subject="Ověření e-mailu",
message=f"{message}"
)
@shared_task
def send_email_clerk_add_var_symbol_task(user_id):
try:
user = CustomUser.objects.get(pk=user_id)
except user.DoesNotExist:
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
return 0
uid = urlsafe_base64_encode(force_bytes(user.pk))
# url = f"http://localhost:5173/clerk/add-var-symbol/{uid}/" # NEVIM
# TODO: Replace with actual URL once frontend route is ready
url = f"{settings.FRONTEND_URL}/clerk/add-var-symbol/{uid}/"
message = f"Byl vytvořen nový uživatel:\n {user.firstname} {user.secondname} {user.email} .\n Doplňte variabilní symbol {url} ."
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.debug("\nEMAIL OBSAH:\n",message, "\nKONEC OBSAHU")
send_email_with_context(
recipients=user.email,
subject="Doplnění variabilního symbolu",
message=message
)
@shared_task
def send_email_clerk_accepted_task(user_id):
try:
user = CustomUser.objects.get(pk=user_id)
except user.DoesNotExist:
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
return 0
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = account_activation_token.make_token(user)
message = f"Úředník potvrdil vaší registraci. Můžete se přihlásit."
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.debug("\nEMAIL OBSAH:\n",message, "\nKONEC OBSAHU")
send_email_with_context(
recipients=user.email,
subject="Úředník potvrdil váší registraci",
message=message
)
def send_email_with_context(recipients, subject, message):
"""
General function to send emails with a specific context.
"""
if isinstance(recipients, str):
recipients = [recipients]
try:
send_mail(
subject=subject,
message=message,
from_email=None,
recipient_list=recipients,
fail_silently=False,
)
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.debug("\nEMAIL OBSAH:\n",message, "\nKONEC OBSAHU")
return True
except Exception as e:
logger.error(f"E-mail se neodeslal: {e}")
return False

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

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

33
backend/account/tokens.py Normal file
View File

@@ -0,0 +1,33 @@
from django.contrib.auth.tokens import PasswordResetTokenGenerator
# Subclass PasswordResetTokenGenerator to create a separate token generator
# for account activation. This allows future customization specific to activation tokens,
# even though it currently behaves exactly like the base class.
class AccountActivationTokenGenerator(PasswordResetTokenGenerator):
pass # No changes yet; inherits all behavior from PasswordResetTokenGenerator
# Create an instance of AccountActivationTokenGenerator to be used for generating
# and validating account activation tokens throughout the app.
account_activation_token = AccountActivationTokenGenerator()
# Create an instance of the base PasswordResetTokenGenerator to be used
# for password reset tokens.
password_reset_token = PasswordResetTokenGenerator()
from rest_framework_simplejwt.authentication import JWTAuthentication
#NEMĚNIT CUSTOM SBÍRANÍ COOKIE TOKENU
class CookieJWTAuthentication(JWTAuthentication):
def authenticate(self, request):
raw_token = request.COOKIES.get('access_token')
if not raw_token:
return None
validated_token = self.get_validated_token(raw_token)
return self.get_user(validated_token), validated_token

28
backend/account/urls.py Normal file
View File

@@ -0,0 +1,28 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import *
router = DefaultRouter()
router.register(r'users', UserView, basename='user') # change URL to plural users ?
urlpatterns = [
path('', include(router.urls)), # automaticky přidá všechny cesty z viewsetu
path("user/me/", CurrentUserView.as_view(), name="user-me"), # get current user data
path('token/', CookieTokenObtainPairView.as_view(), name='token_obtain_pair'), #přihlášení (get token)
path('token/refresh/', CookieTokenRefreshView.as_view(), name='token_refresh'), #refresh token
#potom co access token vyprší tak se pomocí refresh tokenu získa další
path('logout/', LogoutView.as_view(), name='logout'), # odhlášení (smaže tokeny)
path('registration/', UserRegistrationViewSet.as_view({'post': 'create'}), name='create_seller'),
#slouží čistě pro email
path("registration/verify-email/<uidb64>/<token>/", EmailVerificationView.as_view(), name="verify-email"),
path("registration/activation-varsymbol/", UserActivationViewSet.as_view(), name="activate_user_and_input_var_symbol"),
path("reset-password/", PasswordResetRequestView.as_view(), name="reset-password-request"),
path("reset-password/<uidb64>/<token>/", PasswordResetConfirmView.as_view(), name="reset-password-confirm"),
]

62
backend/account/utils.py Normal file
View File

@@ -0,0 +1,62 @@
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from booking.models import Event, Reservation, MarketSlot, Square
from product.models import Product, EventProduct
from servicedesk.models import ServiceTicket
from django.contrib.auth import get_user_model
import logging
logger = logging.getLogger(__name__)
def assign_permissions_based_on_role(user):
role_perms = {
"cityClerk": {
"view": [Event, Reservation, MarketSlot, get_user_model(), Product, EventProduct, ServiceTicket],
"add": [Reservation, get_user_model()],
"change": [Reservation, get_user_model()],
# "delete": [Reservation],
},
"squareManager": {
"view": [Event, MarketSlot, Square, Product, EventProduct],
"add": [Event, MarketSlot, Square, Product, EventProduct],
"change": [Event, MarketSlot, Square, Product, EventProduct],
},
# "admin": {
# "view": [Event, Reservation, get_user_model()],
# "add": [Event, Reservation],
# "change": [Event, Reservation],
# "delete": [Event, Reservation],
# },
# etc.
"admin": "all", # Mark this role specially
}
if not user.role:
logger.info("User has no role set")
return
if user.role == "admin":
user.is_staff = True
user.is_superuser = True
# user.save()
return
# Reset in case role changed away from admin
user.is_superuser = False
perms_for_role = role_perms.get(user.role, {})
for action, models in perms_for_role.items():
for model in models:
content_type = ContentType.objects.get_for_model(model)
codename = f"{action}_{model._meta.model_name}"
try:
permission = Permission.objects.get(codename=codename, content_type=content_type)
user.user_permissions.add(permission)
except Permission.DoesNotExist:
# You may log this
pass
# user.save()

409
backend/account/views.py Normal file
View File

@@ -0,0 +1,409 @@
from django.contrib.auth import get_user_model, authenticate
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes, force_str
from django.conf import settings
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import ensure_csrf_cookie
from .serializers import *
from .permissions import *
from .tasks import *
from .models import CustomUser
from .tokens import *
from .filters import UserFilter
from rest_framework import generics, permissions, status, viewsets
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError, AuthenticationFailed
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
User = get_user_model()
#general user view API
import logging
logger = logging.getLogger(__name__)
from rest_framework_simplejwt.views import TokenObtainPairView
#---------------------------------------------TOKENY------------------------------------------------
# Custom Token obtaining view
@extend_schema(
tags=["api"],
summary="Obtain JWT access and refresh tokens (cookie-based)",
request=CustomTokenObtainPairSerializer,
description="Authentication - získaš Access a Refresh token... lze do <username> vložit E-mail nebo username"
)
@method_decorator(ensure_csrf_cookie, name="dispatch")
class CookieTokenObtainPairView(TokenObtainPairView):
permission_classes = [AllowAny]
serializer_class = CustomTokenObtainPairSerializer
def post(self, request, *args, **kwargs):
response = super().post(request, *args, **kwargs)
# Získáme tokeny z odpovědi
access = response.data.get("access")
refresh = response.data.get("refresh")
if not access or not refresh:
return response # Např. při chybě přihlášení
jwt_settings = settings.SIMPLE_JWT
# Access token cookie
response.set_cookie(
key=jwt_settings.get("AUTH_COOKIE", "access_token"),
value=access,
httponly=jwt_settings.get("AUTH_COOKIE_HTTP_ONLY", True),
secure=jwt_settings.get("AUTH_COOKIE_SECURE", not settings.DEBUG),
samesite=jwt_settings.get("AUTH_COOKIE_SAMESITE", "Lax"),
path=jwt_settings.get("AUTH_COOKIE_PATH", "/"),
max_age=int(settings.ACCESS_TOKEN_LIFETIME.total_seconds()),
)
# Refresh token cookie
response.set_cookie(
key=jwt_settings.get("AUTH_COOKIE_REFRESH", "refresh_token"),
value=refresh,
httponly=jwt_settings.get("AUTH_COOKIE_HTTP_ONLY", True),
secure=jwt_settings.get("AUTH_COOKIE_SECURE", not settings.DEBUG),
samesite=jwt_settings.get("AUTH_COOKIE_SAMESITE", "Lax"),
path=jwt_settings.get("AUTH_COOKIE_PATH", "/"),
max_age=int(settings.REFRESH_TOKEN_LIFETIME.total_seconds()),
)
return response
def validate(self, attrs):
username = attrs.get("username")
password = attrs.get("password")
# Přihlaš uživatele ručně
user = authenticate(request=self.context.get('request'), username=username, password=password)
if not user:
raise AuthenticationFailed("Špatné uživatelské jméno nebo heslo.")
if not user.is_active:
raise AuthenticationFailed("Uživatel je deaktivován.")
# Nastav validní uživatele (přebere další logiku ze SimpleJWT)
self.user = user
# Vrátí access a refresh token jako obvykle
return super().validate(attrs)
@extend_schema(
tags=["api"],
summary="Refresh JWT token using cookie",
description="Refresh JWT token"
)
@method_decorator(ensure_csrf_cookie, name="dispatch")
class CookieTokenRefreshView(APIView):
permission_classes = [AllowAny]
def post(self, request):
refresh_token = request.COOKIES.get('refresh_token') or request.data.get('refresh')
if not refresh_token:
return Response({"detail": "Refresh token cookie not found."}, status=status.HTTP_400_BAD_REQUEST)
try:
refresh = RefreshToken(refresh_token)
access_token = str(refresh.access_token)
new_refresh_token = str(refresh) # volitelně nový refresh token
response = Response({
"access": access_token,
"refresh": new_refresh_token,
})
jwt_settings = settings.SIMPLE_JWT
# Access token cookie
response.set_cookie(
key=jwt_settings.get("AUTH_COOKIE", "access_token"),
value=access_token,
httponly=jwt_settings.get("AUTH_COOKIE_HTTP_ONLY", True),
secure=jwt_settings.get("AUTH_COOKIE_SECURE", not settings.DEBUG),
samesite=jwt_settings.get("AUTH_COOKIE_SAMESITE", "Lax"),
path=jwt_settings.get("AUTH_COOKIE_PATH", "/"),
max_age=int(5),
)
# Refresh token cookie
response.set_cookie(
key=jwt_settings.get("AUTH_COOKIE_REFRESH", "refresh_token"),
value=new_refresh_token,
httponly=jwt_settings.get("AUTH_COOKIE_HTTP_ONLY", True),
secure=jwt_settings.get("AUTH_COOKIE_SECURE", not settings.DEBUG),
samesite=jwt_settings.get("AUTH_COOKIE_SAMESITE", "Lax"),
path=jwt_settings.get("AUTH_COOKIE_PATH", "/"),
max_age=int(settings.REFRESH_TOKEN_LIFETIME.total_seconds()),
)
return response
except TokenError:
logger.error("Invalid refresh token used.")
return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED)
#---------------------------------------------LOGIN/LOGOUT------------------------------------------------
@extend_schema(
tags=["api"],
summary="Logout user (delete access and refresh token cookies)",
description="Odhlásí uživatele smaže access a refresh token cookies"
)
class LogoutView(APIView):
permission_classes = [AllowAny]
def post(self, request):
response = Response({"detail": "Logout successful"}, status=status.HTTP_200_OK)
# Smazání cookies
response.delete_cookie("access_token", path="/")
response.delete_cookie("refresh_token", path="/")
return response
#--------------------------------------------------------------------------------------------------------------
@extend_schema(
tags=["User"],
responses={200: CustomUserSerializer},
description="Zobrazí všechny uživatele s možností filtrování a řazení.",
)
class UserView(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = CustomUserSerializer
filter_backends = [DjangoFilterBackend]
filterset_class = UserFilter
# Require authentication and role permission
permission_classes = [IsAuthenticated]
class Meta:
model = CustomUser
extra_kwargs = {
"email": {"help_text": "Unikátní e-mailová adresa uživatele."},
"phone_number": {"help_text": "Telefonní číslo ve formátu +420123456789."},
"role": {"help_text": "Role uživatele určující jeho oprávnění v systému."},
"account_type": {"help_text": "Typ účtu firma nebo fyzická osoba."},
"email_verified": {"help_text": "Určuje, zda je e-mail ověřen."},
"create_time": {"help_text": "Datum a čas registrace uživatele (pouze pro čtení).", "read_only": True},
"var_symbol": {"help_text": "Variabilní symbol pro platby, pokud je vyžadován."},
"bank_account": {"help_text": "Číslo bankovního účtu uživatele."},
"ICO": {"help_text": "IČO firmy, pokud se jedná o firemní účet."},
"RC": {"help_text": "Rodné číslo pro fyzické osoby."},
"city": {"help_text": "Město trvalého pobytu / sídla."},
"street": {"help_text": "Ulice a číslo popisné."},
"PSC": {"help_text": "PSČ místa pobytu / sídla."},
"GDPR": {"help_text": "Souhlas se zpracováním osobních údajů."},
"is_active": {"help_text": "Stav aktivace uživatele."},
}
def get_permissions(self):
if self.action in ['list', 'create']: # GET / POST /api/account/users/
return [OnlyRolesAllowed("cityClerk", "admin")()]
elif self.action in ['update', 'partial_update', 'destroy']: # PUT / PATCH / DELETE /api/account/users/{id}
if self.request.user.role in ['cityClerk', 'admin']:
return [OnlyRolesAllowed("cityClerk", "admin")()]
elif self.kwargs.get('pk') and str(self.request.user.id) == self.kwargs['pk']:
return [IsAuthenticated]
else:
# fallback - deny access
return [OnlyRolesAllowed("cityClerk", "admin")()] # or custom DenyAll()
elif self.action == 'retrieve': # GET /api/account/users/{id}
if self.request.user.role in ['cityClerk', 'admin']:
return [OnlyRolesAllowed("cityClerk", "admin")()]
elif self.kwargs.get('pk') and str(self.request.user.id) == self.kwargs['pk']:
return [IsAuthenticated()]
else:
return [OnlyRolesAllowed("cityClerk", "admin")()] # or a custom read-only self-access permission
return super().get_permissions()
# Get current user data
@extend_schema(
tags=["User"],
summary="Get current authenticated user",
description="Vrátí detail aktuálně přihlášeného uživatele podle JWT tokenu nebo session.",
responses={
200: OpenApiResponse(response=CustomUserSerializer),
401: OpenApiResponse(description="Unauthorized, uživatel není přihlášen"),
}
)
class CurrentUserView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
serializer = CustomUserSerializer(request.user)
return Response(serializer.data)
#------------------------------------------------REGISTRACE--------------------------------------------------------------
#1. registration API
@extend_schema(
tags=["User Registration"],
summary="Register a new user (company or individual)",
request=UserRegistrationSerializer,
responses={201: UserRegistrationSerializer},
description="1. Registrace nového uživatele(firmy). Uživateli přijde email s odkazem na ověření.",
)
class UserRegistrationViewSet(ModelViewSet):
queryset = CustomUser.objects.all()
serializer_class = UserRegistrationSerializer
http_method_names = ['post']
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
try:
send_email_verification_task.delay(user.id) # posílaní emailu pro potvrzení registrace - CELERY TASK
except Exception as e:
logger.error(f"Celery not available, using fallback. Error: {e}")
send_email_verification_task(user.id) # posílaní emailu pro potvrzení registrace
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
#2. confirming email
@extend_schema(
tags=["User Registration"],
summary="Verify user email via link",
responses={
200: OpenApiResponse(description="Email úspěšně ověřen."),
400: OpenApiResponse(description="Chybný nebo expirovaný token.")
},
parameters=[
OpenApiParameter(name='uidb64', type=str, location='path', description="Token z E-mailu"),
OpenApiParameter(name='token', type=str, location='path', description="Token uživatele"),
],
description="2. Ověření emailu pomocí odkazu s uid a tokenem. (stačí jenom převzít a poslat)",
)
class EmailVerificationView(APIView):
def get(self, request, uidb64, token):
try:
uid = force_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(pk=uid)
except (User.DoesNotExist, ValueError, TypeError):
return Response({"error": "Neplatný odkaz."}, status=400)
if account_activation_token.check_token(user, token):
user.email_verified = True
user.save()
return Response({"detail": "E-mail byl úspěšně ověřen. Účet čeká na schválení."})
else:
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
#3. seller activation API (var_symbol)
@extend_schema(
tags=["User Registration"],
summary="Activate user and set variable symbol (admin/cityClerk only)",
request=UserActivationSerializer,
responses={200: UserActivationSerializer},
description="3. Aktivace uživatele a zadání variabilního symbolu (pouze pro adminy a úředníky).",
)
class UserActivationViewSet(APIView):
permission_classes = [OnlyRolesAllowed('cityClerk', 'admin')]
def patch(self, request, *args, **kwargs):
serializer = UserActivationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
try:
send_email_clerk_accepted_task.delay(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol - CELERY TASK
except Exception as e:
logger.error(f"Celery not available, using fallback. Error: {e}")
send_email_clerk_accepted_task(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol
return Response(serializer.to_representation(user), status=status.HTTP_200_OK)
#-------------------------------------------------END REGISTRACE-------------------------------------------------------------
#1. PasswordReset + send Email
@extend_schema(
tags=["User password reset"],
summary="Request password reset (send email)",
request=PasswordResetRequestSerializer,
responses={
200: OpenApiResponse(description="Odeslán email s instrukcemi."),
400: OpenApiResponse(description="Neplatný email.")
},
description="1(a). Požadavek na reset hesla - uživatel zadá svůj email."
)
class PasswordResetRequestView(APIView):
def post(self, request):
serializer = PasswordResetRequestSerializer(data=request.data)
if serializer.is_valid():
try:
user = User.objects.get(email=serializer.validated_data['email'])
except User.DoesNotExist:
# Always return 200 even if user doesn't exist to avoid user enumeration
return Response({"detail": "E-mail s odkazem byl odeslán."})
try:
send_password_reset_email_task.delay(user.id) # posílaní emailu pro obnovení hesla - CELERY TASK
except Exception as e:
logger.error(f"Celery not available, using fallback. Error: {e}")
send_password_reset_email_task(user.id) # posílaní emailu pro obnovení hesla registrace
return Response({"detail": "E-mail s odkazem byl odeslán."})
return Response(serializer.errors, status=400)
#2. Confirming reset
@extend_schema(
tags=["User password reset"],
summary="Confirm password reset via token",
request=PasswordResetConfirmSerializer,
parameters=[
OpenApiParameter(name='uidb64', type=str, location=OpenApiParameter.PATH),
OpenApiParameter(name='token', type=str, location=OpenApiParameter.PATH),
],
responses={
200: OpenApiResponse(description="Heslo bylo změněno."),
400: OpenApiResponse(description="Chybný token nebo data.")
},
description="1(a). Potvrzení resetu hesla pomocí tokenu z emailu."
)
class PasswordResetConfirmView(APIView):
def post(self, request, uidb64, token):
try:
uid = force_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(pk=uid)
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
return Response({"error": "Neplatný odkaz."}, status=400)
if not password_reset_token.check_token(user, token):
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
serializer = PasswordResetConfirmSerializer(data=request.data)
if serializer.is_valid():
user.set_password(serializer.validated_data['password'])
user.save()
return Response({"detail": "Heslo bylo úspěšně změněno."})
return Response(serializer.errors, status=400)

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()

View File

30
backend/commerce/admin.py Normal file
View File

@@ -0,0 +1,30 @@
from django.contrib import admin
from trznice.admin import custom_admin_site
from .models import Order
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "status", "user", "price_to_pay", "reservation", "is_deleted")
list_filter = ("user", "status", "reservation", "is_deleted")
search_fields = ("user__email", "reservation__event")
ordering = ("id",)
base_fields = ["status", "reservation", "created_at", "user", "price_to_pay", "payed_at", "note"]
readonly_fields = ("id", "created_at", "payed_at")
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(Order, OrderAdmin)

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

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

View File

@@ -0,0 +1,12 @@
import django_filters
from .models import Order
class OrderFilter(django_filters.FilterSet):
reservation = django_filters.NumberFilter(field_name="reservation__id")
user = django_filters.NumberFilter(field_name="user__id")
status = django_filters.ChoiceFilter(choices=Order.STATUS_CHOICES)
class Meta:
model = Order
fields = ["reservation", "user", "status"]

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.2.4 on 2025-08-07 15:13
import django.core.validators
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'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Order',
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)),
('created_at', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(choices=[('payed', 'Zaplaceno'), ('pending', 'Čeká na zaplacení'), ('cancelled', 'Stornovano')], default='pending', max_length=20)),
('note', models.TextField(blank=True, null=True)),
('price_to_pay', models.DecimalField(blank=True, decimal_places=2, default=0, help_text='Cena k zaplacení. Počítá se automaticky z Rezervace.', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])),
('payed_at', models.DateTimeField(blank=True, null=True)),
('reservation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='order', to='booking.reservation')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

113
backend/commerce/models.py Normal file
View File

@@ -0,0 +1,113 @@
import uuid
from django.db import models
from django.conf import settings
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from trznice.models import SoftDeleteModel
from booking.models import Reservation
from account.models import CustomUser
class Order(SoftDeleteModel):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="orders", null=False, blank=False)
reservation = models.OneToOneField(Reservation, on_delete=models.CASCADE, related_name="order", null=False, blank=False)
created_at = models.DateTimeField(auto_now_add=True)
STATUS_CHOICES = [
("payed", "Zaplaceno"),
("pending", "Čeká na zaplacení"),
("cancelled", "Stornovano"),
]
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
note = models.TextField(blank=True, null=True)
price_to_pay = models.DecimalField(blank=True,
default=0,
max_digits=8,
decimal_places=2,
validators=[MinValueValidator(0)],
help_text="Cena k zaplacení. Počítá se automaticky z Rezervace.",
)
payed_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"Objednávka {self.id} od uživatele {self.user}"
def clean(self):
if not self.user_id:
raise ValidationError("Zadejte ID Uživatele.")
if not self.reservation_id:
raise ValidationError("Zadejte ID Rezervace.")
# Safely get product and event objects for error messages and validation
try:
reservation_obj = Reservation.objects.get(pk=self.reservation_id)
except Reservation.DoesNotExist:
raise ValidationError("Neplatné ID Rezervace.")
"""try:
user_obj = CustomUser.objects.get(pk=self.user_id)
if reservation_obj.user != user_obj:
raise ValidationError("Tato rezervace naleží jinému Uživatelovi.")
except CustomUser.DoesNotExist:
raise ValidationError("Neplatné ID Uživatele.")"""
# Overlapping sales window check
overlapping = Order.objects.exclude(id=self.id).filter(
reservation_id=self.reservation_id,
)
if overlapping.exists():
raise ValidationError("Tato Rezervace už je zaplacena.")
errors = {}
# If order is marked as payed, it must have a payed_at timestamp
if self.status == "payed" and not self.payed_at:
errors["payed_at"] = "Musíte zadat datum a čas zaplacení, pokud je objednávka zaplacena."
# If order is not payed, payed_at must be null
if self.status != "payed" and self.payed_at:
errors["payed_at"] = "Datum zaplacení může být uvedeno pouze u zaplacených objednávek."
if self.reservation.final_price:
self.price_to_pay = self.reservation.final_price
else:
errors["price_to_pay"] = "Chyba v Rezervaci, neplatná cena."
# Price must be greater than zero
if self.price_to_pay:
if self.price_to_pay < 0:
errors["price_to_pay"] = "Cena musí být větší než 0."
# if self.price_to_pay == 0 and self.reservation:
else:
errors["price_to_pay"] = "Nemůže být prázdné."
if errors:
raise ValidationError(errors)
def save(self, *args, **kwargs):
self.full_clean()
if self.status == "cancelled":
self.reservation.status = "cancelled"
else:
self.reservation.status = "reserved"
self.reservation.save()
# if self.reservation:
# self.price_to_pay = self.reservation.final_price
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
self.reservation.status = "cancelled"
self.reservation.save()
return super().delete(*args, **kwargs)

View File

@@ -0,0 +1,178 @@
from rest_framework import serializers
from django.utils import timezone
from trznice.utils import RoundedDateTimeField
from account.serializers import CustomUserSerializer
from booking.serializers import ReservationSerializer
from account.models import CustomUser
from booking.models import Event, MarketSlot, Reservation
from .models import Order
from decimal import Decimal
import logging
logger = logging.getLogger(__name__)
#počítaní ceny!!! (taky validní)
class SlotPriceInputSerializer(serializers.Serializer):
slot_id = serializers.PrimaryKeyRelatedField(queryset=MarketSlot.objects.all())
used_extension = serializers.FloatField(min_value=0)
#počítaní ceny!!! (počítá správně!!)
class PriceCalculationSerializer(serializers.Serializer):
slot = serializers.PrimaryKeyRelatedField(queryset=MarketSlot.objects.all())
reserved_from = RoundedDateTimeField()
reserved_to = RoundedDateTimeField()
used_extension = serializers.FloatField(min_value=0, required=False)
final_price = serializers.DecimalField(max_digits=8, decimal_places=2, read_only=True)
def validate(self, data):
from django.utils.timezone import make_aware, is_naive
reserved_from = data["reserved_from"]
reserved_to = data["reserved_to"]
if is_naive(reserved_from):
reserved_from = make_aware(reserved_from)
if is_naive(reserved_to):
reserved_to = make_aware(reserved_to)
duration = reserved_to - reserved_from
days = duration.days + 1 # zahrnujeme první den
data["reserved_from"] = reserved_from
data["reserved_to"] = reserved_to
data["duration"] = days
market_slot = data["slot"]
event = market_slot.event if hasattr(market_slot, "event") else None
if not event or not event.square:
raise serializers.ValidationError("Slot musí být přiřazen k akci, která má náměstí.")
# Get width and height from market_slot
area = market_slot.width * market_slot.height
price_per_m2 = market_slot.price_per_m2 if market_slot.price_per_m2 and market_slot.price_per_m2 > 0 else event.price_per_m2
if not price_per_m2 or price_per_m2 < 0:
raise serializers.ValidationError("Cena za m² není dostupná nebo je záporná.")
# 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"))
data["final_price"] = final_price
return data
class OrderSerializer(serializers.ModelSerializer):
created_at = RoundedDateTimeField(read_only=True, required=False)
payed_at = RoundedDateTimeField(read_only=True, required=False)
user = CustomUserSerializer(read_only=True)
reservation = ReservationSerializer(read_only=True)
user_id = serializers.PrimaryKeyRelatedField(
queryset=CustomUser.objects.all(), source="user", write_only=True, required=False, allow_null=True
)
reservation_id = serializers.PrimaryKeyRelatedField(
queryset=Reservation.objects.all(), source="reservation", write_only=True
)
price_to_pay = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, allow_null=True
)
class Meta:
model = Order
fields = [
"id",
"user", # nested read-only
"user_id", # required in POST/PUT
"reservation", # nested read-only
"reservation_id", # required in POST/PUT
"created_at",
"status",
"note",
"price_to_pay",
"payed_at",
]
read_only_fields = ["id", "created_at", "price_to_pay", "payed_at"]
extra_kwargs = {
"user_id": {"help_text": "ID uživatele, který objednávku vytvořil", "required": False},
"reservation_id": {"help_text": "ID rezervace, ke které se objednávka vztahuje", "required": True},
"status": {"help_text": "Stav objednávky (např. new / paid / cancelled)", "required": False},
"note": {"help_text": "Poznámka k objednávce (volitelné)", "required": False},
"price_to_pay": {
"help_text": "Celková cena, kterou má uživatel zaplatit. Pokud není zadána, převezme se z rezervace.",
"required": False,
"allow_null": True,
},
"payed_at": {"help_text": "Datum a čas, kdy byla objednávka zaplacena", "required": False},
}
def validate(self, data):
if "status" in data and data["status"] not in dict(Order.STATUS_CHOICES):
raise serializers.ValidationError({"status": "Neplatný stav objednávky."})
# status = data.get("status", getattr(self.instance, "status", "pending"))
# payed_at = data.get("payed_at", getattr(self.instance, "payed_at", None))
reservation = data.get("reservation", getattr(self.instance, "reservation", None))
price = data.get("price_to_pay", getattr(self.instance, "price_to_pay", 0))
errors = {}
# if status == "payed" and not payed_at:
# errors["payed_at"] = "Musíte zadat datum a čas zaplacení, pokud je objednávka zaplacena."
# if status != "payed" and payed_at:
# errors["payed_at"] = "Datum zaplacení může být uvedeno pouze u zaplacených objednávek."
if price is not None and price < 0:
errors["price_to_pay"] = "Cena musí být větší nebo rovna 0."
if reservation:
if self.instance is None and hasattr(reservation, "order"):
errors["reservation"] = "Tato rezervace již má přiřazenou objednávku."
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"]:
errors["user"] = "Pouze administrátor, úředník nebo správce tržiště může vytvářet rezervace pro jiné uživatele."
if errors:
raise serializers.ValidationError(errors)
return data
def create(self, validated_data):
if validated_data.get("reservation"):
validated_data["price_to_pay"] = validated_data["reservation"].final_price
validated_data["user"] = validated_data.pop("user_id", validated_data.get("user"))
validated_data["reservation"] = validated_data.pop("reservation_id", validated_data.get("reservation"))
return super().create(validated_data)
def update(self, instance, validated_data):
old_status = instance.status
new_status = validated_data.get("status", old_status)
logger.debug(f"\n\nUpdating order {instance.id} from status {old_status} to {new_status}\n\n")
if old_status != "payed" and new_status == "payed":
validated_data["payed_at"] = timezone.now()
return super().update(instance, validated_data)

View File

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

11
backend/commerce/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import OrderViewSet, CalculateReservationPriceView
router = DefaultRouter()
router.register(r'orders', OrderViewSet, basename='order')
urlpatterns = [
path('', include(router.urls)),
path("calculate_price/", CalculateReservationPriceView.as_view(), name="calculate_price"),
]

74
backend/commerce/views.py Normal file
View File

@@ -0,0 +1,74 @@
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets, filters, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.decorators import api_view
from decimal import Decimal
from drf_spectacular.utils import extend_schema
from account.permissions import RoleAllowed
from rest_framework.permissions import IsAuthenticated
from .serializers import OrderSerializer, PriceCalculationSerializer
from .filters import OrderFilter
from .models import Order
@extend_schema(
tags=["Order"],
description=(
"Správa objednávek vytvoření, úprava a výpis. Filtrování podle rezervace, uživatele atd.\n\n"
"🔍 **Fulltextové vyhledávání (`?search=`)** prohledává:\n"
"- poznámku (`note`)\n"
"- e-mail uživatele (`user.email`)\n"
"- jméno a příjmení uživatele (`user.first_name`, `user.last_name`)\n"
"- poznámku rezervace (`reservation.note`)\n\n"
"**Příklady:** `?search=jan.novak@example.com`, `?search=poznámka`"
)
)
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all().select_related("user", "reservation").order_by("-created_at")
serializer_class = OrderSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
filterset_class = OrderFilter
ordering_fields = ["created_at", "price_to_pay", "payed_at"]
search_fields = [
"note",
"user__email",
"user__first_name",
"user__last_name",
"reservation__note",
]
permission_classes = [RoleAllowed("admin", "cityClerk", "seller")]
# permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = Order.objects.select_related("user", "reservation").order_by("-created_at")
user = self.request.user
if hasattr(user, "role") and user.role == "seller":
return queryset.filter(user=user)
return queryset
class CalculateReservationPriceView(APIView):
@extend_schema(
request=PriceCalculationSerializer,
responses={200: {"type": "object", "properties": {"final_price": {"type": "number"}}}},
tags=["Order"],
summary="Calculate reservation price",
description="Spočítá celkovou cenu rezervace pro zvolený slot, použitá rozšíření a trvání rezervace"
)
def post(self, request):
serializer = PriceCalculationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
# PriceCalculationSerializer now returns 'final_price' in validated_data
return Response({"final_price": data["final_price"]}, status=status.HTTP_200_OK)

View File

View File

@@ -0,0 +1,22 @@
from django.contrib import admin
from .models import AppConfig
from trznice.admin import custom_admin_site
class AppConfigAdmin(admin.ModelAdmin):
def has_add_permission(self, request):
# Prevent adding more than one instance
return not AppConfig.objects.exists()
def has_delete_permission(self, request, obj=None):
# Prevent deletion
return False
readonly_fields = ('last_changed_by', 'last_changed_at',)
def save_model(self, request, obj, form, change):
obj.last_changed_by = request.user
super().save_model(request, obj, form, change)
custom_admin_site.register(AppConfig, AppConfigAdmin)

View File

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

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2025-08-07 15:13
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AppConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bank_account', models.CharField(blank=True, max_length=255, null=True, validators=[django.core.validators.RegexValidator(code='invalid_bank_account', message='Zadejte platné číslo účtu ve formátu [prefix-]číslo_účtu/kód_banky, např. 1234567890/0100 nebo 123-4567890/0100.', regex='^(\\d{0,6}-)?\\d{10}/\\d{4}$')])),
('sender_email', models.EmailField(max_length=254)),
('last_changed_at', models.DateTimeField(auto_now=True, verbose_name='Kdy byly naposled udělany změny.')),
('last_changed_by', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='app_config', to=settings.AUTH_USER_MODEL, verbose_name='Kdo naposled udělal změny.')),
],
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.4 on 2025-09-25 14:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('configuration', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='appconfig',
name='background_image',
field=models.ImageField(blank=True, help_text='Obrázek pozadí webu (nepovinné).', null=True, upload_to='config/'),
),
migrations.AddField(
model_name='appconfig',
name='contact_email',
field=models.EmailField(blank=True, help_text='Kontaktní e-mail pro veřejnost (může se lišit od odesílací adresy).', max_length=254, null=True),
),
migrations.AddField(
model_name='appconfig',
name='contact_phone',
field=models.CharField(blank=True, help_text='Kontaktní telefon veřejně zobrazený na webu.', max_length=50, null=True),
),
migrations.AddField(
model_name='appconfig',
name='logo',
field=models.ImageField(blank=True, help_text='Logo webu (transparentní PNG doporučeno).', null=True, upload_to='config/'),
),
migrations.AddField(
model_name='appconfig',
name='max_reservations_per_event',
field=models.PositiveIntegerField(default=1, help_text='Maximální počet rezervací (slotů) povolených pro jednoho uživatele na jednu akci.'),
),
migrations.AddField(
model_name='appconfig',
name='variable_symbol',
field=models.PositiveIntegerField(blank=True, help_text='Výchozí variabilní symbol pro platby (pokud není specifikováno jinde).', null=True),
),
]

View File

@@ -0,0 +1,88 @@
from django.db import models
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.conf import settings
class AppConfig(models.Model):
bank_account = models.CharField(
max_length=255,
null=True,
blank=True,
validators=[
RegexValidator(
regex=r'^(\d{0,6}-)?\d{10}/\d{4}$',
message=(
"Zadejte platné číslo účtu ve formátu [prefix-]číslo_účtu/kód_banky, "
"např. 1234567890/0100 nebo 123-4567890/0100."
),
code='invalid_bank_account'
)
],
)
sender_email = models.EmailField()
# ---- New configurable site settings ----
background_image = models.ImageField(
upload_to="config/",
null=True,
blank=True,
help_text="Obrázek pozadí webu (nepovinné)."
)
logo = models.ImageField(
upload_to="config/",
null=True,
blank=True,
help_text="Logo webu (transparentní PNG doporučeno)."
)
variable_symbol = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Výchozí variabilní symbol pro platby (pokud není specifikováno jinde)."
)
max_reservations_per_event = models.PositiveIntegerField(
default=1,
help_text="Maximální počet rezervací (slotů) povolených pro jednoho uživatele na jednu akci."
)
contact_phone = models.CharField(
max_length=50,
null=True,
blank=True,
help_text="Kontaktní telefon veřejně zobrazený na webu."
)
contact_email = models.EmailField(
null=True,
blank=True,
help_text="Kontaktní e-mail pro veřejnost (může se lišit od odesílací adresy)."
)
last_changed_by = models.OneToOneField(
settings.AUTH_USER_MODEL,
verbose_name="Kdo naposled udělal změny.",
on_delete=models.SET_NULL, # 🔄 Better than CASCADE to preserve data
related_name="app_config",
null=True,
blank=True
)
last_changed_at = models.DateTimeField(
auto_now=True, # 🔄 Use auto_now to update on every save
verbose_name="Kdy byly naposled udělany změny."
)
def save(self, *args, **kwargs):
if not self.pk and AppConfig.objects.exists():
raise ValidationError('Only one AppConfig instance allowed.')
return super().save(*args, **kwargs)
def __str__(self):
return "App Configuration"
@classmethod
def get_instance(cls):
return cls.objects.first()
# Usage:
# config = AppConfig.get_instance()
# if config:
# print(config.bank_account)

View File

@@ -0,0 +1,159 @@
from django.apps import apps
from django.conf import settings
from django.db.models.fields.related import ForeignObjectRel
from rest_framework import serializers
from trznice.utils import RoundedDateTimeField # noqa: F401 (kept if used elsewhere later)
from .models import AppConfig
class AppConfigSerializer(serializers.ModelSerializer):
class Meta:
model = AppConfig
fields = "__all__"
read_only_fields = ["last_changed_by", "last_changed_at"]
class AppConfigPublicSerializer(serializers.ModelSerializer):
"""Public-facing limited subset used for navbar assets and basic contact info."""
class Meta:
model = AppConfig
fields = [
"id",
"logo",
"background_image",
"contact_email",
"contact_phone",
"max_reservations_per_event",
]
class TrashItemSerializer(serializers.Serializer):
"""Represents a single soft-deleted instance across any model.
Fields:
model: <app_label.model_name>
id: primary key value
deleted_at: timestamp (if model defines it)
data: remaining field values (excluding soft-delete bookkeeping fields)
"""
model = serializers.CharField()
id = serializers.CharField() # CharField to allow UUIDs as well
deleted_at = serializers.DateTimeField(allow_null=True, required=False)
data = serializers.DictField(child=serializers.CharField(allow_blank=True, allow_null=True))
class TrashSerializer(serializers.Serializer):
"""Aggregates all soft-deleted objects (is_deleted=True) from selected apps.
This dynamically inspects registered models and collects those that:
* Have a concrete field named `is_deleted`
* (Optional) Have a manager named `all_objects`; otherwise fall back to default `objects`
Usage: Serialize with `TrashSerializer()` (no instance needed) and access `.data`.
Optionally you can pass a context key `apps` with an iterable of app labels to restrict search
(default: account, booking, commerce, product, servicedesk).
"""
items = serializers.SerializerMethodField()
SETTINGS_APPS = set(getattr(settings, "MY_CREATED_APPS", []))
EXCLUDE_FIELD_NAMES = {"is_deleted", "deleted_at"}
def get_items(self, _obj): # _obj unused (serializer acts as a data provider)
# Allow overriding via context['apps']; otherwise use all custom apps from settings
target_apps = set(self.context.get("apps", self.SETTINGS_APPS))
results = []
for model in apps.get_models():
app_label = model._meta.app_label
if app_label not in target_apps:
continue
# Fast check for is_deleted field
field_names = {f.name for f in model._meta.get_fields() if not isinstance(f, ForeignObjectRel)}
if "is_deleted" not in field_names:
continue
manager = getattr(model, "all_objects", model._default_manager)
queryset = manager.filter(is_deleted=True)
if not queryset.exists():
continue
# Prepare list of simple (non-relational) field objects for extraction
concrete_fields = [
f for f in model._meta.get_fields()
if not isinstance(f, ForeignObjectRel) and getattr(f, "concrete", False)
]
for instance in queryset:
data = {}
for f in concrete_fields:
if f.name in self.EXCLUDE_FIELD_NAMES:
continue
try:
value = f.value_from_object(instance)
# Represent related FK by its PK only
if f.is_relation and hasattr(value, "pk"):
value = value.pk
except Exception: # noqa: BLE001 - defensive; skip problematic field
value = None
data[f.name] = None if value == "" else value
results.append({
"model": f"{app_label}.{model._meta.model_name}",
"id": instance.pk,
"deleted_at": getattr(instance, "deleted_at", None),
"data": data,
})
# Optional: sort by deleted_at descending if available
results.sort(key=lambda i: (i.get("deleted_at") is None, i.get("deleted_at")), reverse=True)
return results
def to_representation(self, instance): # instance unused
all_items = self.get_items(instance)
request = self.context.get("request")
# ---- Pagination params ----
def _to_int(val, default):
try:
return max(1, int(val))
except Exception:
return default
if request is not None:
page = _to_int(request.query_params.get("page", 1), 1)
page_size = _to_int(request.query_params.get("page_size") or request.query_params.get("limit", 20), 20)
else:
# Fallback when no request in context (e.g., manual usage)
page = 1
page_size = 20
# Enforce reasonable upper bound
MAX_PAGE_SIZE = 200
if page_size > MAX_PAGE_SIZE:
page_size = MAX_PAGE_SIZE
total_items = len(all_items)
total_pages = (total_items + page_size - 1) // page_size if page_size else 1
if page > total_pages and total_pages != 0:
page = total_pages
start = (page - 1) * page_size
end = start + page_size
page_items = all_items[start:end]
pagination = {
"page": page,
"page_size": page_size,
"total_items": total_items,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_previous": page > 1,
}
return {"trash": page_items, "pagination": pagination}

View File

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

View File

@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import AppConfigViewSet, TrashView, AppConfigPublicView
router = DefaultRouter()
router.register(r'', AppConfigViewSet, basename='app_config') # handles /api/config/
urlpatterns = [
path('', include(router.urls)),
path('trash/', TrashView.as_view(), name='trash'),
path('public/', AppConfigPublicView.as_view(), name='app-config-public'),
]

View File

@@ -0,0 +1,200 @@
from rest_framework import viewsets
from rest_framework.exceptions import ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
from rest_framework.views import APIView
from rest_framework.response import Response
from django.utils import timezone
from django.apps import apps as django_apps
from .models import AppConfig
from .serializers import AppConfigSerializer, TrashSerializer, AppConfigPublicSerializer
from account.permissions import OnlyRolesAllowed
@extend_schema(
tags=["AppConfig"],
description=(
"Globální konfigurace aplikace správa bankovního účtu, e-mailu odesílatele a dalších nastavení. "
"Umožňuje úpravu přes administrační rozhraní nebo API.\n\n"
"🛠️ **Singleton model** lze vytvořit pouze jednu instanci konfigurace.\n\n"
"📌 **Přístup pouze pro administrátory** (`role=admin`).\n\n"
"**Dostupné akce:**\n"
"- `GET /api/config/` Získání aktuální konfigurace (singleton)\n"
"- `PUT /api/config/` Úprava konfigurace\n\n"
"**Poznámka:** pokus o vytvoření více než jedné konfigurace vrací chybu 400."
)
)
class AppConfigViewSet(viewsets.ModelViewSet):
queryset = AppConfig.objects.all()
serializer_class = AppConfigSerializer
permission_classes = [OnlyRolesAllowed("admin")]
def get_object(self):
# Always return the singleton instance
return AppConfig.get_instance()
def perform_update(self, serializer):
serializer.save(last_changed_by=self.request.user)
def perform_create(self, serializer):
if AppConfig.objects.exists():
raise ValidationError("Only one AppConfig instance allowed.")
serializer.save(last_changed_by=self.request.user)
class AppConfigPublicView(APIView):
"""Read-only public endpoint with limited AppConfig data (logo, background, contact info).
Returns 404 if no configuration exists yet.
"""
authentication_classes = [] # allow anonymous
permission_classes = []
ALLOWED_FIELDS = {
"id",
"logo",
"background_image",
"contact_email",
"contact_phone",
"max_reservations_per_event",
}
def get(self, request):
cfg = AppConfig.get_instance()
if not cfg:
return Response({"detail": "Not configured"}, status=404)
fields_param = request.query_params.get("fields")
if fields_param:
requested = {f.strip() for f in fields_param.split(",") if f.strip()}
valid = [f for f in requested if f in self.ALLOWED_FIELDS]
if not valid:
return Response({
"detail": "No valid fields requested. Allowed: " + ", ".join(sorted(self.ALLOWED_FIELDS))
}, status=400)
data = {}
for f in valid:
data[f] = getattr(cfg, f, None)
return Response(data)
# default full public subset
return Response(AppConfigPublicSerializer(cfg).data)
@extend_schema(
tags=["Trash"],
description=(
"Agregovaný seznam všech soft-smazaných (is_deleted=True) objektů napříč aplikacemi definovanými v `settings.MY_CREATED_APPS`.\n\n"
"Pagination params:\n"
"- `page` (int, default=1)\n"
"- `page_size` nebo `limit` (int, default=20, max=200)\n\n"
"Volitelné parametry do budoucna: `apps` (comma-separated) pokud bude přidána filtrace.\n\n"
"Response obsahuje pole `trash` a objekt `pagination`. Každá položka má strukturu:\n"
"`{ model: 'app_label.model', id: <pk>, deleted_at: <datetime|null>, data: { ...fields } }`."
),
parameters=[
OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Číslo stránky (>=1)"),
OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Počet záznamů na stránce (default 20, max 200)"),
OpenApiParameter(name="limit", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Alias pro page_size"),
],
)
class TrashView(APIView):
permission_classes = [OnlyRolesAllowed("admin")]
def get(self, request):
# Optional filtering by apps (?apps=account,booking)
ctx = {"request": request}
apps_param = request.query_params.get("apps")
if apps_param:
ctx["apps"] = [a.strip() for a in apps_param.split(",") if a.strip()]
serializer = TrashSerializer(context=ctx)
return Response(serializer.data)
@extend_schema(
request={
'application/json': {
'type': 'object',
'properties': {
'model': {'type': 'string', 'example': 'booking.event', 'description': 'app_label.model_name (lowercase)'},
'id': {'type': 'string', 'example': '5', 'description': 'Primární klíč objektu'},
},
'required': ['model', 'id']
}
},
responses={200: dict, 400: dict, 404: dict},
methods=["PATCH"],
description=(
"Obnovení (undelete) jednoho objektu dle model labelu a ID. Nastaví `is_deleted=False` a `deleted_at=None`.\n\n"
"Body JSON:\n"
"{ 'model': 'booking.event', 'id': '5' }\n\n"
"Pokud už objekt není smazaný, operace je idempotentní a jen vrátí informaci, že je aktivní."
),
)
def patch(self, request):
model_label = request.data.get("model")
obj_id = request.data.get("id")
if not model_label or not obj_id:
return Response({
"success": False,
"error": "Missing 'model' or 'id' in request body"
}, status=400)
if "." not in model_label:
return Response({"success": False, "error": "'model' must be in format app_label.model_name"}, status=400)
app_label, model_name = model_label.split(".", 1)
try:
model = django_apps.get_model(app_label, model_name)
except LookupError:
return Response({"success": False, "error": f"Model '{model_label}' not found"}, status=404)
# Ensure model has is_deleted
if not hasattr(model, 'is_deleted') and 'is_deleted' not in [f.name for f in model._meta.fields]:
return Response({"success": False, "error": f"Model '{model_label}' is not soft-deletable"}, status=400)
manager = getattr(model, 'all_objects', model._default_manager)
try:
instance = manager.get(pk=obj_id)
except model.DoesNotExist:
return Response({"success": False, "error": f"Object with id={obj_id} not found"}, status=404)
current_state = getattr(instance, 'is_deleted', False)
if current_state:
# Restore
setattr(instance, 'is_deleted', False)
if hasattr(instance, 'deleted_at'):
setattr(instance, 'deleted_at', None)
instance.save(update_fields=[f.name for f in instance._meta.fields if f.name in ('is_deleted', 'deleted_at')])
state_changed = True
message = "Object restored"
else:
state_changed = False
message = "No state change already active"
# Build minimal representation
data_repr = {}
for f in instance._meta.fields:
if f.name in ('is_deleted', 'deleted_at'):
continue
try:
val = getattr(instance, f.name)
if f.is_relation and hasattr(val, 'pk'):
val = val.pk
except Exception:
val = None
data_repr[f.name] = val
return Response({
"success": True,
"changed": state_changed,
"message": message,
"item": {
"model": model_label.lower(),
"id": instance.pk,
"is_deleted": getattr(instance, 'is_deleted', False),
"deleted_at": getattr(instance, 'deleted_at', None),
"data": data_repr,
}
}, status=200)

28
backend/dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# Use the official Python image from the Docker Hub
FROM python:3.11-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV SSL False
# Set the working directory
WORKDIR /app
# Copy the requirements file and install dependencies
COPY requirements.txt .
RUN pip config set global.trusted-host \
"pypi.org files.pythonhosted.org pypi.python.org" \
--trusted-host=pypi.python.org \
--trusted-host=pypi.org \
--trusted-host=files.pythonhosted.org
RUN pip install -r requirements.txt
RUN apt-get update
# Copy the project files
COPY . .

View File

@@ -0,0 +1,36 @@
document.getElementById('sendEmailBtn').addEventListener('click', function () {
const recipient = document.getElementById('email-recipient').value;
fetch(`${window.location.origin}/test/email`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json'
},
body: JSON.stringify({ recipient: recipient })
})
.then(response => response.json())
.then(data => {
alert('Success: ' + JSON.stringify(data));
})
.catch(error => {
console.error('Error:', error);
alert('Failed to send request.');
});
});
// Helper function to get CSRF token from cookies
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const trimmed = cookie.trim();
if (trimmed.startsWith(name + '=')) {
cookieValue = decodeURIComponent(trimmed.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}

22
backend/manage.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

309
backend/populate_db.py Normal file
View File

@@ -0,0 +1,309 @@
# Renewed populate_db.py: fills all models with relations and validation
import os
import django
import random
from faker import Faker
from decimal import Decimal
from datetime import datetime, timedelta
from django.core.exceptions import ValidationError
from django.utils import timezone
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "trznice.settings")
django.setup()
from booking.models import Square, Event, MarketSlot, Reservation
from account.models import CustomUser
from product.models import Product, EventProduct
from commerce.models import Order
from servicedesk.models import ServiceTicket
fake = Faker("cs_CZ")
def create_users(n=10):
roles = ['admin', 'seller', 'squareManager', 'cityClerk', 'checker', None]
account_types = ['company', 'individual']
users = []
for _ in range(n):
first_name = fake.first_name()
last_name = fake.last_name()
role = random.choice(roles)
email = fake.unique.email()
prefix = random.choice(["601", "602", "603", "604", "605", "606", "607", "608", "720", "721", "722", "723", "724", "725", "730", "731", "732", "733", "734", "735", "736", "737", "738", "739"])
phone_number = "+420" + prefix + ''.join([str(random.randint(0, 9)) for _ in range(6)])
ico = fake.unique.msisdn()[0:8]
rc = f"{fake.random_int(100000, 999999)}/{fake.random_int(100, 9999)}"
psc = fake.postcode().replace(" ", "")[:5]
bank_prefix = f"{random.randint(0, 999999)}-" if random.random() > 0.5 else ""
bank_number = f"{random.randint(1000000000, 9999999999)}/0100"
bank_account = f"{bank_prefix}{bank_number}"
user = CustomUser(
first_name=first_name,
last_name=last_name,
email=email,
role=role,
account_type=random.choice(account_types),
phone_number=phone_number,
ICO=ico,
RC=rc,
city=fake.city(),
street=fake.street_name() + " " + str(fake.building_number()),
PSC=psc,
GDPR=True,
email_verified=random.choice([True, False]),
bank_account=bank_account,
is_active=True,
)
user.username = user.generate_login(first_name, last_name)
user.set_password("password123")
user.full_clean()
user.save()
users.append(user)
print(f"✅ Vytvořeno {len(users)} uživatelů")
return users
def create_squares(n=3):
squares = []
for _ in range(n):
sq = Square(
name=fake.city() + " náměstí",
description=fake.text(max_nb_chars=200),
street=fake.street_name(),
city=fake.city(),
psc=int(fake.postcode().replace(" ", "")),
width=random.randint(20, 50),
height=random.randint(20, 50),
grid_rows=random.randint(40, 60),
grid_cols=random.randint(40, 60),
cellsize=10,
)
sq.full_clean()
sq.save()
squares.append(sq)
print(f"✅ Vytvořeno {len(squares)} náměstí")
return squares
def create_events(squares, n=7):
events = []
attempts = 0
while len(events) < n and attempts < n * 5:
sq = random.choice(squares)
start = datetime.now() + timedelta(days=random.randint(1, 60))
end = start + timedelta(days=random.randint(1, 5))
overlap = Event.objects.filter(square=sq, start__lt=end, end__gt=start).exists()
if overlap:
attempts += 1
continue
try:
event = Event(
name=fake.catch_phrase(),
description=fake.text(max_nb_chars=300),
square=sq,
start=start,
end=end,
price_per_m2=Decimal(f"{random.randint(10, 100)}.00")
)
event.full_clean()
event.save()
events.append(event)
except ValidationError as e:
continue
print(f"✅ Vytvořeno {len(events)} eventů")
return events
def create_products(n=10):
products = []
for _ in range(n):
name = fake.word().capitalize() + " " + fake.word().capitalize()
code = random.randint(10000, 99999)
product = Product(name=name, code=code)
product.full_clean()
product.save()
products.append(product)
print(f"✅ Vytvořeno {len(products)} produktů")
return products
def create_event_products(events, products, n=15):
event_products = []
for _ in range(n):
product = random.choice(products)
event = random.choice(events)
start = event.start + timedelta(days=random.randint(0, 1))
end = min(event.end, start + timedelta(days=random.randint(1, 3)))
# Ensure timezone-aware datetimes
if timezone.is_naive(start):
start = timezone.make_aware(start)
if timezone.is_naive(end):
end = timezone.make_aware(end)
if timezone.is_naive(event.start):
event_start = timezone.make_aware(event.start)
else:
event_start = event.start
if timezone.is_naive(event.end):
event_end = timezone.make_aware(event.end)
else:
event_end = event.end
# Ensure end is not after event_end and start is not before event_start
if start < event_start:
start = event_start
if end > event_end:
end = event_end
ep = EventProduct(
product=product,
event=event,
start_selling_date=start,
end_selling_date=end
)
try:
ep.full_clean()
ep.save()
event_products.append(ep)
except ValidationError as e:
print(f"❌ EventProduct error: {e}")
continue
print(f"✅ Vytvořeno {len(event_products)} event produktů")
return event_products
def create_market_slots(events, max_slots=8):
slots = []
for event in events:
count = random.randint(3, max_slots)
for _ in range(count):
slot = MarketSlot(
event=event,
status=random.choice(["allowed", "blocked"]),
base_size=round(random.uniform(2, 10), 2),
available_extension=round(random.uniform(0, 5), 2),
x=random.randint(0, 30),
y=random.randint(0, 30),
width=random.randint(2, 10),
height=random.randint(2, 10),
price_per_m2=Decimal(f"{random.randint(10, 100)}.00")
)
slot.full_clean()
slot.save()
# Check fields and relations
assert slot.event == event
assert slot.status in ["allowed", "blocked"]
assert isinstance(slot.base_size, float) or isinstance(slot.base_size, Decimal)
assert isinstance(slot.price_per_m2, Decimal)
slots.append(slot)
print(f"✅ Vytvořeno {len(slots)} prodejních míst")
return slots
def create_reservations(users, slots, event_products, max_per_user=2):
reservations = []
for user in users:
max_res_for_user = min(max_per_user, 5)
user_slots = random.sample(slots, k=min(len(slots), max_res_for_user))
for slot in user_slots:
event = slot.event
event_start = event.start
event_end = event.end
if timezone.is_naive(event_start):
event_start = timezone.make_aware(event_start)
if timezone.is_naive(event_end):
event_end = timezone.make_aware(event_end)
allowed_durations = [1, 7, 30]
duration_days = random.choice(allowed_durations)
max_start = event_end - timedelta(days=duration_days)
if max_start <= event_start:
continue
start = event_start + timedelta(seconds=random.randint(0, int((max_start - event_start).total_seconds())))
end = start + timedelta(days=duration_days)
if timezone.is_naive(start):
start = timezone.make_aware(start)
if timezone.is_naive(end):
end = timezone.make_aware(end)
used_extension = round(random.uniform(0, slot.available_extension), 2)
base_size = Decimal(str(slot.base_size))
price_per_m2 = slot.price_per_m2
final_price = (price_per_m2 * (base_size + Decimal(str(used_extension))) * Decimal(duration_days)).quantize(Decimal("0.01"))
price = final_price # <-- set price field as well
if final_price >= Decimal("1000000.00"):
continue
if user.user_reservations.count() >= 5:
break
try:
res = Reservation(
event=event,
market_slot=slot,
user=user,
used_extension=used_extension,
reserved_from=start,
reserved_to=end,
status="reserved",
final_price=final_price,
price=price,
)
res.full_clean()
res.save()
# Check fields and relations
assert res.event == event
assert res.market_slot == slot
assert res.user == user
assert res.status == "reserved"
# Add event_products to reservation
if event_products:
chosen_eps = random.sample(event_products, k=min(len(event_products), random.randint(0, 2)))
res.event_products.add(*chosen_eps)
reservations.append(res)
except ValidationError:
continue
print(f"✅ Vytvořeno {len(reservations)} rezervací")
return reservations
def create_orders(users, reservations):
orders = []
for res in reservations:
user = res.user
order = Order(
user=user,
reservation=res,
status=random.choice(["payed", "pending", "cancelled"]),
price_to_pay=res.final_price,
note=fake.sentence(),
)
try:
order.full_clean()
order.save()
# Check fields and relations
assert order.user == user
assert order.reservation == res
assert order.status in ["payed", "pending", "cancelled"]
orders.append(order)
except ValidationError:
continue
print(f"✅ Vytvořeno {len(orders)} objednávek")
return orders
def create_service_tickets(users, n=10):
tickets = []
for _ in range(n):
user = random.choice(users)
ticket = ServiceTicket(
title=fake.sentence(nb_words=6),
description=fake.text(max_nb_chars=200),
user=user,
status=random.choice(["new", "in_progress", "resolved", "closed"]),
category=random.choice(["tech", "reservation", "payment", "account", "content", "suggestion", "other"]),
)
try:
ticket.full_clean()
ticket.save()
tickets.append(ticket)
except ValidationError:
continue
print(f"✅ Vytvořeno {len(tickets)} servisních tiketů")
return tickets
if __name__ == "__main__":
users = create_users(10)
squares = create_squares(3)
events = create_events(squares, 7)
products = create_products(10)
event_products = create_event_products(events, products, 15)
slots = create_market_slots(events, max_slots=8)
reservations = create_reservations(users, slots, event_products, max_per_user=2)
orders = create_orders(users, reservations)
tickets = create_service_tickets(users, 10)
print("🎉 Naplnění databáze dokončeno.")

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

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

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

89
backend/requirements.txt Normal file
View File

@@ -0,0 +1,89 @@
# -- BASE --
requests
pip
python-dotenv # .env support
virtualenv #venv
Django
numpy # NumPy je knihovna programovacího jazyka Python, která poskytuje infrastrukturu pro práci s vektory, maticemi a obecně vícerozměrnými poli.
# -- DATABASE --
sqlparse #non-validating SQL parser for Python. It provides support for parsing, splitting and formatting SQL statements.
tzdata #timezone
psycopg[binary] #PostgreSQL database adapter for the Python
django-filter
django-constance #allows you to store and manage settings of page in the Django admin interface!!!!
# -- OBJECT STORAGE --
Pillow #adds image processing capabilities to your Python interpreter
whitenoise #pomáha se spuštěním serveru a načítaní static files
django-cleanup #odstraní zbytečné media soubory které nejsou v databázi/modelu
django-storages # potřeba k S3 bucket storage
boto3
# -- PROTOCOLS (asgi, websockets) --
redis
channels_redis
channels #django channels
#channels requried package
uvicorn[standard]
daphne
gunicorn
Twisted[tls,http2] #slouží aby fungovali jwt a CORS bezpečnost na localhostu
# -- REST API --
djangorestframework #REST Framework
djangorestframework-api-key #API key
djangorestframework-simplejwt #JWT authentication for Django REST Framework
PyJWT #JSON Web Token implementation in Python
asgiref #ASGI reference implementation, to be used with Django Channels
pytz
# pytz brings the Olson tz database into Python and allows
# accurate and cross platform timezone calculations.
# It also solves the issue of ambiguous times at the end of daylight saving time.
#documentation for frontend dev
drf-spectacular
# -- APPS --
django-tinymce
django-cors-headers #csfr
celery #slouží k vytvaření asynchoních úkolu (třeba každou hodinu vyčistit cache atd.)
django-celery-beat #slouží k plánování úkolů pro Celery
# -- EDITING photos, gifs, videos --
#aiofiles
#opencv-python #moviepy use this better instead of pillow
#moviepy
#yt-dlp
weasyprint #tvoření PDFek z html dokumentu + css styly
## -- MISCELLANEOUS --
faker #generates fake data for testing purposes

View File

View File

@@ -0,0 +1,30 @@
from django.contrib import admin
from .models import ServiceTicket
from trznice.admin import custom_admin_site
class ServiceTicketAdmin(admin.ModelAdmin):
list_display = ("id", "title", "status", "user", "created_at", "is_deleted")
list_filter = ("status", "is_deleted")
search_fields = ("title", "description", "user__username", "user__email")
ordering = ("-created_at",)
readonly_fields = ['created_at']
base_fields = ['title', 'category', 'description', 'user', 'status', 'created_at']
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(ServiceTicket, ServiceTicketAdmin)

View File

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

View File

@@ -0,0 +1,11 @@
import django_filters
from .models import ServiceTicket
class ServiceTicketFilter(django_filters.FilterSet):
user = django_filters.NumberFilter(field_name="user__id")
status = django_filters.ChoiceFilter(choices=ServiceTicket.STATUS_CHOICES)
category = django_filters.ChoiceFilter(choices=ServiceTicket.CATEGORY_CHOICES)
class Meta:
model = ServiceTicket
fields = ["user", "status", "category"]

View File

@@ -0,0 +1,34 @@
# 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 = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ServiceTicket',
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)),
('title', models.CharField(max_length=255, verbose_name='Název')),
('description', models.TextField(blank=True, null=True, verbose_name='Popis problému')),
('status', models.CharField(blank=True, choices=[('new', 'Nový'), ('in_progress', 'Řeší se'), ('resolved', 'Vyřešeno'), ('closed', 'Uzavřeno')], default='new', max_length=20, verbose_name='Stav')),
('category', models.CharField(blank=True, choices=[('tech', 'Technická chyba'), ('reservation', 'Chyba při rezervaci'), ('payment', 'Problém s platbou'), ('account', 'Problém s účtem'), ('content', 'Nesrovnalost v obsahu'), ('suggestion', 'Návrh na zlepšení'), ('other', 'Jiný')], default='tech', max_length=20, verbose_name='Kategorie')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Datum')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to=settings.AUTH_USER_MODEL, verbose_name='Zadavatel')),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,32 @@
from django.db import models
from django.conf import settings
from trznice.models import SoftDeleteModel
class ServiceTicket(SoftDeleteModel):
STATUS_CHOICES = [
("new", "Nový"),
("in_progress", "Řeší se"),
("resolved", "Vyřešeno"),
("closed", "Uzavřeno"),
]
CATEGORY_CHOICES = [
("tech", "Technická chyba"),
("reservation", "Chyba při rezervaci"),
("payment", "Problém s platbou"),
("account", "Problém s účtem"),
("content", "Nesrovnalost v obsahu"),
("suggestion", "Návrh na zlepšení"),
("other", "Jiný"),
]
title = models.CharField(max_length=255, verbose_name="Název")
description = models.TextField(verbose_name="Popis problému", null=True, blank=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="Zadavatel", related_name="tickets", null=False, blank=False)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new", verbose_name="Stav", blank=True)
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default="tech", verbose_name="Kategorie", blank=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Datum", editable=False)
def __str__(self):
return f"{self.title} ({self.get_status_display()})"

View File

@@ -0,0 +1,47 @@
from rest_framework import serializers
from .models import ServiceTicket
from account.models import CustomUser
class ServiceTicketSerializer(serializers.ModelSerializer):
class Meta:
model = ServiceTicket
fields = [
"id", "title", "description", "user",
"status", "category", "created_at"
]
read_only_fields = ["id", "created_at"]
extra_kwargs = {
"title": {"help_text": "Stručný název požadavku", "required": True},
"description": {"help_text": "Detailní popis problému", "required": False},
"user": {"help_text": "ID uživatele, který požadavek zadává", "required": True},
"status": {"help_text": "Stav požadavku (new / in_progress / resolved / closed)", "required": False},
"category": {"help_text": "Kategorie požadavku (tech / reservation / payment / account / content / suggestion / other)", "required": True},
}
def validate(self, data):
user = data.get("user", None)
# if user is None:
# raise serializers.ValidationError("Product is a required field.")
# # Check if user exists in DB
# if not CustomUser.objects.filter(pk=user.pk if hasattr(user, 'pk') else user).exists():
# raise serializers.ValidationError("Neplatné ID Užívatele.")
# Example validation: status must be one of the defined choices
if "status" in data and data["status"] not in dict(ServiceTicket.STATUS_CHOICES):
raise serializers.ValidationError({"status": "Neplatný stav požadavku."})
if "category" in data and data["category"] not in dict(ServiceTicket.CATEGORY_CHOICES):
raise serializers.ValidationError({"category": "Neplatná kategorie požadavku."})
title = data.get("title", "").strip()
if not title:
raise serializers.ValidationError("Název požadavku nemůže být prázdný.")
if len(title) > 255:
raise serializers.ValidationError("Název požadavku nemůže být delší než 255 znaků.")
data["title"] = title # Optional: overwrite with trimmed version
return data

View File

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

View File

@@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ServiceTicketViewSet
router = DefaultRouter()
router.register(r'', ServiceTicketViewSet, basename='tickets')
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,84 @@
from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema
from django.contrib.auth import get_user_model
from .models import ServiceTicket
from .serializers import ServiceTicketSerializer
from .filters import ServiceTicketFilter
from account.email import send_email_with_context
from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import PermissionDenied
# from account.permissions import RoleAllowed
@extend_schema(
tags=["ServiceTicket"],
description="Správa uživatelských požadavků vytvoření, úprava a výpis. Filtrování podle stavu, urgence, uživatele atd."
)
class ServiceTicketViewSet(viewsets.ModelViewSet):
# queryset = ServiceTicket.objects.select_related("user").all().order_by("-created_at")
queryset = ServiceTicket.objects.all().order_by("-created_at")
serializer_class = ServiceTicketSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
filterset_class = ServiceTicketFilter
ordering_fields = ["created_at"]
search_fields = ["title", "description", "user__username"]
permission_classes = [IsAuthenticated]
def get_queryset(self):
user = self.request.user
if user.role in ["admin", "cityClerk"]: # Adjust as needed for staff roles
# return ServiceTicket.objects.select_related("user").all().order_by("-created_at")
return ServiceTicket.objects.all().order_by("-created_at")
else:
# return ServiceTicket.objects.select_related("user").filter(user=user).order_by("-created_at")
return ServiceTicket.objects.filter(user=user).order_by("-created_at")
def get_object(self):
obj = super().get_object()
if self.request.user.role not in ["admin", "cityClerk"] and obj.user != self.request.user:
raise PermissionDenied("Nemáte oprávnění pracovat s tímto požadavkem.")
return obj
def perform_create(self, serializer):
user_request = serializer.save(user=self.request.user)
# Map categories to roles responsible for handling them
category_role_map = {
"tech": "admin",
"reservation": "cityClerk",
"payment": "admin",
"account": "admin",
"content": "admin",
"suggestion": "admin",
"other": "admin"
}
role = category_role_map.get(user_request.category)
if not role:
return # Or log: unknown category, no notification sent
User = get_user_model()
recipients = User.objects.filter(role=role, email__isnull=False).exclude(email="").values_list("email", flat=True)
if not recipients:
recipients = User.objects.filter(role='admin', email__isnull=False).exclude(email="").values_list("email", flat=True)
if not recipients:
return
subject = "Nový uživatelský požadavek"
message = f"""
Nový požadavek byl vytvořen:
Název: {user_request.title}
Kategorie: {user_request.get_category_display()}
Popis: {user_request.description or ""}
Vytvořeno: {user_request.created_at.strftime('%d.%m.%Y %H:%M')}
Zadal: {user_request.user.get_full_name()} ({user_request.user.email})
Spravujte požadavky v systému.
"""
send_email_with_context(list(recipients), subject, message)

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Váš přístup do systému e-Rezervace</title>
</head>
<body>
<p>Dobrý den <strong>{{ username }}</strong>,</p>
<p>byl vám vytvořen účet v systému e-Rezervace.</p>
<p>Přihlašte se kliknutím na následující odkaz:</p>
<p><a href="{{ login_url }}">{{ login_url }}</a></p>
<br>
<p>S pozdravem,<br>Váš tým</p>
</body>
</html>

View File

@@ -0,0 +1,8 @@
Dobrý den {{ username }},
byl vám vytvořen účet v systému e-Rezervace. Přihlašte se přes následující odkaz:
{{ login_url }}
S pozdravem,
Váš tým

View File

@@ -0,0 +1,74 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home - Backend</title>
<style>
body {
background-color: cadetblue;
}
nav {
font-family: sans-serif;
padding: 1rem;
}
nav ol {
list-style-type: none;
padding: 0;
}
nav li {
margin-bottom: 0.5rem;
padding: 0.5em;
}
nav a {
text-decoration: none;
color: #2c3e50;
}
nav a:hover {
color: #2980b9;
}
</style>
</head>
<body>
{% if user.is_authenticated %}
<h1>Logged as: {{user.username}} | Role: {{user.role}}</h1>
{% endif %}
<nav>
<ol>
{% if user.is_authenticated %}
<li style="background-color: greenyellow; width: max-content; "><a href="/logout/">Logout</a></li>
{% else %}
<li style="background-color: greenyellow; width: max-content; "><a href="/admin/">Login</a></li>
{% endif %}
<li style="background-color: red; width: max-content; "><a href="/admin/">Admin</a></li>
<h2>API (Rest UI)</h2>
<li style="background-color: antiquewhite; width: max-content; "><a href="/account/">Account</a></li>
<li style="background-color: antiquewhite; width: max-content; "><a href="/booking/">Booking</a></li>
<h2>Swagger</h2>
<!--<li style="background-color: limegreen; width: max-content; "><a href="/swagger.json">Swagger JSON</a></li>-->
<!--<li style="background-color: limegreen; width: max-content; "><a href="/swagger.yaml">Swagger YAML</a></li>-->
<li style="background-color: limegreen; width: max-content; "><a href="/swagger/">Swagger UI</a></li>
<h2>Other UI</h2>
<li style="background-color: thistle; width: max-content; "><a href="/redoc/">Redoc</a></li>
<h2>Test E-mail</h2>
<label for="recipient">Recipient</label>
<input type="text" name="recipient" id="email-recipient">
<button id="sendEmailBtn">Send!</button>
</ol>
</nav>
<script src="{% static 'js/index.js' %}"></script>
</body>
</html>

View File

View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)

48
backend/trznice/admin.py Normal file
View File

@@ -0,0 +1,48 @@
from django.contrib.admin import AdminSite
from django.contrib import admin
from django_celery_beat.models import PeriodicTask, IntervalSchedule, CrontabSchedule, SolarSchedule, ClockedSchedule
class RoleBasedAdminSite(AdminSite):
site_header = "Tržiště Admin"
site_title = "Tržiště Admin"
index_title = "Přehled"
def get_app_list(self, request):
app_list = super().get_app_list(request)
if not hasattr(request.user, "role"):
return []
role = request.user.role
# define allowed models per role
role_model_access = {
"squareManager": ["Square", "Event", "MarketSlot", "Product", "EventProduct"],
"cityClerk": ["CustomUser", "Event", "MarketSlot", "Reservation", "Product", "EventProduct", "ServiceTicket"],
# admin will see everything
}
# only restrict if user has limited access
if role in role_model_access:
allowed = role_model_access[role]
for app in app_list:
app["models"] = [
model for model in app["models"]
if model["object_name"] in allowed
]
return app_list
# Initialize the custom admin site
custom_admin_site = RoleBasedAdminSite(name='custom_admin')
# # Register your models to the custom admin site
custom_admin_site.register(PeriodicTask)
custom_admin_site.register(IntervalSchedule)
custom_admin_site.register(CrontabSchedule)
custom_admin_site.register(SolarSchedule)
custom_admin_site.register(ClockedSchedule)

16
backend/trznice/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for trznice project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings')
application = get_asgi_application()

18
backend/trznice/celery.py Normal file
View File

@@ -0,0 +1,18 @@
import os
from celery import Celery
from django.conf import settings
# Nastav environment variable pro Django settings modul
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings')
app = Celery('trznice')
# Načti konfiguraci z Django settings (prefix "CELERY_")
app.config_from_object('django.conf:settings', namespace='CELERY')
# Automaticky najdi tasks.py ve všech appkách
# app.autodiscover_tasks()
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
# Optional but recommended for beat to use DB scheduler
# from django_celery_beat.schedulers import DatabaseScheduler

61
backend/trznice/models.py Normal file
View File

@@ -0,0 +1,61 @@
from django.db import models
from django.utils import timezone
class ActiveManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_deleted=False)
class AllManager(models.Manager):
def get_queryset(self):
return super().get_queryset()
# How to use custom object Managers: add these fields to your model, to override objects behaviour and all_objects behaviour
# objects = ActiveManager()
# all_objects = AllManager()
class SoftDeleteModel(models.Model):
is_deleted = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
def delete(self, using=None, keep_parents=False):
self.is_deleted = True
self.deleted_at = timezone.now()
self.save()
objects = ActiveManager()
all_objects = AllManager()
class Meta:
abstract = True
def delete(self, *args, **kwargs):
# Soft delete self
self.is_deleted = True
self.deleted_at = timezone.now()
self.save()
def hard_delete(self, using=None, keep_parents=False):
super().delete(using=using, keep_parents=keep_parents)
# SiteSettings model for managing site-wide settings
"""class SiteSettings(models.Model):
bank = models.CharField(max_length=100, blank=True)
support_email = models.EmailField(blank=True)
logo = models.ImageField(upload_to='settings/', blank=True, null=True)
def __str__(self):
return "Site Settings"
class Meta:
verbose_name = "Site Settings"
verbose_name_plural = "Site Settings"
@classmethod
def get_solo(cls):
obj, created = cls.objects.get_or_create(id=1)
return obj
"""

951
backend/trznice/settings.py Normal file
View File

@@ -0,0 +1,951 @@
"""
Django settings for trznice project.
Generated by 'django-admin startproject' using Django 5.1.3.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""
import os
from typing import Dict, Any
from pathlib import Path
from django.core.management.utils import get_random_secret_key
from django.db import OperationalError, connections
from datetime import timedelta
from dotenv import load_dotenv
load_dotenv() # Pouze načte proměnné lokálně, pokud nejsou dostupné
#---------------- ENV VARIABLES USECASE--------------
# v jiné app si to importneš skrz: from django.conf import settings
# a použiješ takto: settings.FRONTEND_URL
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
#-------------------------BASE ⚙️------------------------
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Pavel
# from django.conf.locale.en import formats as en_formats
DATETIME_INPUT_FORMATS = [
"%Y-%m-%d", # '2025-07-25'
"%Y-%m-%d %H:%M", # '2025-07-25 14:30'
"%Y-%m-%d %H:%M:%S", # '2025-07-25 14:30:59'
"%Y-%m-%dT%H:%M", # '2025-07-25T14:30'
"%Y-%m-%dT%H:%M:%S", # '2025-07-25T14:30:59'
]
LANGUAGE_CODE = 'cs'
TIME_ZONE = 'Europe/Prague'
USE_I18N = True
USE_TZ = True
# SECURITY WARNING: don't run with debug turned on in production!
if os.getenv("DEBUG", "") == "True":
DEBUG = True
else:
DEBUG = False
print(f"\nDEBUG state: {str(DEBUG)}\nDEBUG .env raw: {os.getenv('DEBUG', '')}\n")
#-----------------------BASE END⚙--------------------------
#--------------- URLS 🌐 -------------------
ASGI_APPLICATION = 'trznice.asgi.application' #daphne
ROOT_URLCONF = 'trznice.urls'
LOGIN_URL = '/admin' #nastavení Login adresy
#-----------------------------------------
#----------------------------------- LOGS -------------------------------------------
#slouží pro tisknutí do konzole v dockeru skrz: logger.debug("content")
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {name}: {message}",
"style": "{",
},
},
"root": {
"handlers": ["console"],
"level": "DEBUG" if DEBUG else "INFO",
},
}
"""
import logging
# Vytvoř si logger podle názvu souboru (modulu)
logger = logging.getLogger(__name__)
logger.debug("Ladicí zpráva vidíš jen když je DEBUG = True")
logger.info("Informace např. že uživatel klikl na tlačítko")
logger.warning("Varování něco nečekaného, ale ne kritického")
logger.error("Chyba něco se pokazilo, ale aplikace jede dál")
logger.critical("Kritická chyba selhání systému, třeba pád služby")
"""
#---------------------------------- END LOGS ---------------------------------------
#-------------------------------------SECURITY 🔐------------------------------------
if DEBUG:
SECRET_KEY = 'pernament'
else:
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", get_random_secret_key())
# Honor reverse proxy host/port even without SSL
USE_X_FORWARDED_HOST = True
# Optionally honor proto if you terminate SSL at proxy
# SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SESSION_COOKIE_AGE = 86400 # one day
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
AUTHENTICATION_BACKENDS = [
#'trznice.backend.EmailOrUsernameModelBackend', #custom backend z authentication aplikace
'django.contrib.auth.backends.ModelBackend',
]
#--------------------------------END SECURITY 🔐-------------------------------------
#-------------------------------------CORS + HOSTs 🌐🔐------------------------------------
ALLOWED_HOSTS = ["*"]
from urllib.parse import urlparse
parsed = urlparse(FRONTEND_URL)
CSRF_TRUSTED_ORIGINS = [
f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
"http://192.168.67.98",
"https://itsolutions.vontor.cz",
"https://react.vontor.cz",
"http://localhost:5173",
"http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
#server
"http://192.168.67.98",
"https://itsolutions.vontor.cz",
"https://react.vontor.cz",
#nginx docker (local)
"http://localhost",
"http://localhost:80",
"http://127.0.0.1",
]
if DEBUG:
CORS_ALLOWED_ORIGINS = [
f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
"http://localhost:5173",
"http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
#server
"http://192.168.67.98",
"https://itsolutions.vontor.cz",
"https://react.vontor.cz",
#nginx docker (local)
"http://localhost",
"http://localhost:80",
"http://127.0.0.1",
]
else:
CORS_ALLOWED_ORIGINS = [
"http://192.168.67.98",
"https://itsolutions.vontor.cz",
"https://react.vontor.cz",
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = False # Tohle musí být false, když používáš credentials
SESSION_COOKIE_SAMESITE = None
CSRF_COOKIE_SAMESITE = None
print("CORS_ALLOWED_ORIGINS =", CORS_ALLOWED_ORIGINS)
print("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS)
print("ALLOWED_HOSTS =", ALLOWED_HOSTS)
#--------------------------------END CORS + HOSTs 🌐🔐---------------------------------
#--------------------------------------SSL 🧾------------------------------------
if os.getenv("SSL", "") == "True":
USE_SSL = True
else:
USE_SSL = False
if USE_SSL is True:
print("SSL turned on!")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# USE_X_FORWARDED_HOST stays True (set above)
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
else:
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
SECURE_SSL_REDIRECT = False
SECURE_BROWSER_XSS_FILTER = False
SECURE_CONTENT_TYPE_NOSNIFF = False
# USE_X_FORWARDED_HOST stays True (set above)
print(f"\nUsing SSL: {USE_SSL}\n")
#--------------------------------END-SSL 🧾---------------------------------
#-------------------------------------REST FRAMEWORK 🛠️------------------------------------
# ⬇️ Základní lifetime konfigurace
ACCESS_TOKEN_LIFETIME = timedelta(minutes=60)
REFRESH_TOKEN_LIFETIME = timedelta(days=5)
# ⬇️ Nastavení SIMPLE_JWT podle režimu
if DEBUG:
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME,
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
"AUTH_COOKIE": "access_token",
"AUTH_COOKIE_REFRESH": "refresh_token",
"AUTH_COOKIE_DOMAIN": None,
"AUTH_COOKIE_SECURE": False,
"AUTH_COOKIE_HTTP_ONLY": True,
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
"AUTH_COOKIE_PATH": "/",
"AUTH_COOKIE_SAMESITE": "Lax", # change to "None" only if you serve via HTTPS; keep Lax if using same-origin
# ...existing code...
}
else:
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME,
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
"AUTH_COOKIE": "access_token",
"AUTH_COOKIE_REFRESH": "refresh_token", # ensure refresh cookie is recognized/used
"AUTH_COOKIE_DOMAIN": None,
"AUTH_COOKIE_SECURE": True, # HTTPS only
"AUTH_COOKIE_HTTP_ONLY": True,
"AUTH_COOKIE_PATH": "/",
"AUTH_COOKIE_SAMESITE": "None", # potřebné pro cross-origin
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
}
REST_FRAMEWORK = {
"DATETIME_FORMAT": "%Y-%m-%d %H:%M",
'DEFAULT_AUTHENTICATION_CLASSES': (
'account.tokens.CookieJWTAuthentication',
'rest_framework_simplejwt.authentication.JWTAuthentication', # <-- allow Bearer Authorization
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.AllowAny',
),
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
}
#--------------------------------END REST FRAMEWORK 🛠️-------------------------------------
#-------------------------------------APPS 📦------------------------------------
MY_CREATED_APPS = [
'account',
'booking',
'product',
'servicedesk',
'commerce',
'configuration',
]
INSTALLED_APPS = [
'daphne', #asgi bude fungovat lokálně (musí být na začátku)
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders', #cors
'django_celery_beat', #slouží k plánování úkolů pro Celery
#'chat.apps.GlobalChatCheck', #tohle se spusti při každé django inicializaci (migration, createmigration, runserver)
#'authentication',
'storages',# Adds support for external storage services like Amazon S3 via django-storages
'django_filters',
'channels' ,# django channels
'rest_framework',
'rest_framework_api_key',
'rest_framework_simplejwt.token_blacklist',
'drf_spectacular', #rest framework, grafické zobrazení
#Nastavení stránky
#'constance',
#'constance.backends.database',
'django.contrib.sitemaps',
'tinymce',
#kvůli bugum je lepší to dát na poslední místo v INSTALLED_APPS
'django_cleanup.apps.CleanupConfig', #app která maže nepoužité soubory(media) z databáze na S3
]
#skládaní dohromady INSTALLED_APPS
INSTALLED_APPS = INSTALLED_APPS[:-1] + MY_CREATED_APPS + INSTALLED_APPS[-1:]
# -------------------------------------END APPS 📦------------------------------------
#-------------------------------------MIDDLEWARE 🧩------------------------------------
# Middleware is a framework of hooks into Django's request/response processing.
MIDDLEWARE = [
# Middleware that allows your backend to accept requests from other domains (CORS)
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
#CUSTOM
#'tools.middleware.CustomMaxUploadSizeMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',# díky tomu funguje načítaní static files
]
#--------------------------------END MIDDLEWARE 🧩---------------------------------
#-------------------------------------CACHE + CHANNELS(ws) 📡🗄️------------------------------------
# Caching settings for Redis (using Docker's internal network name for Redis)
if DEBUG is False:
#PRODUCTION
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://redis:6379/0', # Using the service name `redis` from Docker Compose
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'PASSWORD': os.getenv('REDIS_PASSWORD'), # Make sure to set REDIS_PASSWORD in your environment
},
}
}
# WebSockets Channel Layers (using Redis in production)
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [('redis', 6379)], # Use `redis` service in Docker Compose
},
}
}
else:
#DEVELOPMENT
# Use in-memory channel layer for development (when DEBUG is True)
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels.layers.InMemoryChannelLayer',
}
}
# Use in-memory cache for development (when DEBUG is True)
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
#--------------------------------END CACHE + CHANNELS(ws) 📡🗄️---------------------------------
#-------------------------------------CELERY 📅------------------------------------
# CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND")
try:
import redis
# test connection
r = redis.Redis(host='localhost', port=6379, db=0)
r.ping()
except Exception:
CELERY_BROKER_URL = 'memory://'
CELERY_ACCEPT_CONTENT = os.getenv("CELERY_ACCEPT_CONTENT")
CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER")
CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE")
CELERY_BEAT_SCHEDULER = os.getenv("CELERY_BEAT_SCHEDULER")
# if DEBUG:
# CELERY_BROKER_URL = 'redis://localhost:6379/0'
# try:
# import redis
# # test connection
# r = redis.Redis(host='localhost', port=6379, db=0)
# r.ping()
# except Exception:
# CELERY_BROKER_URL = 'memory://'
# CELERY_ACCEPT_CONTENT = ['json']
# CELERY_TASK_SERIALIZER = 'json'
# CELERY_TIMEZONE = 'Europe/Prague'
# CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
# from celery.schedules import crontab
# CELERY_BEAT_SCHEDULE = {
# 'hard_delete_soft_deleted_monthly': {
# 'task': 'trznice.tasks.hard_delete_soft_deleted_records',
# 'schedule': crontab(minute=0, hour=0, day_of_month=1), # každý první den v měsíci o půlnoci
# },
# 'delete_old_reservations_monthly': {
# 'task': 'account.tasks.delete_old_reservations',
# 'schedule': crontab(minute=0, hour=1, day_of_month=1), # každý první den v měsíci v 1:00 ráno
# },
# }
# else:
# # Nebo nastav dummy broker, aby se úlohy neodesílaly
# CELERY_BROKER_URL = 'memory://' # broker v paměti, pro testování bez Redis
#-------------------------------------END CELERY 📅------------------------------------
#-------------------------------------DATABASE 💾------------------------------------
# Nastavuje výchozí typ primárního klíče pro modely.
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# říka že se úkladá do databáze, místo do cookie
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
USE_DOCKER_DB = os.getenv("USE_DOCKER_DB", "False") in ["True", "true", "1", True]
if USE_DOCKER_DB is False:
# DEV
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3', # Database engine
'NAME': BASE_DIR / 'db.sqlite3', # Path to the SQLite database file
}
}
else:
#DOCKER
DATABASES = {
'default': {
'ENGINE': os.getenv('DATABASE_ENGINE'),
'NAME': os.getenv('POSTGRES_DB'),
'USER': os.getenv('POSTGRES_USER'),
'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
'HOST': os.getenv('DATABASE_HOST'),
'PORT': os.getenv('DATABASE_PORT'),
}
}
print(f"\nUsing Docker DB: {USE_DOCKER_DB}\nDatabase settings: {DATABASES}\n")
AUTH_USER_MODEL = 'account.CustomUser' #class CustomUser(AbstractUser) best practice to use AbstractUser
#--------------------------------END DATABASE 💾---------------------------------
#--------------------------------------PAGE SETTINGS -------------------------------------
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
# Configuration for Constance(variables)
CONSTANCE_CONFIG = {
'BITCOIN_WALLET': ('', 'Public BTC wallet address'),
'SUPPORT_EMAIL': ('admin@example.com', 'Support email'),
}
#--------------------------------------EMAIL 📧--------------------------------------
if DEBUG:
# DEVELOPMENT
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # Use console backend for development
# EMAILY SE BUDOU POSÍLAT DO KONZOLE!!!
else:
# PRODUCTION
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = os.getenv("EMAIL_HOST_DEV")
EMAIL_PORT = int(os.getenv("EMAIL_PORT_DEV", 465))
EMAIL_USE_TLS = True # ❌ Keep this OFF when using SSL
EMAIL_USE_SSL = False # ✅ Must be True for port 465
EMAIL_HOST_USER = os.getenv("EMAIL_USER_DEV")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD_DEV")
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
EMAIL_TIMEOUT = 10
print("---------EMAIL----------\nEMAIL_HOST =", os.getenv("EMAIL_HOST_DEV"))
print("EMAIL_PORT =", os.getenv("EMAIL_PORT_DEV"))
print("EMAIL_USER =", os.getenv("EMAIL_USER_DEV"))
print("EMAIL_USER_PASSWORD =", os.getenv("EMAIL_USER_PASSWORD_DEV"), "\n------------------------")
#----------------------------------EMAIL END 📧-------------------------------------
#-------------------------------------TEMPLATES 🗂️------------------------------------
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
"DIRS": [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
#--------------------------------END TEMPLATES 🗂️---------------------------------
#-------------------------------------MEDIA + STATIC 🖼️, AWS ☁️------------------------------------
# nastavení složky pro globalstaticfiles (static složky django hledá samo)
STATICFILES_DIRS = [
BASE_DIR / 'globalstaticfiles',
]
if os.getenv("USE_AWS", "") == "True":
USE_AWS = True
else:
USE_AWS = False
print(f"\n-------------- USE_AWS: {USE_AWS} --------------")
if USE_AWS is False:
# DEVELOPMENT
# Development: Use local file system storage for static files
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
# Media and Static URL for local dev
MEDIA_URL = os.getenv("MEDIA_URL", "/media/") # URL prefix for media files
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # Local folder for user-uploaded files
STATIC_URL = '/static/'
# Local folder for collected static files
STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
elif USE_AWS:
# PRODUCTION
AWS_LOCATION = "static"
# Production: Use S3 storage
STORAGES = {
"default": {
"BACKEND" : "storages.backends.s3boto3.S3StaticStorage",
},
"staticfiles": {
"BACKEND" : "storages.backends.s3boto3.S3StaticStorage",
},
}
# Media and Static URL for AWS S3
MEDIA_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/media/'
STATIC_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/static/'
CSRF_TRUSTED_ORIGINS.append(STATIC_URL)
# Static files should be collected to a local directory and then uploaded to S3
STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME')
AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', 'us-east-1') # Default to 'us-east-1' if not set
AWS_S3_SIGNATURE_VERSION = 's3v4' # Use AWS Signature Version 4
AWS_S3_USE_SSL = True
AWS_S3_FILE_OVERWRITE = True
AWS_DEFAULT_ACL = None # Set to None to avoid setting a default ACL
print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------")
#--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️---------------------------------
#-------------------------------------TINY MCE ✍️------------------------------------
TINYMCE_JS_URL = 'https://cdn.tiny.cloud/1/no-api-key/tinymce/7/tinymce.min.js'
TINYMCE_DEFAULT_CONFIG = {
"height": "320px",
"width": "960px",
"menubar": "file edit view insert format tools table help",
"plugins": "advlist autolink lists link image charmap print preview anchor searchreplace visualblocks code "
"fullscreen insertdatetime media table paste code help wordcount spellchecker",
"toolbar": "undo redo | bold italic underline strikethrough | fontselect fontsizeselect formatselect | alignleft "
"aligncenter alignright alignjustify | outdent indent | numlist bullist checklist | forecolor "
"backcolor casechange permanentpen formatpainter removeformat | pagebreak | charmap emoticons | "
"fullscreen preview save print | insertfile image media pageembed template link anchor codesample | "
"a11ycheck ltr rtl | showcomments addcomment code",
"custom_undo_redo_levels": 10,
}
TINYMCE_SPELLCHECKER = True
TINYMCE_COMPRESSOR = True
#--------------------------------END-TINY-MCE-SECTION ✍️---------------------------------
#-------------------------------------DRF SPECTACULAR 📊------------------------------------
SPECTACULAR_DEFAULTS: Dict[str, Any] = {
# A regex specifying the common denominator for all operation paths. If
# SCHEMA_PATH_PREFIX is set to None, drf-spectacular will attempt to estimate
# a common prefix. Use '' to disable.
# Mainly used for tag extraction, where paths like '/api/v1/albums' with
# a SCHEMA_PATH_PREFIX regex '/api/v[0-9]' would yield the tag 'albums'.
'SCHEMA_PATH_PREFIX': None,
# Remove matching SCHEMA_PATH_PREFIX from operation path. Usually used in
# conjunction with appended prefixes in SERVERS.
'SCHEMA_PATH_PREFIX_TRIM': False,
# Insert a manual path prefix to the operation path, e.g. '/service/backend'.
# Use this for example to align paths when the API is mounted as a sub-resource
# behind a proxy and Django is not aware of that. Alternatively, prefixes can
# also specified via SERVERS, but this makes the operation path more explicit.
'SCHEMA_PATH_PREFIX_INSERT': '',
# Coercion of {pk} to {id} is controlled by SCHEMA_COERCE_PATH_PK. Additionally,
# some libraries (e.g. drf-nested-routers) use "_pk" suffixed path variables.
# This setting globally coerces path variables like "{user_pk}" to "{user_id}".
'SCHEMA_COERCE_PATH_PK_SUFFIX': False,
# Schema generation parameters to influence how components are constructed.
# Some schema features might not translate well to your target.
# Demultiplexing/modifying components might help alleviate those issues.
'DEFAULT_GENERATOR_CLASS': 'drf_spectacular.generators.SchemaGenerator',
# Create separate components for PATCH endpoints (without required list)
'COMPONENT_SPLIT_PATCH': True,
# Split components into request and response parts where appropriate
# This setting is highly recommended to achieve the most accurate API
# description, however it comes at the cost of having more components.
'COMPONENT_SPLIT_REQUEST': True,
# Aid client generator targets that have trouble with read-only properties.
'COMPONENT_NO_READ_ONLY_REQUIRED': False,
# Adds "minLength: 1" to fields that do not allow blank strings. Deactivated
# by default because serializers do not strictly enforce this on responses and
# so "minLength: 1" may not always accurately describe API behavior.
# Gets implicitly enabled by COMPONENT_SPLIT_REQUEST, because this can be
# accurately modeled when request and response components are separated.
'ENFORCE_NON_BLANK_FIELDS': False,
# This version string will end up the in schema header. The default OpenAPI
# version is 3.0.3, which is heavily tested. We now also support 3.1.0,
# which contains the same features and a few mandatory, but minor changes.
'OAS_VERSION': '3.0.3',
# Configuration for serving a schema subset with SpectacularAPIView
'SERVE_URLCONF': None,
# complete public schema or a subset based on the requesting user
'SERVE_PUBLIC': True,
# include schema endpoint into schema
'SERVE_INCLUDE_SCHEMA': True,
# list of authentication/permission classes for spectacular's views.
'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'], #account.permissions.AdminOnly
# None will default to DRF's AUTHENTICATION_CLASSES
'SERVE_AUTHENTICATION': None,
# Dictionary of general configuration to pass to the SwaggerUI({ ... })
# https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
# The settings are serialized with json.dumps(). If you need customized JS, use a
# string instead. The string must then contain valid JS and is passed unchanged.
'SWAGGER_UI_SETTINGS': {
'deepLinking': True,
},
# Initialize SwaggerUI with additional OAuth2 configuration.
# https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/
'SWAGGER_UI_OAUTH2_CONFIG': {},
# Dictionary of general configuration to pass to the Redoc.init({ ... })
# https://redocly.com/docs/redoc/config/#functional-settings
# The settings are serialized with json.dumps(). If you need customized JS, use a
# string instead. The string must then contain valid JS and is passed unchanged.
'REDOC_UI_SETTINGS': {},
# CDNs for swagger and redoc. You can change the version or even host your
# own depending on your requirements. For self-hosting, have a look at
# the sidecar option in the README.
'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest',
'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest/favicon-32x32.png',
'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@latest',
# Append OpenAPI objects to path and components in addition to the generated objects
'APPEND_PATHS': {},
'APPEND_COMPONENTS': {},
# Postprocessing functions that run at the end of schema generation.
# must satisfy interface result = hook(generator, request, public, result)
'POSTPROCESSING_HOOKS': [
'drf_spectacular.hooks.postprocess_schema_enums'
],
# Preprocessing functions that run before schema generation.
# must satisfy interface result = hook(endpoints=result) where result
# is a list of Tuples (path, path_regex, method, callback).
# Example: 'drf_spectacular.hooks.preprocess_exclude_path_format'
'PREPROCESSING_HOOKS': [],
# Determines how operations should be sorted. If you intend to do sorting with a
# PREPROCESSING_HOOKS, be sure to disable this setting. If configured, the sorting
# is applied after the PREPROCESSING_HOOKS. Accepts either
# True (drf-spectacular's alpha-sorter), False, or a callable for sort's key arg.
'SORT_OPERATIONS': True,
# enum name overrides. dict with keys "YourEnum" and their choice values "field.choices"
# e.g. {'SomeEnum': ['A', 'B'], 'OtherEnum': 'import.path.to.choices'}
'ENUM_NAME_OVERRIDES': {},
# Adds "blank" and "null" enum choices where appropriate. disable on client generation issues
'ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE': True,
# Add/Append a list of (``choice value`` - choice name) to the enum description string.
'ENUM_GENERATE_CHOICE_DESCRIPTION': True,
# Optional suffix for generated enum.
# e.g. {'ENUM_SUFFIX': "Type"} would produce an enum name 'StatusType'.
'ENUM_SUFFIX': 'Enum',
# function that returns a list of all classes that should be excluded from doc string extraction
'GET_LIB_DOC_EXCLUDES': 'drf_spectacular.plumbing.get_lib_doc_excludes',
# Function that returns a mocked request for view processing. For CLI usage
# original_request will be None.
# interface: request = build_mock_request(method, path, view, original_request, **kwargs)
'GET_MOCK_REQUEST': 'drf_spectacular.plumbing.build_mock_request',
# Camelize names like "operationId" and path parameter names
# Camelization of the operation schema itself requires the addition of
# 'drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields'
# to POSTPROCESSING_HOOKS. Please note that the hook depends on
# ``djangorestframework_camel_case``, while CAMELIZE_NAMES itself does not.
'CAMELIZE_NAMES': False,
# Changes the location of the action/method on the generated OperationId. For example,
# "POST": "group_person_list", "group_person_create"
# "PRE": "list_group_person", "create_group_person"
'OPERATION_ID_METHOD_POSITION': 'POST',
# Determines if and how free-form 'additionalProperties' should be emitted in the schema. Some
# code generator targets are sensitive to this. None disables generic 'additionalProperties'.
# allowed values are 'dict', 'bool', None
'GENERIC_ADDITIONAL_PROPERTIES': 'dict',
# Path converter schema overrides (e.g. <int:foo>). Can be used to either modify default
# behavior or provide a schema for custom converters registered with register_converter(...).
# Takes converter labels as keys and either basic python types, OpenApiType, or raw schemas
# as values. Example: {'aint': OpenApiTypes.INT, 'bint': str, 'cint': {'type': ...}}
'PATH_CONVERTER_OVERRIDES': {},
# Determines whether operation parameters should be sorted alphanumerically or just in
# the order they arrived. Accepts either True, False, or a callable for sort's key arg.
'SORT_OPERATION_PARAMETERS': True,
# @extend_schema allows to specify status codes besides 200. This functionality is usually used
# to describe error responses, which rarely make use of list mechanics. Therefore, we suppress
# listing (pagination and filtering) on non-2XX status codes by default. Toggle this to enable
# list responses with ListSerializers/many=True irrespective of the status code.
'ENABLE_LIST_MECHANICS_ON_NON_2XX': False,
# This setting allows you to deviate from the default manager by accessing a different model
# property. We use "objects" by default for compatibility reasons. Using "_default_manager"
# will likely fix most issues, though you are free to choose any name.
"DEFAULT_QUERY_MANAGER": 'objects',
# Controls which authentication methods are exposed in the schema. If not None, will hide
# authentication classes that are not contained in the whitelist. Use full import paths
# like ['rest_framework.authentication.TokenAuthentication', ...].
# Empty list ([]) will hide all authentication methods. The default None will show all.
'AUTHENTICATION_WHITELIST': None,
# Controls which parsers are exposed in the schema. Works analog to AUTHENTICATION_WHITELIST.
# List of allowed parsers or None to allow all.
'PARSER_WHITELIST': None,
# Controls which renderers are exposed in the schema. Works analog to AUTHENTICATION_WHITELIST.
# rest_framework.renderers.BrowsableAPIRenderer is ignored by default if whitelist is None
'RENDERER_WHITELIST': None,
# Option for turning off error and warn messages
'DISABLE_ERRORS_AND_WARNINGS': False,
# Runs exemplary schema generation and emits warnings as part of "./manage.py check --deploy"
'ENABLE_DJANGO_DEPLOY_CHECK': True,
# General schema metadata. Refer to spec for valid inputs
# https://spec.openapis.org/oas/v3.0.3#openapi-object
'TITLE': 'e-Tržnice API',
'DESCRIPTION': 'This is the API documentation for e-Tržnice.',
'TOS': None,
# Optional: MAY contain "name", "url", "email"
'CONTACT': {},
# Optional: MUST contain "name", MAY contain URL
'LICENSE': {},
# Statically set schema version. May also be an empty string. When used together with
# view versioning, will become '0.0.0 (v2)' for 'v2' versioned requests.
# Set VERSION to None if only the request version should be rendered.
'VERSION': '1.0.0',
# Optional list of servers.
# Each entry MUST contain "url", MAY contain "description", "variables"
# e.g. [{'url': 'https://example.com/v1', 'description': 'Text'}, ...]
'SERVERS': [],
# Tags defined in the global scope
'TAGS': [],
# Optional: List of OpenAPI 3.1 webhooks. Each entry should be an import path to an
# OpenApiWebhook instance.
'WEBHOOKS': [],
# Optional: MUST contain 'url', may contain "description"
'EXTERNAL_DOCS': {},
# Arbitrary specification extensions attached to the schema's info object.
# https://swagger.io/specification/#specification-extensions
'EXTENSIONS_INFO': {},
# Arbitrary specification extensions attached to the schema's root object.
# https://swagger.io/specification/#specification-extensions
'EXTENSIONS_ROOT': {},
# Oauth2 related settings. used for example by django-oauth2-toolkit.
# https://spec.openapis.org/oas/v3.0.3#oauth-flows-object
'OAUTH2_FLOWS': [],
'OAUTH2_AUTHORIZATION_URL': None,
'OAUTH2_TOKEN_URL': None,
'OAUTH2_REFRESH_URL': None,
'OAUTH2_SCOPES': None,
}

59
backend/trznice/urls.py Normal file
View File

@@ -0,0 +1,59 @@
"""
URL configuration for trznice project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from django.contrib.auth import views as auth_views
from django.conf.urls.static import static
from django.conf import settings
from rest_framework import permissions
from . import views
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
SpectacularRedocView,
)
from .admin import custom_admin_site
urlpatterns = [
path('login/', auth_views.LoginView.as_view(), name='login'), # pro Swagger
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
# path('admin/', admin.site.urls),
path("admin/", custom_admin_site.urls), # override default admin
path('api/account/', include('account.urls')),
path('api/booking/', include('booking.urls')),
path('api/', include('product.urls')),
path('api/service-tickets/', include('servicedesk.urls')),
path('api/commerce/', include('commerce.urls')),
path('api/config/', include('configuration.urls')),
#rest framework, map of api
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
path('', views.index, name='index'),
path('test/email', views.test_mail, name='test-email')
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

12
backend/trznice/utils.py Normal file
View File

@@ -0,0 +1,12 @@
from rest_framework.fields import DateTimeField
from datetime import datetime
def truncate_to_minutes(dt: datetime) -> datetime:
return dt.replace(second=0, microsecond=0)
class RoundedDateTimeField(DateTimeField):
def to_internal_value(self, value):
dt = super().to_internal_value(value)
return truncate_to_minutes(dt)

38
backend/trznice/views.py Normal file
View File

@@ -0,0 +1,38 @@
from django.shortcuts import render, redirect
from django.core.mail import send_mail
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
import json
def index(request):
return render(request, "html/index.html", context={'user': request.user})
@csrf_exempt
def test_mail(request):
if request.method != "POST":
return JsonResponse({"error": "Only POST allowed"}, status=405)
try:
data = json.loads(request.body)
recipient = data.get("recipient")
if not recipient:
return JsonResponse({"error": "Missing recipient"}, status=400)
send_mail(
subject="Test",
message="Django test mail",
from_email=None, # použije defaultní FROM_EMAIL ze settings
recipient_list=[recipient],
fail_silently=False,
)
return JsonResponse({"success": f"E-mail sent to {recipient}"})
except Exception as e:
import traceback
traceback.print_exc() # vypíše do konzole
return JsonResponse({"error": str(e)}, status=500)

Some files were not shown because too many files have changed in this diff Show More