init
This commit is contained in:
20
backend/.dockerignore
Normal file
20
backend/.dockerignore
Normal 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
BIN
backend/.gitignore
vendored
Normal file
Binary file not shown.
0
backend/account/__init__.py
Normal file
0
backend/account/__init__.py
Normal file
105
backend/account/admin.py
Normal file
105
backend/account/admin.py
Normal 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
6
backend/account/apps.py
Normal 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
108
backend/account/email.py
Normal 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)
|
||||
30
backend/account/filters.py
Normal file
30
backend/account/filters.py
Normal 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
16
backend/account/forms.py
Normal 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
|
||||
@@ -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."))
|
||||
59
backend/account/migrations/0001_initial.py
Normal file
59
backend/account/migrations/0001_initial.py
Normal 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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
backend/account/migrations/__init__.py
Normal file
0
backend/account/migrations/__init__.py
Normal file
199
backend/account/models.py
Normal file
199
backend/account/models.py
Normal 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
|
||||
|
||||
|
||||
72
backend/account/permissions.py
Normal file
72
backend/account/permissions.py
Normal 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'
|
||||
|
||||
224
backend/account/serializers.py
Normal file
224
backend/account/serializers.py
Normal 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
130
backend/account/tasks.py
Normal 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
3
backend/account/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
33
backend/account/tokens.py
Normal file
33
backend/account/tokens.py
Normal 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
28
backend/account/urls.py
Normal 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
62
backend/account/utils.py
Normal 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
409
backend/account/views.py
Normal 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)
|
||||
1
backend/booking/__init__.py
Normal file
1
backend/booking/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# from . import tasks
|
||||
135
backend/booking/admin.py
Normal file
135
backend/booking/admin.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Event, Reservation, MarketSlot, Square, ReservationCheck
|
||||
from .forms import ReservationAdminForm
|
||||
from trznice.admin import custom_admin_site
|
||||
|
||||
class SquareAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "name", "description", "street", "city", "width", "height", "is_deleted")
|
||||
list_filter = ("name", "is_deleted")
|
||||
search_fields = ("name", "description")
|
||||
ordering = ("name",)
|
||||
|
||||
base_fields = ['name', 'description', 'street', 'city', 'psc', 'width', 'height', 'grid_rows', 'grid_cols', 'cellsize', 'image']
|
||||
|
||||
def get_fields(self, request, obj=None):
|
||||
fields = self.base_fields.copy()
|
||||
if request.user.role == "admin":
|
||||
fields += ['is_deleted', 'deleted_at']
|
||||
return fields
|
||||
|
||||
def get_queryset(self, request):
|
||||
# Use the all_objects manager to show even soft-deleted entries
|
||||
if request.user.role == "admin":
|
||||
qs = self.model.all_objects.all()
|
||||
else:
|
||||
qs = self.model.objects.all()
|
||||
return qs
|
||||
|
||||
custom_admin_site.register(Square, SquareAdmin)
|
||||
|
||||
# @admin.register(Event)
|
||||
class EventAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "name", "square", "start", "end", "price_per_m2", "is_deleted")
|
||||
list_filter = ("start", "end", "is_deleted")
|
||||
search_fields = ("name", "description")
|
||||
ordering = ("-start",)
|
||||
|
||||
base_fields = ['name', 'description', 'square', 'price_per_m2', 'start', 'end', 'image']
|
||||
|
||||
def get_fields(self, request, obj=None):
|
||||
fields = self.base_fields.copy()
|
||||
if request.user.role == "admin":
|
||||
fields += ['is_deleted', 'deleted_at']
|
||||
return fields
|
||||
|
||||
def get_queryset(self, request):
|
||||
# Use the all_objects manager to show even soft-deleted entries
|
||||
if request.user.role == "admin":
|
||||
qs = self.model.all_objects.all()
|
||||
else:
|
||||
qs = self.model.objects.all()
|
||||
return qs
|
||||
|
||||
custom_admin_site.register(Event, EventAdmin)
|
||||
|
||||
# @admin.register(Reservation)
|
||||
class ReservationAdmin(admin.ModelAdmin):
|
||||
form = ReservationAdminForm
|
||||
|
||||
list_display = ("id", "event", "user", "reserved_from", "reserved_to", "status", "created_at", "is_checked", "is_deleted")
|
||||
list_filter = ("status", "user", "event", "is_deleted")
|
||||
search_fields = ("user__username", "user__email", "event__name", "note")
|
||||
ordering = ("-created_at",)
|
||||
filter_horizontal = ['event_products'] # adds a nice widget for selection
|
||||
|
||||
base_fields = ['event', 'market_slot', 'user', 'status', 'used_extension', 'event_products', 'reserved_to', 'reserved_from', 'final_price', 'note', "is_checked", "last_checked_at", "last_checked_by"]
|
||||
|
||||
def get_fields(self, request, obj=None):
|
||||
fields = self.base_fields.copy()
|
||||
if request.user.role == "admin":
|
||||
fields += ['is_deleted', 'deleted_at']
|
||||
return fields
|
||||
|
||||
def get_queryset(self, request):
|
||||
# Use the all_objects manager to show even soft-deleted entries
|
||||
if request.user.role == "admin":
|
||||
qs = self.model.all_objects.all()
|
||||
else:
|
||||
qs = self.model.objects.all()
|
||||
return qs
|
||||
|
||||
custom_admin_site.register(Reservation, ReservationAdmin)
|
||||
|
||||
|
||||
class MarketSlotAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "event", "number", "status", "base_size", "available_extension", "price_per_m2", "x", "y", "width", "height", "is_deleted")
|
||||
list_filter = ("status", "event", "is_deleted")
|
||||
search_fields = ("event__name",)
|
||||
ordering = ("event", "status")
|
||||
|
||||
base_fields = ['event', 'status', 'number', 'base_size', 'available_extension', 'price_per_m2', 'width', 'height', 'x', 'y']
|
||||
|
||||
readonly_fields = ("id", "number") # zde
|
||||
|
||||
def get_fields(self, request, obj=None):
|
||||
fields = self.base_fields.copy()
|
||||
if request.user.role == "admin":
|
||||
fields += ['is_deleted', 'deleted_at']
|
||||
return fields
|
||||
|
||||
def get_queryset(self, request):
|
||||
# Use the all_objects manager to show even soft-deleted entries
|
||||
if request.user.role == "admin":
|
||||
qs = self.model.all_objects.all()
|
||||
else:
|
||||
qs = self.model.objects.all()
|
||||
return qs
|
||||
|
||||
custom_admin_site.register(MarketSlot, MarketSlotAdmin)
|
||||
|
||||
|
||||
class ReservationCheckAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "reservation", "checker", "checked_at", "is_deleted")
|
||||
list_filter = ("reservation", "checker", "is_deleted")
|
||||
search_fields = ("checker__email", "reservation__event__name")
|
||||
ordering = ("-checked_at",)
|
||||
|
||||
base_fields = ["reservation", "checker", "checked_at"]
|
||||
|
||||
readonly_fields = ("id", "checked_at") # zde
|
||||
|
||||
def get_fields(self, request, obj=None):
|
||||
fields = self.base_fields.copy()
|
||||
if request.user.role == "admin":
|
||||
fields += ['is_deleted', 'deleted_at']
|
||||
return fields
|
||||
|
||||
def get_queryset(self, request):
|
||||
# Use the all_objects manager to show even soft-deleted entries
|
||||
if request.user.role == "admin":
|
||||
qs = self.model.all_objects.all()
|
||||
else:
|
||||
qs = self.model.objects.all()
|
||||
return qs
|
||||
custom_admin_site.register(ReservationCheck, ReservationCheckAdmin)
|
||||
9
backend/booking/apps.py
Normal file
9
backend/booking/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BookingConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'booking'
|
||||
|
||||
def ready(self):
|
||||
import booking.signals # <-- this line is important
|
||||
23
backend/booking/filters.py
Normal file
23
backend/booking/filters.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import django_filters
|
||||
from .models import Event, Reservation
|
||||
|
||||
class EventFilter(django_filters.FilterSet):
|
||||
start_after = django_filters.IsoDateTimeFilter(field_name="start", lookup_expr="gte")
|
||||
end_before = django_filters.IsoDateTimeFilter(field_name="end", lookup_expr="lte")
|
||||
city = django_filters.CharFilter(field_name="square__city", lookup_expr="icontains")
|
||||
square = django_filters.NumberFilter(field_name="square__id") # přidáno filtrování podle ID náměstí
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ["start_after", "end_before", "city", "square"] # přidáno "square"
|
||||
|
||||
|
||||
|
||||
class ReservationFilter(django_filters.FilterSet):
|
||||
event = django_filters.NumberFilter(field_name="event__id")
|
||||
user = django_filters.NumberFilter(field_name="user__id")
|
||||
status = django_filters.ChoiceFilter(choices=Reservation.STATUS_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = Reservation
|
||||
fields = ["event", "user", "status"]
|
||||
21
backend/booking/forms.py
Normal file
21
backend/booking/forms.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import Reservation
|
||||
|
||||
class ReservationAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Reservation
|
||||
fields = '__all__'
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
event = cleaned_data.get('event')
|
||||
products = cleaned_data.get('event_products')
|
||||
|
||||
if event and products:
|
||||
invalid_products = [p for p in products if p.event != event]
|
||||
if invalid_products:
|
||||
product_names = ', '.join(str(p) for p in invalid_products)
|
||||
raise ValidationError(f"Některé produkty nepatří k této akci: {product_names}")
|
||||
|
||||
return cleaned_data
|
||||
0
backend/booking/management/__init__.py
Normal file
0
backend/booking/management/__init__.py
Normal file
0
backend/booking/management/commands/__init__.py
Normal file
0
backend/booking/management/commands/__init__.py
Normal file
55
backend/booking/management/commands/seed_celery_beat.py
Normal file
55
backend/booking/management/commands/seed_celery_beat.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# yourapp/management/commands/seed_celery_beat.py
|
||||
import json
|
||||
from django.utils import timezone
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_celery_beat.models import PeriodicTask, IntervalSchedule, CrontabSchedule
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seeds the database with predefined Celery Beat tasks."
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# # Example 1 — Run every 10 minutes
|
||||
# schedule, _ = IntervalSchedule.objects.get_or_create(
|
||||
# every=10,
|
||||
# period=IntervalSchedule.MINUTES,
|
||||
# )
|
||||
|
||||
# Example 2 — Run each 5 minutes
|
||||
crontab_delete_unpayed, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute='*/5',
|
||||
hour='*',
|
||||
day_of_week='*',
|
||||
day_of_month='*',
|
||||
month_of_year='*',
|
||||
timezone=timezone.get_current_timezone_name(),
|
||||
)
|
||||
|
||||
PeriodicTask.objects.get_or_create(
|
||||
name='Zrušení nezaplacených rezervací',
|
||||
task='booking.tasks.cancel_unpayed_reservations_task',
|
||||
crontab=crontab_delete_unpayed,
|
||||
args=json.dumps([]), # Optional arguments
|
||||
kwargs=json.dumps({"minutes": 30}),
|
||||
description="Maže Rezervace podle Objednávky, pokud ta nebyla zaplacena v době 30 minut. Tím se uvolní Prodejní Místa pro nové rezervace.\nJako vstupní argument může být zadán počet minut, podle kterého nezaplacená rezervaace bude stornovana."
|
||||
)
|
||||
|
||||
|
||||
crontab_delete_soft, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute='0',
|
||||
hour='1',
|
||||
day_of_week='*',
|
||||
day_of_month='1',
|
||||
month_of_year='*',
|
||||
timezone=timezone.get_current_timezone_name(),
|
||||
)
|
||||
|
||||
PeriodicTask.objects.get_or_create(
|
||||
name='Skartace soft-smazaných záznamů',
|
||||
task='booking.tasks.hard_delete_soft_deleted_records_task',
|
||||
crontab=crontab_delete_soft,
|
||||
args=json.dumps([]), # Optional arguments
|
||||
kwargs=json.dumps({"years": 10, "days": 0}), # Optional kwargs
|
||||
description="Mazání všech záznamů označených jako smazané v databázi.\nJako vstupní argument lze zadat počet let nebo dnů, podle kterého se určí, jak staré záznamy budou trvale odstraněny."
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("✅ Celery Beat tasks have been seeded."))
|
||||
111
backend/booking/migrations/0001_initial.py
Normal file
111
backend/booking/migrations/0001_initial.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-07 15:13
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Event',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('start', models.DateField()),
|
||||
('end', models.DateField()),
|
||||
('price_per_m2', models.DecimalField(decimal_places=2, help_text='Cena za m² pro rezervaci', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='squares-imgs/')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReservationCheck',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('checked_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Square',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('street', models.CharField(default='Ulice není zadaná', max_length=255)),
|
||||
('city', models.CharField(default='Město není zadané', max_length=255)),
|
||||
('psc', models.PositiveIntegerField(default=12345, help_text='Zadejte platné PSČ (5 číslic)', validators=[django.core.validators.MaxValueValidator(99999), django.core.validators.MinValueValidator(10000)])),
|
||||
('width', models.PositiveIntegerField(default=10)),
|
||||
('height', models.PositiveIntegerField(default=10)),
|
||||
('grid_rows', models.PositiveSmallIntegerField(default=60)),
|
||||
('grid_cols', models.PositiveSmallIntegerField(default=45)),
|
||||
('cellsize', models.PositiveIntegerField(default=10)),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='squares-imgs/')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MarketSlot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('status', models.CharField(choices=[('allowed', 'Povoleno'), ('blocked', 'Zablokováno')], default='allowed', max_length=20)),
|
||||
('number', models.PositiveSmallIntegerField(default=1, editable=False, help_text='Pořadové číslo prodejního místa na svém Eventu')),
|
||||
('base_size', models.FloatField(default=0, help_text='Základní velikost (m²)', validators=[django.core.validators.MinValueValidator(0.0)])),
|
||||
('available_extension', models.FloatField(default=0, help_text='Možnost rozšíření (m²)', validators=[django.core.validators.MinValueValidator(0.0)])),
|
||||
('x', models.SmallIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('y', models.SmallIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('width', models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('height', models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('price_per_m2', models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Cena za m² pro toto prodejní místo. Neuvádět, pokud chcete nechat výchozí cenu za m² na tomto Eventu.', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_marketSlots', to='booking.event')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Reservation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('used_extension', models.FloatField(default=0, help_text='Použité rozšíření (m2)', validators=[django.core.validators.MinValueValidator(0.0)])),
|
||||
('reserved_from', models.DateField()),
|
||||
('reserved_to', models.DateField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('status', models.CharField(choices=[('reserved', 'Zarezervováno'), ('cancelled', 'Zrušeno')], default='reserved', max_length=20)),
|
||||
('note', models.TextField(blank=True, null=True)),
|
||||
('final_price', models.DecimalField(decimal_places=2, default=0, help_text='Cena vypočtena automaticky na zakladě ceny za m² prodejního místa a počtu dní rezervace.', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||
('is_checked', models.BooleanField(default=False)),
|
||||
('last_checked_at', models.DateTimeField(blank=True, null=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_reservations', to='booking.event')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
54
backend/booking/migrations/0002_initial.py
Normal file
54
backend/booking/migrations/0002_initial.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-07 15:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('booking', '0001_initial'),
|
||||
('product', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='event_products',
|
||||
field=models.ManyToManyField(blank=True, related_name='reservations', to='product.eventproduct'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='last_checked_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reservations_checker', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='market_slot',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='booking.marketslot'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reservation',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_reservations', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reservationcheck',
|
||||
name='checker',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='performed_checks', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reservationcheck',
|
||||
name='reservation',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checks', to='booking.reservation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='square',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='square_events', to='booking.square'),
|
||||
),
|
||||
]
|
||||
0
backend/booking/migrations/__init__.py
Normal file
0
backend/booking/migrations/__init__.py
Normal file
395
backend/booking/models.py
Normal file
395
backend/booking/models.py
Normal file
@@ -0,0 +1,395 @@
|
||||
from decimal import Decimal
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.conf import settings
|
||||
from django.db.models import Max
|
||||
from django.utils import timezone
|
||||
|
||||
from trznice.models import SoftDeleteModel
|
||||
from trznice.utils import truncate_to_minutes
|
||||
|
||||
|
||||
#náměstí
|
||||
class Square(SoftDeleteModel):
|
||||
name = models.CharField(max_length=255, default="", null=False, blank=False)
|
||||
|
||||
description = models.TextField(null=True, blank=True)
|
||||
|
||||
street = models.CharField(max_length=255, default="Ulice není zadaná", null=False, blank=False)
|
||||
city = models.CharField(max_length=255, default="Město není zadané", null=False, blank=False)
|
||||
psc = models.PositiveIntegerField(
|
||||
default=12345,
|
||||
validators=[
|
||||
MaxValueValidator(99999),
|
||||
MinValueValidator(10000)
|
||||
],
|
||||
help_text="Zadejte platné PSČ (5 číslic)",
|
||||
null=False, blank=False,
|
||||
)
|
||||
|
||||
width = models.PositiveIntegerField(default=10)
|
||||
height = models.PositiveIntegerField(default=10)
|
||||
|
||||
#Grid Parameters
|
||||
grid_rows = models.PositiveSmallIntegerField(default=60)
|
||||
grid_cols = models.PositiveSmallIntegerField(default=45)
|
||||
cellsize = models.PositiveIntegerField(default=10)
|
||||
|
||||
image = models.ImageField(upload_to="squares-imgs/", blank=True, null=True)
|
||||
|
||||
def clean(self):
|
||||
if self.width <= 0 :
|
||||
raise ValidationError("Šířka náměstí nemůže být menší nebo rovna nule.")
|
||||
|
||||
if self.height <= 0:
|
||||
raise ValidationError("Výška náměstí nemůže být menší nebo rovna nule.")
|
||||
|
||||
if self.grid_rows <= 0:
|
||||
raise ValidationError("Počet řádků mapy nemůže být menší nebo rovna nule.")
|
||||
|
||||
if self.grid_cols <= 0:
|
||||
raise ValidationError("Počet sloupců mapy nemůže být menší nebo rovna nule.")
|
||||
|
||||
if self.cellsize <= 0:
|
||||
raise ValidationError("Velikost mapové buňky nemůže být menší nebo rovna nule.")
|
||||
|
||||
return super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
for event in self.square_events.all():
|
||||
event.delete() # ✅ This triggers Event.delete()
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class Event(SoftDeleteModel):
|
||||
"""Celé náměstí
|
||||
|
||||
Args:
|
||||
models (args): w,h skutečné rozměry náměstí | x,y souřadnice levého horního rohu
|
||||
|
||||
"""
|
||||
name = models.CharField(max_length=255, null=False, blank=False)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
square = models.ForeignKey(Square, on_delete=models.CASCADE, related_name="square_events", null=False, blank=False)
|
||||
|
||||
start = models.DateField()
|
||||
end = models.DateField()
|
||||
|
||||
price_per_m2 = models.DecimalField(max_digits=8, decimal_places=2, help_text="Cena za m² pro rezervaci", validators=[MinValueValidator(0)], null=False, blank=False)
|
||||
|
||||
|
||||
image = models.ImageField(upload_to="squares-imgs/", blank=True, null=True)
|
||||
|
||||
|
||||
def clean(self):
|
||||
if not (self.start and self.end):
|
||||
raise ValidationError("Datum začátku a konce musí být neprázdné.")
|
||||
|
||||
# Remove truncate_to_minutes and timezone logic
|
||||
if self.start >= self.end:
|
||||
raise ValidationError("Datum začátku musí být před datem konce.")
|
||||
|
||||
overlapping = Event.objects.exclude(id=self.id).filter(
|
||||
square=self.square,
|
||||
start__lt=self.end,
|
||||
end__gt=self.start,
|
||||
)
|
||||
if overlapping.exists():
|
||||
raise ValidationError("V tomto termínu už na daném náměstí probíhá jiná událost.")
|
||||
|
||||
return super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
for market_slot in self.event_marketSlots.all():
|
||||
market_slot.delete()
|
||||
# self.event_marketSlots.all().update(is_deleted=True, deleted_at=timezone.now())
|
||||
# self.event_reservations.all().update(is_deleted=True, deleted_at=timezone.now())
|
||||
self.event_products.all().update(is_deleted=True, deleted_at=timezone.now())
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class MarketSlot(SoftDeleteModel):
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="event_marketSlots", null=False, blank=False)
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("allowed", "Povoleno"),
|
||||
("blocked", "Zablokováno"),
|
||||
]
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="allowed")
|
||||
number = models.PositiveSmallIntegerField(default=1, help_text="Pořadové číslo prodejního místa na svém Eventu", editable=False)
|
||||
|
||||
base_size = models.FloatField(default=0, help_text="Základní velikost (m²)", validators=[MinValueValidator(0.0)], null=False, blank=False)
|
||||
available_extension = models.FloatField(default=0, help_text="Možnost rozšíření (m²)", validators=[MinValueValidator(0.0)], null=False, blank=False)
|
||||
|
||||
x = models.SmallIntegerField(default=0, blank=False, validators=[MinValueValidator(0)])
|
||||
y = models.SmallIntegerField(default=0, blank=False, validators=[MinValueValidator(0)])
|
||||
|
||||
width = models.PositiveIntegerField(default=0, blank=False, validators=[MinValueValidator(0)])
|
||||
height = models.PositiveIntegerField(default=0, blank=False, validators=[MinValueValidator(0)])
|
||||
|
||||
price_per_m2 = models.DecimalField(
|
||||
default=Decimal("0.00"),
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text="Cena za m² pro toto prodejní místo. Neuvádět, pokud chcete nechat výchozí cenu za m² na tomto Eventu."
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
if self.base_size <= 0:
|
||||
raise ValidationError("Základní velikost prodejního místa musí být větší než nula.")
|
||||
|
||||
return super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
# TODO: Fix this hovno logic, kdy uyivatel zada 0, se nastavi cena. Vymyslet neco noveho
|
||||
# If price_per_m2 is 0, use the event default
|
||||
# if self.event and hasattr(self.event, 'price_per_m2'):
|
||||
if self.price_per_m2 == 0 and self.event and hasattr(self.event, 'price_per_m2'):
|
||||
self.price_per_m2 = self.event.price_per_m2
|
||||
|
||||
# Automatically assign next available number within the same event
|
||||
if self._state.adding:
|
||||
max_number = MarketSlot.objects.filter(event=self.event).aggregate(Max('number'))['number__max'] or 0
|
||||
self.number = max_number + 1
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"Prodejní místo {self.number} na {self.event}"
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
|
||||
for reservation in self.reservations.all():
|
||||
reservation.delete()
|
||||
# self.marketslot_reservations.all().update(is_deleted=True, deleted_at=timezone.now())
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
class Reservation(SoftDeleteModel):
|
||||
STATUS_CHOICES = [
|
||||
("reserved", "Zarezervováno"),
|
||||
("cancelled", "Zrušeno"),
|
||||
]
|
||||
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="event_reservations", null=False, blank=False)
|
||||
market_slot = models.ForeignKey(
|
||||
'MarketSlot',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='reservations',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="user_reservations", null=False, blank=False)
|
||||
|
||||
used_extension = models.FloatField(default=0 ,help_text="Použité rozšíření (m2)", validators=[MinValueValidator(0.0)])
|
||||
reserved_from = models.DateField(null=False, blank=False)
|
||||
reserved_to = models.DateField(null=False, blank=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="reserved")
|
||||
note = models.TextField(blank=True, null=True)
|
||||
|
||||
final_price = models.DecimalField(
|
||||
default=0,
|
||||
blank=False,
|
||||
null=False,
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text="Cena vypočtena automaticky na zakladě ceny za m² prodejního místa a počtu dní rezervace."
|
||||
)
|
||||
price = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
null=False,
|
||||
blank=False
|
||||
)
|
||||
|
||||
event_products = models.ManyToManyField("product.EventProduct", related_name="reservations", blank=True)
|
||||
|
||||
# Datails about checking
|
||||
#TODO: Dodelat frontend
|
||||
is_checked = models.BooleanField(default=False)
|
||||
last_checked_at = models.DateTimeField(null=True, blank=True)
|
||||
last_checked_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="reservations_checker"
|
||||
)
|
||||
|
||||
def update_check_status(self):
|
||||
last_check = self.checks.filter(is_deleted=False).order_by("-checked_at").first()
|
||||
if last_check:
|
||||
self.is_checked = True
|
||||
self.last_checked_at = last_check.checked_at
|
||||
self.last_checked_by = last_check.checker
|
||||
else:
|
||||
self.is_checked = False
|
||||
self.last_checked_at = None
|
||||
self.last_checked_by = None
|
||||
|
||||
def calculate_price(self):
|
||||
# Use market_slot width and height for area
|
||||
if not self.event or not self.event.square:
|
||||
raise ValidationError("Rezervace musí mít přiřazenou akci s náměstím.")
|
||||
if not self.market_slot:
|
||||
raise ValidationError("Rezervace musí mít přiřazené prodejní místo.")
|
||||
|
||||
area = self.market_slot.width * self.market_slot.height
|
||||
|
||||
price_per_m2 = None
|
||||
if self.market_slot.price_per_m2 and self.market_slot.price_per_m2 > 0:
|
||||
price_per_m2 = self.market_slot.price_per_m2
|
||||
else:
|
||||
price_per_m2 = self.event.price_per_m2
|
||||
|
||||
if not price_per_m2 or price_per_m2 < 0:
|
||||
raise ValidationError("Cena za m² není dostupná nebo je záporná.")
|
||||
|
||||
# Calculate number of days
|
||||
days = (self.reserved_to - self.reserved_from).days + 1
|
||||
|
||||
# Calculate final price using slot area and reserved days
|
||||
final_price = Decimal(area) * Decimal(price_per_m2) * Decimal(days)
|
||||
final_price = final_price.quantize(Decimal("0.01"))
|
||||
return final_price
|
||||
|
||||
def clean(self):
|
||||
if not self.reserved_from or not self.reserved_to:
|
||||
raise ValidationError("Datum rezervace nemůže být prázdný.")
|
||||
|
||||
# Remove truncate_to_minutes and timezone logic
|
||||
if self.reserved_from > self.reserved_to:
|
||||
raise ValidationError("Datum začátku rezervace musí být dříve než její konec.")
|
||||
if self.reserved_from == self.reserved_to:
|
||||
raise ValidationError("Začátek a konec rezervace nemohou být stejné.")
|
||||
|
||||
# Only check for overlapping reservations on the same market_slot
|
||||
if self.market_slot:
|
||||
overlapping = Reservation.objects.exclude(id=self.id).filter(
|
||||
market_slot=self.market_slot,
|
||||
status="reserved",
|
||||
reserved_from__lt=self.reserved_to,
|
||||
reserved_to__gt=self.reserved_from,
|
||||
)
|
||||
else:
|
||||
raise ValidationError("Rezervace musí mít v sobě prodejní místo (MarketSlot).")
|
||||
|
||||
if overlapping.exists():
|
||||
raise ValidationError("Rezervace se překrývá s jinou rezervací na stejném místě.")
|
||||
|
||||
# Check event bounds (date only)
|
||||
if self.event:
|
||||
event_start = self.event.start
|
||||
event_end = self.event.end
|
||||
|
||||
if self.reserved_from < event_start or self.reserved_to > event_end:
|
||||
raise ValidationError("Rezervace musí být v rámci trvání akce.")
|
||||
|
||||
if self.used_extension > self.market_slot.available_extension:
|
||||
raise ValidationError("Požadované rozšíření je větší než možné rožšíření daného prodejního místa.")
|
||||
|
||||
if self.market_slot and self.event != self.market_slot.event:
|
||||
raise ValidationError(f"Prodejní místo {self.market_slot} není část této akce, musí být ze stejné akce jako rezervace.")
|
||||
|
||||
if self.user:
|
||||
if self.user.user_reservations.all().count() > 5:
|
||||
raise ValidationError(f"{self.user} už má 5 rezervací, víc není možno rezervovat pro jednoho uživatele.")
|
||||
else:
|
||||
raise ValidationError("Rezervace musí mít v sobě uživatele.")
|
||||
|
||||
if self.final_price == 0 or self.final_price is None:
|
||||
self.final_price = self.calculate_price()
|
||||
elif self.final_price < 0:
|
||||
raise ValidationError("Cena nemůže být záporná.")
|
||||
|
||||
return super().clean()
|
||||
|
||||
|
||||
def save(self, *args, validate=True, **kwargs):
|
||||
if validate:
|
||||
self.full_clean()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"Rezervace {self.user} na event {self.event.name}"
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
order = getattr(self, "order", None)
|
||||
if order is not None:
|
||||
order.delete()
|
||||
|
||||
# Fix: Use a valid status value for MarketSlot
|
||||
if self.market_slot:
|
||||
event_end_date = self.market_slot.event.end
|
||||
now_date = timezone.now().date()
|
||||
if event_end_date > now_date:
|
||||
self.market_slot.status = "allowed"
|
||||
self.market_slot.save()
|
||||
|
||||
self.checks.all().update(is_deleted=True, deleted_at=timezone.now())
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class ReservationCheck(SoftDeleteModel):
|
||||
reservation = models.ForeignKey(
|
||||
Reservation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="checks"
|
||||
)
|
||||
checker = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name="performed_checks"
|
||||
)
|
||||
checked_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def clean(self):
|
||||
# Check checker role
|
||||
if not self.checker or not hasattr(self.checker, "role") or self.checker.role not in ["admin", "checker"]:
|
||||
raise ValidationError("Uživatel není Kontrolor.")
|
||||
|
||||
# Validate reservation existence (safe check)
|
||||
if not Reservation.objects.filter(pk=self.reservation_id).exists():
|
||||
raise ValidationError("Neplatné ID Rezervace.")
|
||||
|
||||
super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self.is_deleted = True
|
||||
self.deleted_at = timezone.now()
|
||||
self.save()
|
||||
from .signals import update_reservation_check_status
|
||||
# Simulate post_delete behavior
|
||||
update_reservation_check_status(sender=ReservationCheck, instance=self)
|
||||
602
backend/booking/serializers.py
Normal file
602
backend/booking/serializers.py
Normal file
@@ -0,0 +1,602 @@
|
||||
from rest_framework import serializers
|
||||
from datetime import timedelta
|
||||
from booking.models import Event, MarketSlot
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
|
||||
try:
|
||||
from commerce.serializers import PriceCalculationSerializer
|
||||
except ImportError:
|
||||
PriceCalculationSerializer = None
|
||||
|
||||
from trznice.utils import RoundedDateTimeField
|
||||
from .models import Event, MarketSlot, Reservation, Square, ReservationCheck
|
||||
from account.models import CustomUser
|
||||
from product.serializers import EventProductSerializer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#----------------------SHORT SERIALIZERS---------------------------------
|
||||
|
||||
class EventShortSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Square
|
||||
fields = ["id", "name"]
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
"name": {"read_only": True, "help_text": "Název náměstí"}
|
||||
}
|
||||
|
||||
class UserShortSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ["id", "username"]
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
"username": {"read_only": True, "help_text": "username uživatele"}
|
||||
}
|
||||
|
||||
class SquareShortSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Square
|
||||
fields = ["id", "name"]
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
"name": {"read_only": True, "help_text": "Název náměstí"}
|
||||
}
|
||||
|
||||
class ReservationShortSerializer(serializers.ModelSerializer):
|
||||
user = UserShortSerializer(read_only=True)
|
||||
event = EventShortSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Reservation
|
||||
fields = ["id", "user", "event"]
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
"user": {"read_only": True, "help_text": "Majitel rezervace"},
|
||||
"event": {"read_only": True, "help_text": "Akce na které je vytvořena rezervace"}
|
||||
}
|
||||
|
||||
#------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
#------------------------NORMAL SERIALIZERS------------------------------
|
||||
|
||||
class ReservationCheckSerializer(serializers.ModelSerializer):
|
||||
reservation = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Reservation.objects.all(),
|
||||
write_only=True,
|
||||
help_text="ID rezervace, která se kontroluje."
|
||||
)
|
||||
reservation_info = ReservationShortSerializer(source="reservation", read_only=True)
|
||||
|
||||
checker = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||||
checker_info = UserShortSerializer(source="checker", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ReservationCheck
|
||||
fields = [
|
||||
"id", "reservation", "reservation_info",
|
||||
"checker", "checker_info", "checked_at"
|
||||
]
|
||||
read_only_fields = ["id", "checked_at"]
|
||||
|
||||
def validate_reservation(self, value):
|
||||
if value.status != "reserved":
|
||||
raise serializers.ValidationError("Rezervaci lze kontrolovat pouze pokud je ve stavu 'reserved'.")
|
||||
return value
|
||||
|
||||
def validate_checker(self, value):
|
||||
user = self.context["request"].user
|
||||
if not user.is_staff and value != user:
|
||||
raise serializers.ValidationError("Pouze administrátor může nastavit jiného uživatele jako kontrolora.")
|
||||
return value
|
||||
|
||||
|
||||
class ReservationSerializer(serializers.ModelSerializer):
|
||||
reserved_from = serializers.DateField()
|
||||
reserved_to = serializers.DateField()
|
||||
|
||||
event = EventShortSerializer(read_only=True)
|
||||
user = UserShortSerializer(read_only=True)
|
||||
market_slot = serializers.PrimaryKeyRelatedField(
|
||||
queryset=MarketSlot.objects.filter(is_deleted=False), required=True
|
||||
)
|
||||
|
||||
last_checked_by = UserShortSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Reservation
|
||||
fields = [
|
||||
"id", "market_slot",
|
||||
"used_extension", "reserved_from", "reserved_to",
|
||||
"created_at", "status", "note", "final_price",
|
||||
"event", "user", "is_checked", "last_checked_by", "last_checked_at"
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "is_checked", "last_checked_by", "last_checked_at"]
|
||||
extra_kwargs = {
|
||||
"event": {"help_text": "ID (Event), ke které rezervace patří", "required": True},
|
||||
"market_slot": {"help_text": "ID konkrétního prodejního místa (MarketSlot)", "required": True},
|
||||
"user": {"help_text": "ID a název uživatele, který rezervaci vytváří", "required": True},
|
||||
"used_extension": {"help_text": "Velikost rozšíření v m², které chce uživatel využít", "required": True},
|
||||
"reserved_from": {"help_text": "Datum a čas začátku rezervace", "required": True},
|
||||
"reserved_to": {"help_text": "Datum a čas konce rezervace", "required": True},
|
||||
"status": {"help_text": "Stav rezervace (reserved / cancelled)", "required": False, "default": "reserved"},
|
||||
"note": {"help_text": "Poznámka k rezervaci (volitelné)", "required": False},
|
||||
"final_price": {"help_text": "Cena za Rezervaci, počítá se podle plochy prodejního místa a počtů dní.", "required": False, "default": 0},
|
||||
|
||||
"is_checked": {"help_text": "Stav je True, pokud již byla provedena aspoň jedna kontrola.", "required": False, "read_only": True},
|
||||
"last_checked_by": {"help_text": "Kontrolor, který provedl poslední kontrolu.", "required": False, "read_only": True},
|
||||
"last_checked_at": {"help_text": "Čas kdy byla provedena poslední kontrola.", "required": False, "read_only": True}
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
# Accept both "market_slot" and legacy "marketSlot" keys for compatibility
|
||||
if "marketSlot" in data and "market_slot" not in data:
|
||||
data["market_slot"] = data["marketSlot"]
|
||||
# Debug: log incoming data for troubleshooting
|
||||
logger.debug(f"ReservationSerializer.to_internal_value input data: {data}")
|
||||
return super().to_internal_value(data)
|
||||
|
||||
|
||||
def to_internal_value(self, data):
|
||||
# Accept both "market_slot" and legacy "marketSlot" keys for compatibility
|
||||
if "marketSlot" in data and "market_slot" not in data:
|
||||
data["market_slot"] = data["marketSlot"]
|
||||
# Debug: log incoming data for troubleshooting
|
||||
logger.debug(f"ReservationSerializer.to_internal_value input data: {data}")
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate(self, data):
|
||||
logger.debug(f"ReservationSerializer.validate market_slot: {data.get('market_slot')}, event: {data.get('event')}")
|
||||
# Get the event object from the provided event id (if present)
|
||||
event_id = self.initial_data.get("event")
|
||||
if event_id:
|
||||
try:
|
||||
event = Event.objects.get(pk=event_id)
|
||||
data["event"] = event
|
||||
except Event.DoesNotExist:
|
||||
raise serializers.ValidationError({"event": "Zadaná akce (event) neexistuje."})
|
||||
else:
|
||||
event = data.get("event")
|
||||
|
||||
market_slot = data.get("market_slot")
|
||||
# --- FIX: Ensure event is set before permission check in views ---
|
||||
if event is None and market_slot is not None:
|
||||
event = market_slot.event
|
||||
data["event"] = event
|
||||
logger.debug(f"ReservationSerializer.validate auto-filled event from market_slot: {event}")
|
||||
|
||||
|
||||
|
||||
user = data.get("user")
|
||||
request_user = self.context["request"].user if "request" in self.context else None
|
||||
|
||||
# If user is not specified, use the logged-in user
|
||||
if user is None and request_user is not None:
|
||||
user = request_user
|
||||
data["user"] = user
|
||||
|
||||
# If user is specified and differs from logged-in user, check permissions
|
||||
if user is not None and request_user is not None and user != request_user:
|
||||
if request_user.role not in ["admin", "cityClerk", "squareManager"]:
|
||||
raise serializers.ValidationError("Pouze administrátor, úředník nebo správce tržiště může vytvářet rezervace pro jiné uživatele.")
|
||||
|
||||
|
||||
|
||||
if user is None:
|
||||
raise serializers.ValidationError("Rezervace musí mít přiřazeného uživatele.")
|
||||
if user.user_reservations.filter(status="reserved").count() >= 5:
|
||||
raise serializers.ValidationError("Uživatel už má 5 aktivních rezervací.")
|
||||
|
||||
reserved_from = data.get("reserved_from")
|
||||
reserved_to = data.get("reserved_to")
|
||||
used_extension = data.get("used_extension", 0)
|
||||
final_price = data.get("final_price", 0)
|
||||
|
||||
if "status" in data:
|
||||
if self.instance: # update
|
||||
if data["status"] != self.instance.status and user.role not in ["admin", "cityClerk"]:
|
||||
raise serializers.ValidationError({
|
||||
"status": "Pouze administrátor nebo úředník může upravit status rezervace."
|
||||
})
|
||||
else:
|
||||
data["status"] = "reserved"
|
||||
|
||||
privileged_roles = ["admin", "cityClerk"]
|
||||
|
||||
# Define max allowed price based on model's decimal constraints (8 digits, 2 decimal places)
|
||||
MAX_FINAL_PRICE = Decimal("999999.99")
|
||||
|
||||
if user and getattr(user, "role", None) in privileged_roles:
|
||||
# 🧠 Automatický výpočet ceny rezervace pokud není zadána
|
||||
if not final_price or final_price == 0:
|
||||
market_slot = data.get("market_slot")
|
||||
event = data.get("event")
|
||||
reserved_from = data.get("reserved_from")
|
||||
reserved_to = data.get("reserved_to")
|
||||
used_extension = data.get("used_extension", 0)
|
||||
# --- Prefer PriceCalculationSerializer if available ---
|
||||
if PriceCalculationSerializer:
|
||||
try:
|
||||
price_serializer = PriceCalculationSerializer(data={
|
||||
"market_slot": market_slot.id if market_slot else None,
|
||||
"used_extension": used_extension,
|
||||
"reserved_from": reserved_from,
|
||||
"reserved_to": reserved_to,
|
||||
"event": event.id if event else None,
|
||||
"user": user.id if user else None,
|
||||
})
|
||||
price_serializer.is_valid(raise_exception=True)
|
||||
calculated_price = price_serializer.validated_data.get("final_price")
|
||||
if calculated_price is not None:
|
||||
try:
|
||||
# Always quantize to two decimals
|
||||
decimal_price = Decimal(str(calculated_price)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
# Clamp value to max allowed and raise error if exceeded
|
||||
if decimal_price > MAX_FINAL_PRICE:
|
||||
logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})")
|
||||
data["final_price"] = MAX_FINAL_PRICE
|
||||
raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."})
|
||||
else:
|
||||
data["final_price"] = decimal_price
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
raise serializers.ValidationError("Výsledná cena není platné číslo.")
|
||||
else:
|
||||
raise serializers.ValidationError("Výpočet ceny selhal.")
|
||||
except Exception as e:
|
||||
logger.error(f"PriceCalculationSerializer failed: {e}", exc_info=True)
|
||||
market_slot = data.get("market_slot")
|
||||
event = data.get("event")
|
||||
reserved_from = data.get("reserved_from")
|
||||
reserved_to = data.get("reserved_to")
|
||||
used_extension = data.get("used_extension", 0)
|
||||
price_per_m2 = data.get("price_per_m2")
|
||||
if price_per_m2 is None:
|
||||
if market_slot and hasattr(market_slot, "price_per_m2"):
|
||||
price_per_m2 = market_slot.price_per_m2
|
||||
elif event and hasattr(event, "price_per_m2"):
|
||||
price_per_m2 = event.price_per_m2
|
||||
else:
|
||||
raise serializers.ValidationError("Cena za m² není dostupná.")
|
||||
base_size = getattr(market_slot, "base_size", None)
|
||||
if base_size is None:
|
||||
raise serializers.ValidationError("Základní velikost (base_size) není dostupná.")
|
||||
duration_days = (reserved_to - reserved_from).days
|
||||
base_size_decimal = Decimal(str(base_size))
|
||||
used_extension_decimal = Decimal(str(used_extension))
|
||||
duration_days_decimal = Decimal(str(duration_days))
|
||||
price_per_m2_decimal = Decimal(str(price_per_m2))
|
||||
calculated_price = duration_days_decimal * (price_per_m2_decimal * (base_size_decimal + used_extension_decimal))
|
||||
try:
|
||||
decimal_price = Decimal(str(calculated_price)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
# Clamp value to max allowed and raise error if exceeded
|
||||
if decimal_price > MAX_FINAL_PRICE:
|
||||
logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})")
|
||||
data["final_price"] = MAX_FINAL_PRICE
|
||||
raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."})
|
||||
else:
|
||||
data["final_price"] = decimal_price
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
raise serializers.ValidationError("Výsledná cena není platné číslo.")
|
||||
else:
|
||||
price_per_m2 = data.get("price_per_m2")
|
||||
if price_per_m2 is None:
|
||||
if market_slot and hasattr(market_slot, "price_per_m2"):
|
||||
price_per_m2 = market_slot.price_per_m2
|
||||
elif event and hasattr(event, "price_per_m2"):
|
||||
price_per_m2 = event.price_per_m2
|
||||
else:
|
||||
raise serializers.ValidationError("Cena za m² není dostupná.")
|
||||
resolution = event.square.cellsize if event and hasattr(event, "square") else 1
|
||||
width = getattr(market_slot, "width", 1)
|
||||
height = getattr(market_slot, "height", 1)
|
||||
# If you want to include used_extension, add it to area
|
||||
area_m2 = Decimal(width) * Decimal(height) * Decimal(resolution) * Decimal(resolution)
|
||||
duration_days = (reserved_to - reserved_from).days
|
||||
|
||||
price_per_m2_decimal = Decimal(str(price_per_m2))
|
||||
calculated_price = Decimal(duration_days) * area_m2 * price_per_m2_decimal
|
||||
try:
|
||||
decimal_price = Decimal(str(calculated_price)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
# Clamp value to max allowed and raise error if exceeded
|
||||
if decimal_price > MAX_FINAL_PRICE:
|
||||
logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})")
|
||||
data["final_price"] = MAX_FINAL_PRICE
|
||||
raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."})
|
||||
else:
|
||||
data["final_price"] = decimal_price
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
raise serializers.ValidationError("Výsledná cena není platné číslo.")
|
||||
else:
|
||||
if self.instance: # update
|
||||
if final_price != self.instance.final_price and (not user or user.role not in privileged_roles):
|
||||
raise serializers.ValidationError({
|
||||
"final_price": "Pouze administrátor nebo úředník může upravit finální cenu."
|
||||
})
|
||||
else: # create
|
||||
if not user or user.role not in privileged_roles:
|
||||
raise serializers.ValidationError({
|
||||
"final_price": "Pouze administrátor nebo úředník může nastavit finální cenu."
|
||||
})
|
||||
if data.get("final_price") is not None:
|
||||
try:
|
||||
decimal_price = Decimal(str(data["final_price"])).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
# Clamp value to max allowed and raise error if exceeded
|
||||
if decimal_price > MAX_FINAL_PRICE:
|
||||
logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})")
|
||||
data["final_price"] = MAX_FINAL_PRICE
|
||||
raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."})
|
||||
data["final_price"] = decimal_price
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
raise serializers.ValidationError("Výsledná cena není platné číslo.")
|
||||
if data.get("final_price") < 0:
|
||||
raise serializers.ValidationError("Cena za m² nemůže být záporná.")
|
||||
else:
|
||||
# Remove final_price if not privileged
|
||||
data.pop("final_price", None)
|
||||
|
||||
if reserved_from >= reserved_to:
|
||||
raise serializers.ValidationError("Datum začátku rezervace musí být dříve než její konec.")
|
||||
|
||||
if reserved_from < event.start or reserved_to > event.end:
|
||||
raise serializers.ValidationError("Rezervace musí být v rámci trvání akce.")
|
||||
|
||||
overlapping = None
|
||||
if market_slot:
|
||||
if market_slot.event != event:
|
||||
raise serializers.ValidationError("Prodejní místo nepatří do dané akce.")
|
||||
|
||||
if used_extension > market_slot.available_extension:
|
||||
raise serializers.ValidationError("Požadované rozšíření překračuje dostupné rozšíření.")
|
||||
|
||||
overlapping = Reservation.objects.exclude(id=self.instance.id if self.instance else None).filter(
|
||||
event=event,
|
||||
market_slot=market_slot,
|
||||
reserved_from__lt=reserved_to,
|
||||
reserved_to__gt=reserved_from,
|
||||
status="reserved"
|
||||
)
|
||||
|
||||
if overlapping is not None and overlapping.exists():
|
||||
logger.debug(f"ReservationSerializer.validate: Found overlapping reservations for market_slot {market_slot.id} in event {event.id}")
|
||||
raise serializers.ValidationError("Rezervace se překrývá s jinou rezervací na stejném místě.")
|
||||
|
||||
return data
|
||||
|
||||
class ReservationAvailabilitySerializer(serializers.Serializer):
|
||||
event_id = serializers.IntegerField()
|
||||
market_slot_id = serializers.IntegerField()
|
||||
reserved_from = serializers.DateField()
|
||||
reserved_to = serializers.DateField()
|
||||
|
||||
class Meta:
|
||||
model = Reservation
|
||||
fields = ["event", "market_slot", "reserved_from", "reserved_to"]
|
||||
extra_kwargs = {
|
||||
"event": {"help_text": "ID of the event"},
|
||||
"market_slot": {"help_text": "ID of the market slot"},
|
||||
"reserved_from": {"help_text": "Start date of the reservation"},
|
||||
"reserved_to": {"help_text": "End date of the reservation"},
|
||||
}
|
||||
|
||||
def validate(self, data):
|
||||
event_id = data.get("event_id")
|
||||
market_slot_id = data.get("market_slot_id")
|
||||
reserved_from = data.get("reserved_from")
|
||||
reserved_to = data.get("reserved_to")
|
||||
|
||||
if reserved_from >= reserved_to:
|
||||
raise serializers.ValidationError("Konec rezervace musí být po začátku.")
|
||||
|
||||
# Zkontroluj existenci Eventu a Slotu
|
||||
try:
|
||||
event = Event.objects.get(id=event_id)
|
||||
except Event.DoesNotExist:
|
||||
raise serializers.ValidationError("Událost neexistuje.")
|
||||
|
||||
try:
|
||||
market_slot = MarketSlot.objects.get(id=market_slot_id)
|
||||
except MarketSlot.DoesNotExist:
|
||||
raise serializers.ValidationError("Slot neexistuje.")
|
||||
|
||||
# Zkontroluj status slotu
|
||||
if market_slot.status == "blocked":
|
||||
raise serializers.ValidationError("Tento slot je zablokovaný správcem.")
|
||||
|
||||
# Zkontroluj, že datumy spadají do rozsahu události
|
||||
if reserved_from < event.date_from or reserved_to > event.date_to:
|
||||
raise serializers.ValidationError("Vybrané datumy nespadají do trvání akce.")
|
||||
|
||||
# Zkontroluj, jestli už neexistuje kolizní rezervace
|
||||
conflict = Reservation.objects.filter(
|
||||
event=event,
|
||||
market_slot=market_slot,
|
||||
reserved_from__lt=reserved_to,
|
||||
reserved_to__gt=reserved_from,
|
||||
status="reserved"
|
||||
).exists()
|
||||
|
||||
if conflict:
|
||||
raise serializers.ValidationError("Tento slot je v daném termínu již rezervován.")
|
||||
|
||||
return data
|
||||
|
||||
#--- Reservation end ----
|
||||
|
||||
|
||||
class MarketSlotSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MarketSlot
|
||||
fields = [
|
||||
"id", "event", "number", "status",
|
||||
"base_size", "available_extension",
|
||||
"x", "y", "width", "height",
|
||||
"price_per_m2"
|
||||
]
|
||||
|
||||
read_only_fields = ["id", "number"]
|
||||
extra_kwargs = {
|
||||
"event": {"help_text": "ID akce (Event), ke které toto místo patří", "required": True},
|
||||
"number": {"help_text": "Pořadové číslo prodejního místa u Akce, ke které toto místo patří", "required": False},
|
||||
"status": {"help_text": "Stav prodejního místa", "required": False},
|
||||
"base_size": {"help_text": "Základní velikost (m²)", "required": True},
|
||||
"available_extension": {"help_text": "Možnost rozšíření (m²)", "required": False, "default": 0},
|
||||
"x": {"help_text": "X souřadnice levého horního rohu", "required": True},
|
||||
"y": {"help_text": "Y souřadnice levého horního rohu", "required": True},
|
||||
"width": {"help_text": "Šířka Slotu", "required": True},
|
||||
"height": {"help_text": "Výška Slotu", "required": True},
|
||||
"price_per_m2": {"help_text": "Cena za m² tohoto místa", "required": False, "default": 0},
|
||||
}
|
||||
|
||||
def validate_base_size(self, value):
|
||||
if value <= 0:
|
||||
raise serializers.ValidationError("Základní velikost musí být větší než nula.")
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
price_per_m2 = data.setdefault("price_per_m2", 0)
|
||||
if price_per_m2 < 0:
|
||||
raise serializers.ValidationError("Cena za m² nemůže být záporná.")
|
||||
|
||||
if data.setdefault("available_extension", 0) < 0:
|
||||
raise serializers.ValidationError("Velikost možného rozšíření musí být větší než nula.")
|
||||
|
||||
if data.get("width", 0) <= 0 or data.get("height", 0) <= 0:
|
||||
raise serializers.ValidationError("Šířka a výška místa musí být větší než nula.")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class EventSerializer(serializers.ModelSerializer):
|
||||
square = SquareShortSerializer(read_only=True)
|
||||
square_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Square.objects.all(), source="square", write_only=True
|
||||
)
|
||||
|
||||
|
||||
market_slots = MarketSlotSerializer(many=True, read_only=True, source="event_marketSlots")
|
||||
event_products = EventProductSerializer(many=True, read_only=True)
|
||||
|
||||
start = serializers.DateField()
|
||||
end = serializers.DateField()
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = [
|
||||
"id", "name", "description", "start", "end", "price_per_m2", "image", "market_slots", "event_products",
|
||||
"square", # nested read-only
|
||||
"square_id" # required in POST/PUT
|
||||
]
|
||||
read_only_fields = ["id"]
|
||||
extra_kwargs = {
|
||||
"name": {"help_text": "Název události", "required": True},
|
||||
"description": {"help_text": "Popis události", "required": False},
|
||||
"start": {"help_text": "Datum a čas začátku události", "required": True},
|
||||
"end": {"help_text": "Datum a čas konce události", "required": True},
|
||||
"price_per_m2": {"help_text": "Cena za m² pro rezervaci", "required": True},
|
||||
"image": {"help_text": "Obrázek nebo plán náměstí", "required": False, "allow_null": True},
|
||||
|
||||
"market_slots": {"help_text": "Seznam prodejních míst vytvořených v rámci této události", "required": False},
|
||||
"event_products": {"help_text": "Seznam povolených zboží k prodeji v rámci této události", "required": False},
|
||||
|
||||
"square": {"help_text": "Náměstí, na kterém se akce koná (jen ke čtení)", "required": False},
|
||||
"square_id": {"help_text": "ID Náměstí, na kterém se akce koná (jen ke zápis)", "required": True},
|
||||
}
|
||||
|
||||
def validate(self, data):
|
||||
start = data.get("start")
|
||||
end = data.get("end")
|
||||
square = data.get("square")
|
||||
|
||||
if not start or not end or not square:
|
||||
raise serializers.ValidationError("Pole start, end a square musí být vyplněné.")
|
||||
|
||||
if start >= end:
|
||||
raise serializers.ValidationError("Datum začátku musí být před datem konce.")
|
||||
|
||||
if data.get("price_per_m2", 0) <= 0:
|
||||
raise serializers.ValidationError("Cena za m² plochy pro rezervaci musí být větší než 0.")
|
||||
|
||||
overlapping = Event.objects.exclude(id=self.instance.id if self.instance else None).filter(
|
||||
square=square,
|
||||
start__lt=end,
|
||||
end__gt=start,
|
||||
)
|
||||
|
||||
if overlapping.exists():
|
||||
raise serializers.ValidationError("V tomto termínu už na daném náměstí probíhá jiná událost.")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class SquareSerializer(serializers.ModelSerializer):
|
||||
|
||||
image = serializers.ImageField(required=False, allow_null=True) # Ensure DRF handles image upload
|
||||
|
||||
class Meta:
|
||||
model = Square
|
||||
fields = [
|
||||
"id", "name", "description", "street", "city", "psc",
|
||||
"width", "height", "grid_rows", "grid_cols", "cellsize",
|
||||
"image"
|
||||
]
|
||||
read_only_fields = ["id"]
|
||||
extra_kwargs = {
|
||||
"name": {"help_text": "Název náměstí", "required": True},
|
||||
"description": {"help_text": "Popis náměstí", "required": False},
|
||||
"street": {"help_text": "Ulice, kde se náměstí nachází", "required": False},
|
||||
"city": {"help_text": "Město, kde se náměstí nachází", "required": False},
|
||||
"psc": {"help_text": "PSČ (5 číslic)", "required": False},
|
||||
"width": {"help_text": "Šířka náměstí v metrech", "required": True},
|
||||
"height": {"help_text": "Výška náměstí v metrech", "required": True},
|
||||
"grid_rows": {"help_text": "Počet řádků gridu", "required": True},
|
||||
"grid_cols": {"help_text": "Počet sloupců gridu", "required": True},
|
||||
"cellsize": {"help_text": "Velikost buňky gridu v pixelech", "required": True},
|
||||
"image": {"help_text": "Obrázek / mapa náměstí", "required": False},
|
||||
}
|
||||
|
||||
#-----------------------------------------------------------------------
|
||||
class ReservedDaysSerializer(serializers.Serializer):
|
||||
market_slot_id = serializers.IntegerField()
|
||||
reserved_days = serializers.ListField(child=serializers.DateField(), read_only=True)
|
||||
|
||||
def to_representation(self, instance):
|
||||
# Accept instance as dict or int
|
||||
if isinstance(instance, dict):
|
||||
market_slot_id = instance.get("market_slot_id")
|
||||
else:
|
||||
market_slot_id = instance # assume int
|
||||
|
||||
try:
|
||||
market_slot = MarketSlot.objects.get(id=market_slot_id)
|
||||
except MarketSlot.DoesNotExist:
|
||||
return {"market_slot_id": market_slot_id, "reserved_days": []}
|
||||
|
||||
# Get all reserved days for this slot, return each day individually
|
||||
reservations = Reservation.objects.filter(
|
||||
market_slot_id=market_slot_id,
|
||||
status="reserved"
|
||||
)
|
||||
reserved_days = set()
|
||||
for reservation in reservations:
|
||||
current = reservation.reserved_from
|
||||
end = reservation.reserved_to
|
||||
# Convert to date if it's a datetime
|
||||
if hasattr(current, "date"):
|
||||
current = current.date()
|
||||
if hasattr(end, "date"):
|
||||
end = end.date()
|
||||
# Include both start and end dates
|
||||
while current <= end:
|
||||
reserved_days.add(current)
|
||||
current += timedelta(days=1)
|
||||
|
||||
# Return reserved days as a sorted list of individual dates
|
||||
return {
|
||||
"market_slot_id": market_slot_id,
|
||||
"reserved_days": sorted(reserved_days)
|
||||
}
|
||||
9
backend/booking/signals.py
Normal file
9
backend/booking/signals.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from booking.models import ReservationCheck
|
||||
|
||||
@receiver([post_save, post_delete], sender=ReservationCheck)
|
||||
def update_reservation_check_status(sender, instance, **kwargs):
|
||||
reservation = instance.reservation
|
||||
reservation.update_check_status()
|
||||
reservation.save(update_fields=["is_checked", "last_checked_at", "last_checked_by"])
|
||||
116
backend/booking/tasks.py
Normal file
116
backend/booking/tasks.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from celery import shared_task
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.conf import settings
|
||||
from rest_framework.response import Response
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta, datetime
|
||||
from django.apps import apps
|
||||
|
||||
from trznice.models import SoftDeleteModel
|
||||
from booking.models import Reservation, MarketSlot
|
||||
from commerce.models import Order
|
||||
from account.tasks import send_email_with_context
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@shared_task
|
||||
def test_celery_task():
|
||||
logger.info("✅ Test task executed successfully!")
|
||||
return "Hello from Celery!"
|
||||
|
||||
|
||||
def _validate_days_input(years=None, days=None):
|
||||
if years is not None:
|
||||
return years * 365 if years > 0 else 365
|
||||
if days is not None:
|
||||
return days if days > 0 else 365
|
||||
return 365 # default fallback
|
||||
|
||||
@shared_task
|
||||
def hard_delete_soft_deleted_records_task(years=None, days=None):
|
||||
"""
|
||||
Hard delete všech objektů, které jsou soft-deleted (is_deleted=True)
|
||||
a zároveň byly označeny jako smazané (deleted_at) před více než zadaným časovým obdobím.
|
||||
Jako vstupní argument může být zadán počet let nebo dnů, podle kterého se data skartují.
|
||||
"""
|
||||
|
||||
total_days = _validate_days_input(years, days)
|
||||
|
||||
time_period = timezone.now() - timedelta(days=total_days)
|
||||
|
||||
# Pro všechny modely, které dědí z SoftDeleteModel, smaž staré smazané záznamy
|
||||
for model in apps.get_models():
|
||||
if not issubclass(model, SoftDeleteModel):
|
||||
continue
|
||||
if not model._meta.managed or model._meta.abstract:
|
||||
continue
|
||||
if not hasattr(model, "all_objects"):
|
||||
continue
|
||||
|
||||
# Filtrování soft-deleted a starých
|
||||
deleted_qs = model.all_objects.filter(is_deleted=True, deleted_at__lt=time_period)
|
||||
count = deleted_qs.count()
|
||||
|
||||
# Pokud budeme chtit použit custom logiku
|
||||
# for obj in deleted_qs:
|
||||
# obj.hard_delete()
|
||||
|
||||
deleted_qs.delete()
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"Hard deleted {count} records from {model.__name__}")
|
||||
|
||||
return "Successfully completed hard_delete_soft_deleted_records_task"
|
||||
|
||||
|
||||
@shared_task
|
||||
def cancel_unpayed_reservations_task(minutes=30):
|
||||
"""
|
||||
Smaže Rezervace podle Objednávky, pokud ta nebyla zaplacena v době 30 minut. Tím se uvolní Prodejní Místa pro nové rezervace.
|
||||
Jako vstupní argument může být zadán počet minut, podle kterého nezaplacená rezervaace bude stornovana.
|
||||
"""
|
||||
if minutes <= 0:
|
||||
minutes = 30
|
||||
|
||||
cutoff_time = timezone.now() - timedelta(minutes=minutes)
|
||||
|
||||
orders_qs = Order.objects.select_related("user", "reservation__event").filter(
|
||||
status="pending",
|
||||
created_at__lte=cutoff_time,
|
||||
payed_at__isnull=True
|
||||
)
|
||||
|
||||
count = orders_qs.count()
|
||||
|
||||
for order in orders_qs:
|
||||
order.status = "cancelled"
|
||||
send_email_with_context(
|
||||
recipients=order.user.email,
|
||||
subject="Stornování objednávky",
|
||||
message=(
|
||||
f"Vaše objednávka {order.order_number} má rezervaci prodejního místa "
|
||||
f"na akci {order.reservation.event} a byla stornována po {minutes} minutách nezaplacení."
|
||||
)
|
||||
)
|
||||
order.save()
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"Canceled {count} unpaid orders and released their slots.")
|
||||
|
||||
return "Successfully completed delete_unpayed_reservations_task"
|
||||
|
||||
|
||||
# @shared_task
|
||||
# def delete_old_reservations_task():
|
||||
# """
|
||||
# Smaže rezervace starší než 10 let počítané od začátku příštího roku.
|
||||
# """
|
||||
# now = timezone.now()
|
||||
# next_january_1 = datetime(year=now.year + 1, month=1, day=1, tzinfo=timezone.get_current_timezone())
|
||||
# cutoff_date = next_january_1 - timedelta(days=365 * 10)
|
||||
|
||||
# deleted, _ = Reservation.objects.filter(created__lt=cutoff_date).delete()
|
||||
# print(f"Deleted {deleted} old reservations.")
|
||||
|
||||
# return "Successfully completed delete_old_reservations_task"
|
||||
|
||||
3
backend/booking/tests.py
Normal file
3
backend/booking/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
16
backend/booking/urls.py
Normal file
16
backend/booking/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import EventViewSet, ReservationViewSet, SquareViewSet, MarketSlotViewSet, ReservationAvailabilityCheckView, ReservedDaysView, ReservationCheckViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'events', EventViewSet, basename='event')
|
||||
router.register(r'reservations', ReservationViewSet, basename='reservation')
|
||||
router.register(r'squares', SquareViewSet, basename='square')
|
||||
router.register(r'market-slots', MarketSlotViewSet, basename='market-slot')
|
||||
router.register(r'checks', ReservationCheckViewSet, basename='reservation-checks')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('reservations/check', ReservationAvailabilityCheckView.as_view(), name='event-reservation-check'),
|
||||
path('reserved-days-check/', ReservedDaysView.as_view(), name='reserved-days'),
|
||||
]
|
||||
257
backend/booking/views.py
Normal file
257
backend/booking/views.py
Normal file
@@ -0,0 +1,257 @@
|
||||
from rest_framework import viewsets, filters
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse, OpenApiExample
|
||||
|
||||
from .models import Event, Reservation, MarketSlot, Square, ReservationCheck
|
||||
from .serializers import EventSerializer, ReservationSerializer, MarketSlotSerializer, SquareSerializer, ReservationAvailabilitySerializer, ReservedDaysSerializer, ReservationCheckSerializer
|
||||
from .filters import EventFilter, ReservationFilter
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from account.permissions import *
|
||||
|
||||
import logging
|
||||
|
||||
import logging
|
||||
|
||||
from account.tasks import send_email_verification_task
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["Square"],
|
||||
description=(
|
||||
"Správa náměstí – vytvoření, aktualizace a výpis s doplňkovými informacemi (`quarks`) "
|
||||
"a připojenými eventy. Možno filtrovat podle města, PSČ a velikosti.\n\n"
|
||||
"🔍 **Fulltextové vyhledávání (`?search=`)** prohledává následující pole:\n"
|
||||
"- název náměstí (`name`)\n"
|
||||
"- popis (`description`)\n"
|
||||
"- ulice (`street`)\n"
|
||||
"- město (`city`)\n\n"
|
||||
"**Příklady:** `?search=Ostrava`, `?search=Hlavní třída`"
|
||||
)
|
||||
)
|
||||
class SquareViewSet(viewsets.ModelViewSet):
|
||||
queryset = Square.objects.prefetch_related("square_events").all().order_by("name")
|
||||
serializer_class = SquareSerializer
|
||||
parser_classes = [MultiPartParser, FormParser] # Accept image uploads
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
|
||||
filterset_fields = ["city", "psc", "width", "height"]
|
||||
ordering_fields = ["name", "width", "height"]
|
||||
search_fields = [
|
||||
"name", # název náměstí
|
||||
"description", # popis
|
||||
"street", # ulice
|
||||
"city", # město
|
||||
# "psc" je číslo, obvykle do search_fields nepatří, ale můžeš ho filtrovat přes filterset_fields
|
||||
]
|
||||
|
||||
permission_classes = [RoleAllowed("admin", "squareManager")]
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset()
|
||||
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["Event"],
|
||||
description=(
|
||||
"Základní operace pro správu událostí (Event). Lze filtrovat podle času, města a velikosti náměstí.\n\n"
|
||||
"🔍 **Fulltextové vyhledávání (`?search=`)** prohledává:\n"
|
||||
"- název události (`name`)\n"
|
||||
"- popis (`description`)\n"
|
||||
"- název náměstí (`square.name`)\n"
|
||||
"- město (`square.city`)\n"
|
||||
"- popis náměstí (`square.description`)\n"
|
||||
"- ulice (`square.street`)\n\n"
|
||||
"**Příklady:** `?search=Jarmark`, `?search=Ostrava`, `?search=Masarykovo`"
|
||||
)
|
||||
)
|
||||
class EventViewSet(viewsets.ModelViewSet):
|
||||
queryset = Event.objects.prefetch_related("event_marketSlots", "event_products").all().order_by("start")
|
||||
serializer_class = EventSerializer
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
|
||||
filterset_class = EventFilter
|
||||
ordering_fields = ["start", "end", "price_per_m2"]
|
||||
search_fields = [
|
||||
"name", # název události
|
||||
"description", # popis události
|
||||
"square__name", # název náměstí
|
||||
"square__city", # město
|
||||
"square__description", # popis náměstí (volitelný)
|
||||
"square__street", # ulice
|
||||
]
|
||||
|
||||
permission_classes = [RoleAllowed("admin", "squareManager")]
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["MarketSlot"],
|
||||
description="Vytváření, aktualizace a mazání konkrétních prodejních míst pro události."
|
||||
)
|
||||
class MarketSlotViewSet(viewsets.ModelViewSet):
|
||||
# queryset = MarketSlot.objects.select_related("event").all().order_by("event")
|
||||
queryset = MarketSlot.objects.all().order_by("event")
|
||||
serializer_class = MarketSlotSerializer
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_fields = ["event", "status"]
|
||||
ordering_fields = ["price_per_m2", "x", "y"]
|
||||
|
||||
permission_classes = [RoleAllowed("admin", "squareManager")]
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["Reservation"],
|
||||
description=(
|
||||
"Správa rezervací – vytvoření, úprava a výpis. Filtrování podle eventu, statusu, uživatele atd."
|
||||
)
|
||||
)
|
||||
class ReservationViewSet(viewsets.ModelViewSet):
|
||||
queryset = Reservation.objects.all()
|
||||
serializer_class = ReservationSerializer
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_class = ReservationFilter
|
||||
ordering_fields = ["reserved_from", "reserved_to", "created_at"]
|
||||
search_fields = [
|
||||
"event__name",
|
||||
"event__square__name",
|
||||
"event__square__city",
|
||||
"note",
|
||||
"user__email",
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
]
|
||||
permission_classes = [RoleAllowed("admin", "squareManager", "seller")]
|
||||
|
||||
def get_queryset(self):
|
||||
# queryset = Reservation.objects.select_related("event", "marketSlot", "user").prefetch_related("event_products").order_by("-created_at")
|
||||
queryset = Reservation.objects.all().order_by("-created_at")
|
||||
user = self.request.user
|
||||
if hasattr(user, "role") and user.role == "seller":
|
||||
return queryset.filter(user=user)
|
||||
return queryset
|
||||
|
||||
# Optionally, override create() to add logging or debug info
|
||||
def create(self, request, *args, **kwargs):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Reservation create POST data: {request.data}")
|
||||
try:
|
||||
return super().create(request, *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in ReservationViewSet.create: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def perform_create(self, serializer):
|
||||
self._check_blocked_permission(serializer.validated_data)
|
||||
serializer.save()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
self._check_blocked_permission(serializer.validated_data)
|
||||
serializer.save()
|
||||
|
||||
def _check_blocked_permission(self, data):
|
||||
# FIX: Always get the MarketSlot instance, not just the ID
|
||||
# Accept both "market_slot" (object or int) and "marketSlot" (legacy)
|
||||
slot = data.get("market_slot") or data.get("marketSlot")
|
||||
|
||||
# If slot is a MarketSlot instance, get its id
|
||||
if hasattr(slot, "id"):
|
||||
slot_id = slot.id
|
||||
else:
|
||||
slot_id = slot
|
||||
|
||||
if not isinstance(slot_id, int):
|
||||
raise PermissionDenied("Neplatné ID prodejního místa.")
|
||||
|
||||
try:
|
||||
market_slot = MarketSlot.objects.get(pk=slot_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied("Prodejní místo nebylo nalezeno.")
|
||||
|
||||
if market_slot.status == "blocked":
|
||||
user = self.request.user
|
||||
if getattr(user, "role", None) not in ["admin", "clerk"]:
|
||||
raise PermissionDenied("Toto prodejní místo je zablokované.")
|
||||
|
||||
@extend_schema(
|
||||
tags=["Reservation"],
|
||||
summary="Check reservation availability",
|
||||
request=ReservationAvailabilitySerializer,
|
||||
responses={200: OpenApiExample(
|
||||
'Availability Response',
|
||||
value={"available": True},
|
||||
response_only=True
|
||||
)}
|
||||
)
|
||||
class ReservationAvailabilityCheckView(APIView):
|
||||
def post(self, request):
|
||||
serializer = ReservationAvailabilitySerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
return Response({"available": True}, status=status.HTTP_200_OK)
|
||||
return Response({"available": False}, status=status.HTTP_200_OK)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Reservation"],
|
||||
summary="Get reserved days for a market slot in an event",
|
||||
description=(
|
||||
"Returns a list of reserved days for a given event and market slot. "
|
||||
"Useful for visualizing slot occupancy and preventing double bookings. "
|
||||
"Provide `event_id` and `market_slot_id` as query parameters."
|
||||
),
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="market_slot_id",
|
||||
type=int,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=True,
|
||||
description="ID of the market slot"
|
||||
),
|
||||
],
|
||||
responses={200: ReservedDaysSerializer}
|
||||
)
|
||||
class ReservedDaysView(APIView):
|
||||
"""
|
||||
Returns reserved days for a given event and market slot.
|
||||
GET params: event_id, market_slot_id
|
||||
"""
|
||||
def get(self, request, *args, **kwargs):
|
||||
market_slot_id = request.query_params.get("market_slot_id")
|
||||
if not market_slot_id:
|
||||
return Response(
|
||||
{"detail": "market_slot_id is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
serializer = ReservedDaysSerializer({
|
||||
"market_slot_id": market_slot_id
|
||||
})
|
||||
logger.debug(f"ReservedDaysView GET market_slot_id={market_slot_id}")
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["Reservation Checks"],
|
||||
description="Správa kontrol rezervací – vytváření záznamů o kontrole a jejich výpis."
|
||||
)
|
||||
class ReservationCheckViewSet(viewsets.ModelViewSet):
|
||||
queryset = ReservationCheck.objects.select_related("reservation", "checker").all().order_by("-checked_at")
|
||||
serializer_class = ReservationCheckSerializer
|
||||
permission_classes = [OnlyRolesAllowed("admin", "checker")] # Only checkers & admins can use it
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if hasattr(user, "role") and user.role == "checker":
|
||||
return self.queryset.filter(checker=user) # Checkers only see their own logs
|
||||
return self.queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
0
backend/commerce/__init__.py
Normal file
0
backend/commerce/__init__.py
Normal file
30
backend/commerce/admin.py
Normal file
30
backend/commerce/admin.py
Normal 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
6
backend/commerce/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommerceConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'commerce'
|
||||
12
backend/commerce/filters.py
Normal file
12
backend/commerce/filters.py
Normal 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"]
|
||||
37
backend/commerce/migrations/0001_initial.py
Normal file
37
backend/commerce/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/commerce/migrations/__init__.py
Normal file
0
backend/commerce/migrations/__init__.py
Normal file
113
backend/commerce/models.py
Normal file
113
backend/commerce/models.py
Normal 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)
|
||||
178
backend/commerce/serializers.py
Normal file
178
backend/commerce/serializers.py
Normal 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)
|
||||
3
backend/commerce/tests.py
Normal file
3
backend/commerce/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
11
backend/commerce/urls.py
Normal file
11
backend/commerce/urls.py
Normal 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
74
backend/commerce/views.py
Normal 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)
|
||||
0
backend/configuration/__init__.py
Normal file
0
backend/configuration/__init__.py
Normal file
22
backend/configuration/admin.py
Normal file
22
backend/configuration/admin.py
Normal 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)
|
||||
6
backend/configuration/apps.py
Normal file
6
backend/configuration/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ConfigurationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'configuration'
|
||||
28
backend/configuration/migrations/0001_initial.py
Normal file
28
backend/configuration/migrations/0001_initial.py
Normal 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.')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
0
backend/configuration/migrations/__init__.py
Normal file
0
backend/configuration/migrations/__init__.py
Normal file
88
backend/configuration/models.py
Normal file
88
backend/configuration/models.py
Normal 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)
|
||||
159
backend/configuration/serializers.py
Normal file
159
backend/configuration/serializers.py
Normal 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}
|
||||
3
backend/configuration/tests.py
Normal file
3
backend/configuration/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
12
backend/configuration/urls.py
Normal file
12
backend/configuration/urls.py
Normal 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'),
|
||||
]
|
||||
200
backend/configuration/views.py
Normal file
200
backend/configuration/views.py
Normal 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
28
backend/dockerfile
Normal 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 . .
|
||||
36
backend/globalstaticfiles/js/index.js
Normal file
36
backend/globalstaticfiles/js/index.js
Normal 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
22
backend/manage.py
Normal 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
309
backend/populate_db.py
Normal 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.")
|
||||
0
backend/product/__init__.py
Normal file
0
backend/product/__init__.py
Normal file
60
backend/product/admin.py
Normal file
60
backend/product/admin.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from django.contrib import admin
|
||||
from trznice.admin import custom_admin_site
|
||||
from .models import Product, EventProduct
|
||||
|
||||
|
||||
class ProductAdmin(admin.ModelAdmin):
|
||||
base_list_display = ("id", "name", "code")
|
||||
admin_extra_display = ("is_deleted",)
|
||||
list_filter = ("name", "is_deleted")
|
||||
search_fields = ("name", "code")
|
||||
ordering = ("name",)
|
||||
|
||||
base_fields = ['name', 'code']
|
||||
|
||||
|
||||
def get_fields(self, request, obj=None):
|
||||
fields = self.base_fields.copy()
|
||||
if request.user.role == "admin":
|
||||
fields += ['is_deleted', 'deleted_at']
|
||||
return fields
|
||||
|
||||
def get_queryset(self, request):
|
||||
# Use the all_objects manager to show even soft-deleted entries
|
||||
if request.user.role == "admin":
|
||||
qs = self.model.all_objects.all()
|
||||
else:
|
||||
qs = self.model.objects.all()
|
||||
return qs
|
||||
|
||||
def get_list_display(self, request):
|
||||
if request.user.role == "admin":
|
||||
return self.base_list_display + self.admin_extra_display
|
||||
return self.base_list_display
|
||||
|
||||
custom_admin_site.register(Product, ProductAdmin)
|
||||
|
||||
|
||||
class EventProductAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "event", "product", "start_selling_date", "end_selling_date", "is_deleted")
|
||||
list_filter = ("event", "product", "start_selling_date", "end_selling_date", "is_deleted")
|
||||
search_fields = ("product__name", "event__name")
|
||||
ordering = ("-start_selling_date",)
|
||||
|
||||
base_fields = ['product', 'event', 'start_selling_date', 'end_selling_date']
|
||||
|
||||
def get_fields(self, request, obj=None):
|
||||
fields = self.base_fields.copy()
|
||||
if request.user.role == "admin":
|
||||
fields += ['is_deleted', 'deleted_at']
|
||||
return fields
|
||||
|
||||
def get_queryset(self, request):
|
||||
# Use the all_objects manager to show even soft-deleted entries
|
||||
if request.user.role == "admin":
|
||||
qs = self.model.all_objects.all()
|
||||
else:
|
||||
qs = self.model.objects.all()
|
||||
return qs
|
||||
|
||||
custom_admin_site.register(EventProduct, EventProductAdmin)
|
||||
6
backend/product/apps.py
Normal file
6
backend/product/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProductConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'product'
|
||||
44
backend/product/migrations/0001_initial.py
Normal file
44
backend/product/migrations/0001_initial.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.2.4 on 2025-08-07 15:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('booking', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Product',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('name', models.CharField(max_length=255, verbose_name='Název produktu')),
|
||||
('code', models.PositiveIntegerField(unique=True, verbose_name='Unitatní kód produktu')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventProduct',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('start_selling_date', models.DateTimeField()),
|
||||
('end_selling_date', models.DateTimeField()),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_products', to='booking.event')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_products', to='product.product')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
18
backend/product/migrations/0002_alter_product_code.py
Normal file
18
backend/product/migrations/0002_alter_product_code.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-25 15:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('product', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='product',
|
||||
name='code',
|
||||
field=models.PositiveIntegerField(blank=True, null=True, unique=True, verbose_name='Unitatní kód produktu'),
|
||||
),
|
||||
]
|
||||
0
backend/product/migrations/__init__.py
Normal file
0
backend/product/migrations/__init__.py
Normal file
77
backend/product/models.py
Normal file
77
backend/product/models.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from trznice.models import SoftDeleteModel
|
||||
from booking.models import Event
|
||||
from trznice.utils import truncate_to_minutes
|
||||
|
||||
class Product(SoftDeleteModel):
|
||||
name = models.CharField(max_length=255, verbose_name="Název produktu")
|
||||
code = models.PositiveIntegerField(unique=True, verbose_name="Unitatní kód produktu", null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} : {self.code}"
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
|
||||
self.event_products.all().update(is_deleted=True, deleted_at=timezone.now())
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class EventProduct(SoftDeleteModel):
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="event_products")
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="event_products")
|
||||
start_selling_date = models.DateTimeField()
|
||||
end_selling_date = models.DateTimeField()
|
||||
|
||||
def clean(self):
|
||||
if not (self.start_selling_date and self.end_selling_date):
|
||||
raise ValidationError("Datum začátku a konce musí být neprázné.")
|
||||
|
||||
# Vynecháme sekunky, mikrosecundy atd.
|
||||
self.start_selling_date = truncate_to_minutes(self.start_selling_date)
|
||||
self.end_selling_date = truncate_to_minutes(self.end_selling_date)
|
||||
|
||||
if not self.product_id or not self.event_id:
|
||||
raise ValidationError("Zadejte Akci a Produkt.")
|
||||
|
||||
# Safely get product and event objects for error messages and validation
|
||||
try:
|
||||
product_obj = Product.objects.get(pk=self.product_id)
|
||||
except Product.DoesNotExist:
|
||||
raise ValidationError("Neplatné ID Zboží (Produktu).")
|
||||
|
||||
try:
|
||||
event_obj = Event.objects.get(pk=self.event_id)
|
||||
except Event.DoesNotExist:
|
||||
raise ValidationError("Neplatné ID Akce (Eventu).")
|
||||
|
||||
# Overlapping sales window check
|
||||
overlapping = EventProduct.objects.exclude(id=self.id).filter(
|
||||
event_id=self.event_id,
|
||||
product_id=self.product_id,
|
||||
start_selling_date__lt=self.end_selling_date,
|
||||
end_selling_date__gt=self.start_selling_date,
|
||||
)
|
||||
if overlapping.exists():
|
||||
raise ValidationError("Toto zboží už se prodává v tomto období na této akci.")
|
||||
|
||||
# Ensure sale window is inside event bounds
|
||||
# Event has DateFields (date), while these are DateTimeFields -> compare by date component
|
||||
start_date = self.start_selling_date.date()
|
||||
end_date = self.end_selling_date.date()
|
||||
if start_date < event_obj.start or end_date > event_obj.end:
|
||||
raise ValidationError("Prodej zboží musí být v rámci trvání akce.")
|
||||
|
||||
# Ensure product+event pair is unique
|
||||
if EventProduct.objects.exclude(pk=self.pk).filter(product_id=self.product_id, event_id=self.event_id).exists():
|
||||
raise ValidationError(f"V rámci akce {event_obj} už je {product_obj} zaregistrováno.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean() # This includes clean_fields() + clean() + validate_unique()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product} at {self.event}"
|
||||
155
backend/product/serializers.py
Normal file
155
backend/product/serializers.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueValidator
|
||||
|
||||
from trznice.utils import RoundedDateTimeField
|
||||
from .models import Product, EventProduct
|
||||
from booking.models import Event
|
||||
# from booking.serializers import EventSerializer
|
||||
|
||||
class ProductSerializer(serializers.ModelSerializer):
|
||||
code = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
help_text="Unikátní číselný kód produktu (volitelné)",
|
||||
)
|
||||
events = serializers.SerializerMethodField(help_text="Seznam akcí (eventů), ve kterých se tento produkt prodává.")
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = ["id", "name", "code", "events"]
|
||||
read_only_fields = ["id"]
|
||||
extra_kwargs = {
|
||||
"name": {
|
||||
"help_text": "Název zboží (max. 255 znaků).",
|
||||
"required": True,
|
||||
},
|
||||
"code": {
|
||||
"help_text": "Unikátní kód zboží (např. 'FOOD-001'). Volitelné; pokud vyplněno, musí být jedinečný.",
|
||||
"required": False,
|
||||
"allow_null": True,
|
||||
"allow_blank": True,
|
||||
},
|
||||
}
|
||||
|
||||
def validate_name(self, value):
|
||||
value = value.strip()
|
||||
|
||||
if not value:
|
||||
raise serializers.ValidationError("Název Zboží (Produktu) nemůže být prázdný.")
|
||||
|
||||
if len(value) > 255:
|
||||
raise serializers.ValidationError("Název nesmí být delší než 255 znaků.")
|
||||
return value
|
||||
|
||||
def validate_code(self, value):
|
||||
# Accept empty/null
|
||||
if value in (None, ""):
|
||||
return None
|
||||
# Uniqueness manual check (since we removed built-in validator to permit null/blank)
|
||||
qs = Product.objects.filter(code=value)
|
||||
if self.instance:
|
||||
qs = qs.exclude(pk=self.instance.pk)
|
||||
if qs.exists():
|
||||
raise serializers.ValidationError("Produkt s tímto kódem už existuje.")
|
||||
return value
|
||||
|
||||
def get_events(self, obj):
|
||||
# Expect prefetch: event_products__event
|
||||
events = []
|
||||
# Access prefetched related if available to avoid N+1
|
||||
event_products = getattr(obj, 'event_products_all', None)
|
||||
if event_products is None:
|
||||
# Fallback query (should be avoided if queryset is optimized)
|
||||
event_products = obj.event_products.select_related('event').all()
|
||||
for ep in event_products:
|
||||
if ep.event_id and hasattr(ep, 'event'):
|
||||
events.append({"id": ep.event_id, "name": ep.event.name})
|
||||
return events
|
||||
|
||||
|
||||
class EventProductSerializer(serializers.ModelSerializer):
|
||||
product = ProductSerializer(read_only=True)
|
||||
product_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Product.objects.all(), write_only=True
|
||||
)
|
||||
|
||||
start_selling_date = RoundedDateTimeField()
|
||||
end_selling_date = RoundedDateTimeField()
|
||||
|
||||
class Meta:
|
||||
model = EventProduct
|
||||
fields = [
|
||||
'id',
|
||||
'product', # nested read-only
|
||||
'product_id', # required in POST/PUT
|
||||
'event',
|
||||
'start_selling_date',
|
||||
'end_selling_date',
|
||||
]
|
||||
|
||||
read_only_fields = ["id", "product"]
|
||||
extra_kwargs = {
|
||||
"product": {
|
||||
"help_text": "Detail zboží (jen pro čtení).",
|
||||
"required": False,
|
||||
"read_only": True,
|
||||
},
|
||||
"product_id": {
|
||||
"help_text": "ID zboží, které je povoleno prodávat na akci.",
|
||||
"required": True,
|
||||
"write_only": True,
|
||||
},
|
||||
"event": {
|
||||
"help_text": "ID akce (Event), pro kterou je zboží povoleno.",
|
||||
"required": True,
|
||||
},
|
||||
"start_selling_date": {
|
||||
"help_text": "Začátek prodeje v rámci akce (musí spadat do [event.start, event.end]).",
|
||||
"required": True,
|
||||
},
|
||||
"end_selling_date": {
|
||||
"help_text": "Konec prodeje v rámci akce (po start_selling_date, také v rámci [event.start, event.end]).",
|
||||
"required": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["product"] = validated_data.pop("product_id")
|
||||
return super().create(validated_data)
|
||||
|
||||
def validate(self, data):
|
||||
product = data.get("product_id")
|
||||
event = data.get("event")
|
||||
start = data.get("start_selling_date")
|
||||
end = data.get("end_selling_date")
|
||||
|
||||
if start >= end:
|
||||
raise serializers.ValidationError("Datum začátku prodeje musí být dříve než jeho konec.")
|
||||
|
||||
if event and (start < event.start or end > event.end):
|
||||
raise serializers.ValidationError("Prodej zboží musí být v rámci trvání akce.")
|
||||
|
||||
# When updating, exclude self instance
|
||||
instance_id = self.instance.id if self.instance else None
|
||||
|
||||
# Check for overlapping EventProducts for the same product/event
|
||||
overlapping = EventProduct.objects.exclude(id=instance_id).filter(
|
||||
event=event,
|
||||
product_id=product,
|
||||
start_selling_date__lt=end,
|
||||
end_selling_date__gt=start,
|
||||
)
|
||||
if overlapping.exists():
|
||||
raise serializers.ValidationError("Toto zboží už se prodává v tomto období na této akci.")
|
||||
|
||||
# # Check for duplicate product-event pair
|
||||
# duplicate = EventProduct.objects.exclude(id=instance_id).filter(
|
||||
# event=event,
|
||||
# product_id=product,
|
||||
# )
|
||||
# if duplicate.exists():
|
||||
# raise serializers.ValidationError(f"V rámci akce {event} už je {product} zaregistrováno.")
|
||||
|
||||
return data
|
||||
66
backend/product/tests.py
Normal file
66
backend/product/tests.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from booking.models import Square, Event
|
||||
from .models import Product, EventProduct
|
||||
|
||||
|
||||
class EventProductDateComparisonTests(TestCase):
|
||||
def setUp(self):
|
||||
self.square = Square.objects.create(
|
||||
name="Test Square",
|
||||
street="Test Street",
|
||||
city="Test City",
|
||||
psc=12345,
|
||||
width=10,
|
||||
height=10,
|
||||
grid_rows=10,
|
||||
grid_cols=10,
|
||||
cellsize=10,
|
||||
)
|
||||
today = timezone.now().date()
|
||||
self.event = Event.objects.create(
|
||||
name="Test Event",
|
||||
square=self.square,
|
||||
start=today,
|
||||
end=today + timedelta(days=2),
|
||||
price_per_m2=10,
|
||||
)
|
||||
self.product = Product.objects.create(name="Prod 1")
|
||||
|
||||
def test_event_product_inside_event_range_passes(self):
|
||||
now = timezone.now()
|
||||
ep = EventProduct(
|
||||
product=self.product,
|
||||
event=self.event,
|
||||
start_selling_date=now,
|
||||
end_selling_date=now + timedelta(hours=2),
|
||||
)
|
||||
# Should not raise (specifically regression for datetime.date vs datetime comparison)
|
||||
ep.full_clean() # Will call clean()
|
||||
ep.save()
|
||||
self.assertIsNotNone(ep.id)
|
||||
|
||||
def test_event_product_outside_event_range_fails(self):
|
||||
now = timezone.now()
|
||||
ep = EventProduct(
|
||||
product=self.product,
|
||||
event=self.event,
|
||||
start_selling_date=now - timedelta(days=1), # before event start
|
||||
end_selling_date=now,
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
ep.full_clean()
|
||||
|
||||
def test_event_product_end_after_event_range_fails(self):
|
||||
now = timezone.now()
|
||||
ep = EventProduct(
|
||||
product=self.product,
|
||||
event=self.event,
|
||||
start_selling_date=now,
|
||||
end_selling_date=now + timedelta(days=5), # after event end
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
ep.full_clean()
|
||||
12
backend/product/urls.py
Normal file
12
backend/product/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ProductViewSet, EventProductViewSet
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'products', ProductViewSet, basename='products')
|
||||
router.register(r'event-products', EventProductViewSet, basename='event-products')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
50
backend/product/views.py
Normal file
50
backend/product/views.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from rest_framework import viewsets
|
||||
from django.db import models
|
||||
from .models import Product, EventProduct
|
||||
from .serializers import ProductSerializer, EventProductSerializer
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from account.permissions import RoleAllowed
|
||||
|
||||
from rest_framework import viewsets, filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
@extend_schema(
|
||||
tags=["Product"],
|
||||
description="Seznam produktů, jejich vytváření a úprava. Produkty lze filtrovat a třídit dle názvu nebo kódu."
|
||||
)
|
||||
class ProductViewSet(viewsets.ModelViewSet):
|
||||
queryset = (
|
||||
Product.objects.all()
|
||||
.prefetch_related(
|
||||
models.Prefetch(
|
||||
'event_products',
|
||||
queryset=EventProduct.objects.select_related('event').all(),
|
||||
to_attr='event_products_all'
|
||||
)
|
||||
)
|
||||
.order_by("name")
|
||||
)
|
||||
serializer_class = ProductSerializer
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
|
||||
filterset_fields = ["code"]
|
||||
ordering_fields = ["name", "code"]
|
||||
search_fields = ["name", "code", "event_products__event__name"]
|
||||
|
||||
permission_classes = [RoleAllowed("admin", "squareManager")]
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["EventProduct"],
|
||||
description="Propojení produktů s událostmi. Zde se nastavují data prodeje konkrétního produktu na konkrétní události."
|
||||
)
|
||||
class EventProductViewSet(viewsets.ModelViewSet):
|
||||
# queryset = EventProduct.objects.select_related("product", "event").all().order_by("start_selling_date")
|
||||
queryset = EventProduct.objects.select_related("product").order_by("start_selling_date")
|
||||
serializer_class = EventProductSerializer
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
|
||||
filterset_fields = ["product", "event"]
|
||||
ordering_fields = ["start_selling_date", "end_selling_date"]
|
||||
search_fields = ["product__name", "event__name"]
|
||||
|
||||
permission_classes = [RoleAllowed("admin", "squareManager")]
|
||||
89
backend/requirements.txt
Normal file
89
backend/requirements.txt
Normal 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
|
||||
0
backend/servicedesk/__init__.py
Normal file
0
backend/servicedesk/__init__.py
Normal file
30
backend/servicedesk/admin.py
Normal file
30
backend/servicedesk/admin.py
Normal 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)
|
||||
6
backend/servicedesk/apps.py
Normal file
6
backend/servicedesk/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ServicedeskConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'servicedesk'
|
||||
11
backend/servicedesk/filters.py
Normal file
11
backend/servicedesk/filters.py
Normal 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"]
|
||||
34
backend/servicedesk/migrations/0001_initial.py
Normal file
34
backend/servicedesk/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/servicedesk/migrations/__init__.py
Normal file
0
backend/servicedesk/migrations/__init__.py
Normal file
32
backend/servicedesk/models.py
Normal file
32
backend/servicedesk/models.py
Normal 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()})"
|
||||
|
||||
47
backend/servicedesk/serializers.py
Normal file
47
backend/servicedesk/serializers.py
Normal 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
|
||||
3
backend/servicedesk/tests.py
Normal file
3
backend/servicedesk/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
10
backend/servicedesk/urls.py
Normal file
10
backend/servicedesk/urls.py
Normal 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)),
|
||||
]
|
||||
84
backend/servicedesk/views.py
Normal file
84
backend/servicedesk/views.py
Normal 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)
|
||||
15
backend/templates/emails/create_password.html
Normal file
15
backend/templates/emails/create_password.html
Normal 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>
|
||||
8
backend/templates/emails/create_password.txt
Normal file
8
backend/templates/emails/create_password.txt
Normal 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
|
||||
74
backend/templates/html/index.html
Normal file
74
backend/templates/html/index.html
Normal 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>
|
||||
0
backend/templates/login.html
Normal file
0
backend/templates/login.html
Normal file
3
backend/trznice/__init__.py
Normal file
3
backend/trznice/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
48
backend/trznice/admin.py
Normal file
48
backend/trznice/admin.py
Normal 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
16
backend/trznice/asgi.py
Normal 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
18
backend/trznice/celery.py
Normal 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
61
backend/trznice/models.py
Normal 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
951
backend/trznice/settings.py
Normal 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
59
backend/trznice/urls.py
Normal 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
12
backend/trznice/utils.py
Normal 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
38
backend/trznice/views.py
Normal 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
Reference in New Issue
Block a user