init
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
187
.gitignore
vendored
Normal file
187
.gitignore
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
*/media/
|
||||
*/collectedstaticfiles/
|
||||
|
||||
celerybeat-schedule-shm
|
||||
celerybeat-schedule-wal
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
*/__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Cursor
|
||||
# Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
114
.vscode/settings.json
vendored
Normal file
114
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"editor.tokenColorCustomizations": {
|
||||
"textMateRules": [
|
||||
{
|
||||
"scope": [
|
||||
"comment",
|
||||
"comment.line",
|
||||
"comment.block",
|
||||
"punctuation.definition.comment"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#5fca5f" // světlá zelená
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"files.autoSave": "afterDelay",
|
||||
"vscode-edge-devtools.webhintInstallNotification": true,
|
||||
"explorer.confirmDelete": false,
|
||||
"git.suggestSmartCommit": false,
|
||||
"git.confirmSync": false,
|
||||
"git.autofetch": true,
|
||||
"editor.minimap.enabled": false,
|
||||
"explorer.confirmPasteNative": false,
|
||||
"explorer.confirmDragAndDrop": false,
|
||||
"[php]": {
|
||||
"editor.defaultFormatter": "bmewburn.vscode-intelephense-client"
|
||||
},
|
||||
"redhat.telemetry.enabled": false,
|
||||
"files.exclude": {
|
||||
"**/.git": false
|
||||
},
|
||||
|
||||
/*CUSTOM django-html SETTINGS*/
|
||||
"emmet.includeLanguages": {
|
||||
"django-html": "html"
|
||||
},
|
||||
"files.associations": {
|
||||
"**/*.html": "html",
|
||||
"**/templates/**/*.html": "django-html",
|
||||
"**/templates/**/*": "django-txt"
|
||||
},
|
||||
"[django-html]": {
|
||||
"editor.defaultFormatter": "batisteo.vscode-django",
|
||||
"editor.insertSpaces": false,
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"terminal.integrated.enableMultiLinePasteWarning": false,
|
||||
"workbench.colorCustomizations": {},
|
||||
"html.format.contentUnformatted": "",
|
||||
"python.createEnvironment.trigger": "off",
|
||||
"phpserver.phpConfigPath": "C:\\xampp\\php\\php.ini",
|
||||
"phpserver.phpPath": "C:\\xampp\\php\\php.exe",
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.ts": "${capture}.js",
|
||||
"*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts",
|
||||
"*.jsx": "${capture}.js",
|
||||
"*.tsx": "${capture}.ts",
|
||||
"tsconfig.json": "tsconfig.*.json",
|
||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock",
|
||||
"*.sqlite": "${capture}.${extname}-*",
|
||||
"*.db": "${capture}.${extname}-*",
|
||||
"*.sqlite3": "${capture}.${extname}-*",
|
||||
"*.db3": "${capture}.${extname}-*",
|
||||
"*.sdb": "${capture}.${extname}-*",
|
||||
"*.s3db": "${capture}.${extname}-*"
|
||||
},
|
||||
"workbench.colorTheme": "Atom One Dark",
|
||||
"workbench.iconTheme": "vscode-icons",
|
||||
"github.copilot.nextEditSuggestions.enabled": true,
|
||||
|
||||
"workbench.tree.enableStickyScroll": false,
|
||||
"workbench.tree.indent": 15,
|
||||
"workbench.tree.renderIndentGuides": "none",
|
||||
"explorer.compactFolders": false,
|
||||
"material-icon-theme.hidesExplorerArrows": true,
|
||||
"material-icon-theme.folders.customClones": [
|
||||
{
|
||||
"name": "features-folder",
|
||||
"base": "connection",
|
||||
"folderNames": ["features"],
|
||||
"color": "blue-400",
|
||||
"lightColor": "blue-600"
|
||||
},
|
||||
{
|
||||
"name": "api-folder",
|
||||
"base": "api",
|
||||
"color": "red-400", // new color for Dark theme
|
||||
"lightColor": "red-600", // optional: color for Light theme
|
||||
"folderNames": ["api"],
|
||||
},
|
||||
{
|
||||
"name": "forms-folder",
|
||||
"base": "content",
|
||||
"color": "red-800", // new color for Dark theme
|
||||
"lightColor": "yellow-600", // optional: color for Light theme
|
||||
"folderNames": ["forms", "form", "forms-templates"],
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
"draw.folder.structure.exclude": ["**/node_modules", "**/dist", "**/.git", "**/.vscode"],
|
||||
"draw.folder.structure.style": "EmojiDashes",
|
||||
"draw.folder.structure.allowRecursion": true,
|
||||
"draw.folder.structure.respectGitignore": false,
|
||||
"github.copilot.enable": {
|
||||
"*": true,
|
||||
"plaintext": false,
|
||||
"markdown": true,
|
||||
"scminput": false
|
||||
},
|
||||
"terminal.integrated.stickyScroll.enabled": false
|
||||
}
|
||||
106
README.md
Normal file
106
README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# e-rezervace-jih-vitkovice
|
||||
|
||||
## venv
|
||||
- windows
|
||||
|
||||
```
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
|
||||
/
|
||||
Set-ExecutionPolicy RemoteSigned
|
||||
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate
|
||||
|
||||
#start server
|
||||
daphne -b localhost -p 8000 trznice.asgi:application
|
||||
```
|
||||
|
||||
# if run locally in backend folder:
|
||||
## dont forget to run redis:
|
||||
```
|
||||
docker run redis
|
||||
```
|
||||
|
||||
## 1. create scheduled tasks in db
|
||||
```
|
||||
python manage.py seed_celery_beat
|
||||
```
|
||||
## 2. run CELERY Terminal 1
|
||||
```
|
||||
celery -A trznice worker --pool=solo --loglevel=info
|
||||
```
|
||||
|
||||
## 3. run CELERY BEAT Terminal 2
|
||||
```
|
||||
celery -A trznice beat --loglevel=info
|
||||
```
|
||||
|
||||
--------------------------------------------------------------------
|
||||
|
||||
# django command that will use barebones settings.py for basic work
|
||||
```python manage.py runserver --settings=trznice.base_settings```
|
||||
|
||||
|
||||
# logovaní do dockeru (klasický print nefunguje kvůli bezpečnosti)
|
||||
```
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug("Tvoje hláška")
|
||||
```
|
||||
|
||||
# Django Management Commands
|
||||
```
|
||||
| Command | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| `startproject <name>` | Create a new Django project | `python manage.py startproject myproject` |
|
||||
| `startapp <name>` | Create a new Django app | `python manage.py startapp myapp` |
|
||||
| `runserver [port]` | Run the development server (default: `8000`) | `python manage.py runserver 8080` |
|
||||
| `migrate` | Apply database migrations | `python manage.py migrate` |
|
||||
| `makemigrations [app]` | Create migration files for an app | `python manage.py makemigrations myapp` |
|
||||
| `createsuperuser` | Create an admin superuser | `python manage.py createsuperuser` |
|
||||
| `check` | Check for any project errors | `python manage.py check` |
|
||||
| `shell` | Open the Django shell | `python manage.py shell` |
|
||||
| `dbshell` | Open the database shell | `python manage.py dbshell` |
|
||||
| `collectstatic` | Collect static files for deployment | `python manage.py collectstatic` |
|
||||
| `test [app]` | Run tests for an app | `python manage.py test myapp` |
|
||||
| `sqlmigrate <app> <migration>` | Show the SQL of a migration | `python manage.py sqlmigrate myapp 0001_initial` |
|
||||
| `flush` | Reset the database (removes all data) | `python manage.py flush` |
|
||||
| `dumpdata [app]` | Export database data as JSON | `python manage.py dumpdata myapp > data.json` |
|
||||
| `loaddata <file>` | Load data from a JSON file | `python manage.py loaddata data.json` |
|
||||
| `help` | Show available commands | `python manage.py help` |
|
||||
```
|
||||
Feel free to use or modify this table for your project!
|
||||
|
||||
|
||||
|
||||
# Docker Compose
|
||||
spuštění dockeru pro lokální hosting, s instantníma změnami během editace ve vscodu.
|
||||
```
|
||||
docker compose up --build
|
||||
```
|
||||
## Vytvoření superuživatele na serveru v docker compose konfiguraci
|
||||
```
|
||||
sudo docker exec -it e-rezervace-jih-vitkovice-backend python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## dns reset windows
|
||||
```
|
||||
ipconfig /flushdns
|
||||
```
|
||||
|
||||
# NPM
|
||||
|
||||
```
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
|
||||
|
||||
npm i react-router-dom
|
||||
npm audit fix
|
||||
npm run dev
|
||||
```
|
||||
```
|
||||
ipconfig /flushdns
|
||||
```
|
||||
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
|
||||
|
||||
"""
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user