From 84b34c9615fb0fe645893af918b56a00536847fe Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Thu, 2 Oct 2025 00:54:34 +0200 Subject: [PATCH] init --- .gitattributes | 2 + .gitignore | 187 + .vscode/settings.json | 114 + README.md | 106 + backend/.dockerignore | 20 + backend/.gitignore | Bin 0 -> 48 bytes backend/account/__init__.py | 0 backend/account/admin.py | 105 + backend/account/apps.py | 6 + backend/account/email.py | 108 + backend/account/filters.py | 30 + backend/account/forms.py | 16 + .../commands/createsuperuser_active.py | 40 + backend/account/migrations/0001_initial.py | 59 + backend/account/migrations/__init__.py | 0 backend/account/models.py | 199 + backend/account/permissions.py | 72 + backend/account/serializers.py | 224 + backend/account/tasks.py | 130 + backend/account/tests.py | 3 + backend/account/tokens.py | 33 + backend/account/urls.py | 28 + backend/account/utils.py | 62 + backend/account/views.py | 409 + backend/booking/__init__.py | 1 + backend/booking/admin.py | 135 + backend/booking/apps.py | 9 + backend/booking/filters.py | 23 + backend/booking/forms.py | 21 + backend/booking/management/__init__.py | 0 .../booking/management/commands/__init__.py | 0 .../management/commands/seed_celery_beat.py | 55 + backend/booking/migrations/0001_initial.py | 111 + backend/booking/migrations/0002_initial.py | 54 + backend/booking/migrations/__init__.py | 0 backend/booking/models.py | 395 + backend/booking/serializers.py | 602 + backend/booking/signals.py | 9 + backend/booking/tasks.py | 116 + backend/booking/tests.py | 3 + backend/booking/urls.py | 16 + backend/booking/views.py | 257 + backend/commerce/__init__.py | 0 backend/commerce/admin.py | 30 + backend/commerce/apps.py | 6 + backend/commerce/filters.py | 12 + backend/commerce/migrations/0001_initial.py | 37 + backend/commerce/migrations/__init__.py | 0 backend/commerce/models.py | 113 + backend/commerce/serializers.py | 178 + backend/commerce/tests.py | 3 + backend/commerce/urls.py | 11 + backend/commerce/views.py | 74 + backend/configuration/__init__.py | 0 backend/configuration/admin.py | 22 + backend/configuration/apps.py | 6 + .../configuration/migrations/0001_initial.py | 28 + ..._image_appconfig_contact_email_and_more.py | 43 + backend/configuration/migrations/__init__.py | 0 backend/configuration/models.py | 88 + backend/configuration/serializers.py | 159 + backend/configuration/tests.py | 3 + backend/configuration/urls.py | 12 + backend/configuration/views.py | 200 + backend/dockerfile | 28 + backend/globalstaticfiles/js/index.js | 36 + backend/manage.py | 22 + backend/populate_db.py | 309 + backend/product/__init__.py | 0 backend/product/admin.py | 60 + backend/product/apps.py | 6 + backend/product/migrations/0001_initial.py | 44 + .../migrations/0002_alter_product_code.py | 18 + backend/product/migrations/__init__.py | 0 backend/product/models.py | 77 + backend/product/serializers.py | 155 + backend/product/tests.py | 66 + backend/product/urls.py | 12 + backend/product/views.py | 50 + backend/requirements.txt | 89 + backend/servicedesk/__init__.py | 0 backend/servicedesk/admin.py | 30 + backend/servicedesk/apps.py | 6 + backend/servicedesk/filters.py | 11 + .../servicedesk/migrations/0001_initial.py | 34 + backend/servicedesk/migrations/__init__.py | 0 backend/servicedesk/models.py | 32 + backend/servicedesk/serializers.py | 47 + backend/servicedesk/tests.py | 3 + backend/servicedesk/urls.py | 10 + backend/servicedesk/views.py | 84 + backend/templates/emails/create_password.html | 15 + backend/templates/emails/create_password.txt | 8 + backend/templates/html/index.html | 74 + backend/templates/login.html | 0 backend/trznice/__init__.py | 3 + backend/trznice/admin.py | 48 + backend/trznice/asgi.py | 16 + backend/trznice/celery.py | 18 + backend/trznice/models.py | 61 + backend/trznice/settings.py | 951 + backend/trznice/urls.py | 59 + backend/trznice/utils.py | 12 + backend/trznice/views.py | 38 + backend/trznice/wsgi.py | 16 + docker-compose.yml | 117 + frontend/.dockerignore | 3 + frontend/.gitignore | 24 + frontend/Dockerfile.prod | 16 + frontend/README.md | 0 frontend/eslint.config.js | 29 + frontend/index.html | 13 + frontend/package-lock.json | 4298 ++++ frontend/package.json | 51 + frontend/postcss.config.cjs | 14 + frontend/public/img/bg.png | Bin 0 -> 389241 bytes frontend/public/img/logo.png | Bin 0 -> 19955 bytes frontend/public/img/namest-1.png | Bin 0 -> 275164 bytes frontend/public/img/register-bg.jpg | Bin 0 -> 18255 bytes frontend/public/vite.svg | 1 + frontend/src/App.css | 19 + frontend/src/App.jsx | 155 + frontend/src/api/auth.js | 254 + frontend/src/api/get_chocies.js | 41 + frontend/src/api/model/Settings.js | 89 + frontend/src/api/model/bin.js | 78 + frontend/src/api/model/event-product.js | 79 + frontend/src/api/model/event.js | 76 + frontend/src/api/model/market_slot.js | 74 + frontend/src/api/model/order.js | 82 + frontend/src/api/model/product.js | 98 + frontend/src/api/model/reservation.js | 96 + frontend/src/api/model/square.js | 75 + frontend/src/api/model/ticket.js | 85 + frontend/src/api/model/user.js | 73 + frontend/src/api/tutorialy/user.js | 34 + frontend/src/api/tutorialy/volání api.js | 46 + frontend/src/assets/json/data.json | 362 + frontend/src/assets/react.svg | 1 + frontend/src/components/ConfirmEmailBar.jsx | 114 + frontend/src/components/DynamicGrid.jsx | 525 + frontend/src/components/DynamicMap.jsx | 155 + frontend/src/components/LoginCard.jsx | 175 + frontend/src/components/NavBar.jsx | 71 + frontend/src/components/RegisterCard.jsx | 480 + frontend/src/components/ReportForm.jsx | 0 frontend/src/components/Settings.jsx | 209 + frontend/src/components/Sidebar.jsx | 72 + frontend/src/components/Table.jsx | 197 + frontend/src/components/User-Settings.jsx | 178 + frontend/src/components/forms/ticket.jsx | 135 + .../reservation/ReservationWizard.jsx | 345 + .../reservation/Step1SelectSquare.jsx | 88 + .../reservation/Step2SelectEvent.jsx | 120 + .../src/components/reservation/Step3Map.jsx | 223 + .../components/reservation/Step4Summary.jsx | 138 + .../components/reservation/step3/Calendar.jsx | 219 + .../components/reservation/step3/Pricebox.jsx | 0 .../src/components/reset-password/Create.jsx | 149 + .../src/components/reset-password/Request.jsx | 109 + frontend/src/components/save.txt | 161 + .../components/security/RequireAuthLayout.jsx | 56 + .../src/components/security/RequireRole.jsx | 32 + frontend/src/context/UserContext.jsx | 30 + frontend/src/css/index.css | 18109 ++++++++++++++++ frontend/src/main.jsx | 18 + frontend/src/pages/Admin.jsx | 11 + frontend/src/pages/HelpDesk.jsx | 17 + frontend/src/pages/Home.jsx | 674 + frontend/src/pages/Login.jsx | 41 + frontend/src/pages/PasswordReset.jsx | 45 + frontend/src/pages/PaymentPage.jsx | 84 + frontend/src/pages/Reservation-cart.jsx | 21 + frontend/src/pages/SelectReservation.jsx | 109 + frontend/src/pages/Settings.jsx | 29 + frontend/src/pages/Test.jsx | 16 + frontend/src/pages/Ticket.jsx | 15 + frontend/src/pages/error/403.jsx | 0 frontend/src/pages/error/404.jsx | 0 frontend/src/pages/error/500.jsx | 0 frontend/src/pages/manager/Bin.jsx | 421 + frontend/src/pages/manager/Events.jsx | 621 + frontend/src/pages/manager/Orders.jsx | 411 + frontend/src/pages/manager/Products.jsx | 251 + frontend/src/pages/manager/Reservations.jsx | 670 + frontend/src/pages/manager/SquareDetail.jsx | 269 + frontend/src/pages/manager/Squares.jsx | 368 + frontend/src/pages/manager/UserSettings.jsx | 13 + frontend/src/pages/manager/Users.jsx | 795 + .../src/pages/manager/create/Kde je zbytek.md | 6 + .../pages/manager/create/SquareDesigner.jsx | 273 + .../src/pages/manager/create/create-event.jsx | 244 + .../pages/manager/create/create-product.jsx | 93 + .../src/pages/manager/create/create-user.jsx | 283 + frontend/src/pages/manager/edit/MapEditor.jsx | 266 + .../src/pages/register/EmailVerification.jsx | 17 + frontend/src/pages/register/Register.jsx | 84 + frontend/vite.config.js | 8 + nginx/nginx.conf | 63 + package-lock.json | 6 + 200 files changed, 42048 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/.gitignore create mode 100644 backend/account/__init__.py create mode 100644 backend/account/admin.py create mode 100644 backend/account/apps.py create mode 100644 backend/account/email.py create mode 100644 backend/account/filters.py create mode 100644 backend/account/forms.py create mode 100644 backend/account/management/commands/createsuperuser_active.py create mode 100644 backend/account/migrations/0001_initial.py create mode 100644 backend/account/migrations/__init__.py create mode 100644 backend/account/models.py create mode 100644 backend/account/permissions.py create mode 100644 backend/account/serializers.py create mode 100644 backend/account/tasks.py create mode 100644 backend/account/tests.py create mode 100644 backend/account/tokens.py create mode 100644 backend/account/urls.py create mode 100644 backend/account/utils.py create mode 100644 backend/account/views.py create mode 100644 backend/booking/__init__.py create mode 100644 backend/booking/admin.py create mode 100644 backend/booking/apps.py create mode 100644 backend/booking/filters.py create mode 100644 backend/booking/forms.py create mode 100644 backend/booking/management/__init__.py create mode 100644 backend/booking/management/commands/__init__.py create mode 100644 backend/booking/management/commands/seed_celery_beat.py create mode 100644 backend/booking/migrations/0001_initial.py create mode 100644 backend/booking/migrations/0002_initial.py create mode 100644 backend/booking/migrations/__init__.py create mode 100644 backend/booking/models.py create mode 100644 backend/booking/serializers.py create mode 100644 backend/booking/signals.py create mode 100644 backend/booking/tasks.py create mode 100644 backend/booking/tests.py create mode 100644 backend/booking/urls.py create mode 100644 backend/booking/views.py create mode 100644 backend/commerce/__init__.py create mode 100644 backend/commerce/admin.py create mode 100644 backend/commerce/apps.py create mode 100644 backend/commerce/filters.py create mode 100644 backend/commerce/migrations/0001_initial.py create mode 100644 backend/commerce/migrations/__init__.py create mode 100644 backend/commerce/models.py create mode 100644 backend/commerce/serializers.py create mode 100644 backend/commerce/tests.py create mode 100644 backend/commerce/urls.py create mode 100644 backend/commerce/views.py create mode 100644 backend/configuration/__init__.py create mode 100644 backend/configuration/admin.py create mode 100644 backend/configuration/apps.py create mode 100644 backend/configuration/migrations/0001_initial.py create mode 100644 backend/configuration/migrations/0002_appconfig_background_image_appconfig_contact_email_and_more.py create mode 100644 backend/configuration/migrations/__init__.py create mode 100644 backend/configuration/models.py create mode 100644 backend/configuration/serializers.py create mode 100644 backend/configuration/tests.py create mode 100644 backend/configuration/urls.py create mode 100644 backend/configuration/views.py create mode 100644 backend/dockerfile create mode 100644 backend/globalstaticfiles/js/index.js create mode 100644 backend/manage.py create mode 100644 backend/populate_db.py create mode 100644 backend/product/__init__.py create mode 100644 backend/product/admin.py create mode 100644 backend/product/apps.py create mode 100644 backend/product/migrations/0001_initial.py create mode 100644 backend/product/migrations/0002_alter_product_code.py create mode 100644 backend/product/migrations/__init__.py create mode 100644 backend/product/models.py create mode 100644 backend/product/serializers.py create mode 100644 backend/product/tests.py create mode 100644 backend/product/urls.py create mode 100644 backend/product/views.py create mode 100644 backend/requirements.txt create mode 100644 backend/servicedesk/__init__.py create mode 100644 backend/servicedesk/admin.py create mode 100644 backend/servicedesk/apps.py create mode 100644 backend/servicedesk/filters.py create mode 100644 backend/servicedesk/migrations/0001_initial.py create mode 100644 backend/servicedesk/migrations/__init__.py create mode 100644 backend/servicedesk/models.py create mode 100644 backend/servicedesk/serializers.py create mode 100644 backend/servicedesk/tests.py create mode 100644 backend/servicedesk/urls.py create mode 100644 backend/servicedesk/views.py create mode 100644 backend/templates/emails/create_password.html create mode 100644 backend/templates/emails/create_password.txt create mode 100644 backend/templates/html/index.html create mode 100644 backend/templates/login.html create mode 100644 backend/trznice/__init__.py create mode 100644 backend/trznice/admin.py create mode 100644 backend/trznice/asgi.py create mode 100644 backend/trznice/celery.py create mode 100644 backend/trznice/models.py create mode 100644 backend/trznice/settings.py create mode 100644 backend/trznice/urls.py create mode 100644 backend/trznice/utils.py create mode 100644 backend/trznice/views.py create mode 100644 backend/trznice/wsgi.py create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile.prod create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.cjs create mode 100644 frontend/public/img/bg.png create mode 100644 frontend/public/img/logo.png create mode 100644 frontend/public/img/namest-1.png create mode 100644 frontend/public/img/register-bg.jpg create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/auth.js create mode 100644 frontend/src/api/get_chocies.js create mode 100644 frontend/src/api/model/Settings.js create mode 100644 frontend/src/api/model/bin.js create mode 100644 frontend/src/api/model/event-product.js create mode 100644 frontend/src/api/model/event.js create mode 100644 frontend/src/api/model/market_slot.js create mode 100644 frontend/src/api/model/order.js create mode 100644 frontend/src/api/model/product.js create mode 100644 frontend/src/api/model/reservation.js create mode 100644 frontend/src/api/model/square.js create mode 100644 frontend/src/api/model/ticket.js create mode 100644 frontend/src/api/model/user.js create mode 100644 frontend/src/api/tutorialy/user.js create mode 100644 frontend/src/api/tutorialy/volání api.js create mode 100644 frontend/src/assets/json/data.json create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/components/ConfirmEmailBar.jsx create mode 100644 frontend/src/components/DynamicGrid.jsx create mode 100644 frontend/src/components/DynamicMap.jsx create mode 100644 frontend/src/components/LoginCard.jsx create mode 100644 frontend/src/components/NavBar.jsx create mode 100644 frontend/src/components/RegisterCard.jsx create mode 100644 frontend/src/components/ReportForm.jsx create mode 100644 frontend/src/components/Settings.jsx create mode 100644 frontend/src/components/Sidebar.jsx create mode 100644 frontend/src/components/Table.jsx create mode 100644 frontend/src/components/User-Settings.jsx create mode 100644 frontend/src/components/forms/ticket.jsx create mode 100644 frontend/src/components/reservation/ReservationWizard.jsx create mode 100644 frontend/src/components/reservation/Step1SelectSquare.jsx create mode 100644 frontend/src/components/reservation/Step2SelectEvent.jsx create mode 100644 frontend/src/components/reservation/Step3Map.jsx create mode 100644 frontend/src/components/reservation/Step4Summary.jsx create mode 100644 frontend/src/components/reservation/step3/Calendar.jsx create mode 100644 frontend/src/components/reservation/step3/Pricebox.jsx create mode 100644 frontend/src/components/reset-password/Create.jsx create mode 100644 frontend/src/components/reset-password/Request.jsx create mode 100644 frontend/src/components/save.txt create mode 100644 frontend/src/components/security/RequireAuthLayout.jsx create mode 100644 frontend/src/components/security/RequireRole.jsx create mode 100644 frontend/src/context/UserContext.jsx create mode 100644 frontend/src/css/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/Admin.jsx create mode 100644 frontend/src/pages/HelpDesk.jsx create mode 100644 frontend/src/pages/Home.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/PasswordReset.jsx create mode 100644 frontend/src/pages/PaymentPage.jsx create mode 100644 frontend/src/pages/Reservation-cart.jsx create mode 100644 frontend/src/pages/SelectReservation.jsx create mode 100644 frontend/src/pages/Settings.jsx create mode 100644 frontend/src/pages/Test.jsx create mode 100644 frontend/src/pages/Ticket.jsx create mode 100644 frontend/src/pages/error/403.jsx create mode 100644 frontend/src/pages/error/404.jsx create mode 100644 frontend/src/pages/error/500.jsx create mode 100644 frontend/src/pages/manager/Bin.jsx create mode 100644 frontend/src/pages/manager/Events.jsx create mode 100644 frontend/src/pages/manager/Orders.jsx create mode 100644 frontend/src/pages/manager/Products.jsx create mode 100644 frontend/src/pages/manager/Reservations.jsx create mode 100644 frontend/src/pages/manager/SquareDetail.jsx create mode 100644 frontend/src/pages/manager/Squares.jsx create mode 100644 frontend/src/pages/manager/UserSettings.jsx create mode 100644 frontend/src/pages/manager/Users.jsx create mode 100644 frontend/src/pages/manager/create/Kde je zbytek.md create mode 100644 frontend/src/pages/manager/create/SquareDesigner.jsx create mode 100644 frontend/src/pages/manager/create/create-event.jsx create mode 100644 frontend/src/pages/manager/create/create-product.jsx create mode 100644 frontend/src/pages/manager/create/create-user.jsx create mode 100644 frontend/src/pages/manager/edit/MapEditor.jsx create mode 100644 frontend/src/pages/register/EmailVerification.jsx create mode 100644 frontend/src/pages/register/Register.jsx create mode 100644 frontend/vite.config.js create mode 100644 nginx/nginx.conf create mode 100644 package-lock.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08c9c50 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..72a7572 --- /dev/null +++ b/.vscode/settings.json @@ -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 +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..137048b --- /dev/null +++ b/README.md @@ -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 ` | Create a new Django project | `python manage.py startproject myproject` | +| `startapp ` | 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 ` | 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 ` | 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 +``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..07125d4 --- /dev/null +++ b/backend/.dockerignore @@ -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 \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..6346bf8e506e6af18bdd49933239819eef2b24bb GIT binary patch literal 48 tcmezWFPR~qAqNOk8Il=FfMg0oF_2CK;!L1O8ju9Z6*K5F@G@{Q002LM3XT8( literal 0 HcmV?d00001 diff --git a/backend/account/__init__.py b/backend/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/account/admin.py b/backend/account/admin.py new file mode 100644 index 0000000..5c90d30 --- /dev/null +++ b/backend/account/admin.py @@ -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) \ No newline at end of file diff --git a/backend/account/apps.py b/backend/account/apps.py new file mode 100644 index 0000000..2b08f1a --- /dev/null +++ b/backend/account/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'account' diff --git a/backend/account/email.py b/backend/account/email.py new file mode 100644 index 0000000..62c3425 --- /dev/null +++ b/backend/account/email.py @@ -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) diff --git a/backend/account/filters.py b/backend/account/filters.py new file mode 100644 index 0000000..c1de518 --- /dev/null +++ b/backend/account/filters.py @@ -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" + ] diff --git a/backend/account/forms.py b/backend/account/forms.py new file mode 100644 index 0000000..9ad54d9 --- /dev/null +++ b/backend/account/forms.py @@ -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 diff --git a/backend/account/management/commands/createsuperuser_active.py b/backend/account/management/commands/createsuperuser_active.py new file mode 100644 index 0000000..79a628e --- /dev/null +++ b/backend/account/management/commands/createsuperuser_active.py @@ -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.")) diff --git a/backend/account/migrations/0001_initial.py b/backend/account/migrations/0001_initial.py new file mode 100644 index 0000000..24eaa54 --- /dev/null +++ b/backend/account/migrations/0001_initial.py @@ -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()), + ], + ), + ] diff --git a/backend/account/migrations/__init__.py b/backend/account/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/account/models.py b/backend/account/models.py new file mode 100644 index 0000000..02c23cb --- /dev/null +++ b/backend/account/models.py @@ -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 + + diff --git a/backend/account/permissions.py b/backend/account/permissions.py new file mode 100644 index 0000000..5b54b0b --- /dev/null +++ b/backend/account/permissions.py @@ -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' + diff --git a/backend/account/serializers.py b/backend/account/serializers.py new file mode 100644 index 0000000..aca553f --- /dev/null +++ b/backend/account/serializers.py @@ -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 \ No newline at end of file diff --git a/backend/account/tasks.py b/backend/account/tasks.py new file mode 100644 index 0000000..35f82d9 --- /dev/null +++ b/backend/account/tasks.py @@ -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 diff --git a/backend/account/tests.py b/backend/account/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/account/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/account/tokens.py b/backend/account/tokens.py new file mode 100644 index 0000000..08e9bb2 --- /dev/null +++ b/backend/account/tokens.py @@ -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 + diff --git a/backend/account/urls.py b/backend/account/urls.py new file mode 100644 index 0000000..28993da --- /dev/null +++ b/backend/account/urls.py @@ -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///", 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///", PasswordResetConfirmView.as_view(), name="reset-password-confirm"), +] \ No newline at end of file diff --git a/backend/account/utils.py b/backend/account/utils.py new file mode 100644 index 0000000..158638f --- /dev/null +++ b/backend/account/utils.py @@ -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() \ No newline at end of file diff --git a/backend/account/views.py b/backend/account/views.py new file mode 100644 index 0000000..173bc13 --- /dev/null +++ b/backend/account/views.py @@ -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 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) \ No newline at end of file diff --git a/backend/booking/__init__.py b/backend/booking/__init__.py new file mode 100644 index 0000000..78768a9 --- /dev/null +++ b/backend/booking/__init__.py @@ -0,0 +1 @@ +# from . import tasks diff --git a/backend/booking/admin.py b/backend/booking/admin.py new file mode 100644 index 0000000..3947f40 --- /dev/null +++ b/backend/booking/admin.py @@ -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) \ No newline at end of file diff --git a/backend/booking/apps.py b/backend/booking/apps.py new file mode 100644 index 0000000..08457ca --- /dev/null +++ b/backend/booking/apps.py @@ -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 diff --git a/backend/booking/filters.py b/backend/booking/filters.py new file mode 100644 index 0000000..f5dfd9d --- /dev/null +++ b/backend/booking/filters.py @@ -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"] diff --git a/backend/booking/forms.py b/backend/booking/forms.py new file mode 100644 index 0000000..f0edf6e --- /dev/null +++ b/backend/booking/forms.py @@ -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 diff --git a/backend/booking/management/__init__.py b/backend/booking/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/booking/management/commands/__init__.py b/backend/booking/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/booking/management/commands/seed_celery_beat.py b/backend/booking/management/commands/seed_celery_beat.py new file mode 100644 index 0000000..1430596 --- /dev/null +++ b/backend/booking/management/commands/seed_celery_beat.py @@ -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.")) diff --git a/backend/booking/migrations/0001_initial.py b/backend/booking/migrations/0001_initial.py new file mode 100644 index 0000000..2d598ed --- /dev/null +++ b/backend/booking/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/backend/booking/migrations/0002_initial.py b/backend/booking/migrations/0002_initial.py new file mode 100644 index 0000000..9e08374 --- /dev/null +++ b/backend/booking/migrations/0002_initial.py @@ -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'), + ), + ] diff --git a/backend/booking/migrations/__init__.py b/backend/booking/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/booking/models.py b/backend/booking/models.py new file mode 100644 index 0000000..a352eb5 --- /dev/null +++ b/backend/booking/models.py @@ -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) diff --git a/backend/booking/serializers.py b/backend/booking/serializers.py new file mode 100644 index 0000000..3f323dc --- /dev/null +++ b/backend/booking/serializers.py @@ -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) + } diff --git a/backend/booking/signals.py b/backend/booking/signals.py new file mode 100644 index 0000000..01897fd --- /dev/null +++ b/backend/booking/signals.py @@ -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"]) diff --git a/backend/booking/tasks.py b/backend/booking/tasks.py new file mode 100644 index 0000000..756819a --- /dev/null +++ b/backend/booking/tasks.py @@ -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" + diff --git a/backend/booking/tests.py b/backend/booking/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/booking/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/booking/urls.py b/backend/booking/urls.py new file mode 100644 index 0000000..929983c --- /dev/null +++ b/backend/booking/urls.py @@ -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'), +] \ No newline at end of file diff --git a/backend/booking/views.py b/backend/booking/views.py new file mode 100644 index 0000000..1933251 --- /dev/null +++ b/backend/booking/views.py @@ -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() \ No newline at end of file diff --git a/backend/commerce/__init__.py b/backend/commerce/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/commerce/admin.py b/backend/commerce/admin.py new file mode 100644 index 0000000..1ff8d75 --- /dev/null +++ b/backend/commerce/admin.py @@ -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) \ No newline at end of file diff --git a/backend/commerce/apps.py b/backend/commerce/apps.py new file mode 100644 index 0000000..b996052 --- /dev/null +++ b/backend/commerce/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommerceConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'commerce' diff --git a/backend/commerce/filters.py b/backend/commerce/filters.py new file mode 100644 index 0000000..66be268 --- /dev/null +++ b/backend/commerce/filters.py @@ -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"] diff --git a/backend/commerce/migrations/0001_initial.py b/backend/commerce/migrations/0001_initial.py new file mode 100644 index 0000000..74f2104 --- /dev/null +++ b/backend/commerce/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/backend/commerce/migrations/__init__.py b/backend/commerce/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/commerce/models.py b/backend/commerce/models.py new file mode 100644 index 0000000..254e27e --- /dev/null +++ b/backend/commerce/models.py @@ -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) diff --git a/backend/commerce/serializers.py b/backend/commerce/serializers.py new file mode 100644 index 0000000..bb92849 --- /dev/null +++ b/backend/commerce/serializers.py @@ -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) \ No newline at end of file diff --git a/backend/commerce/tests.py b/backend/commerce/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/commerce/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/commerce/urls.py b/backend/commerce/urls.py new file mode 100644 index 0000000..45b8184 --- /dev/null +++ b/backend/commerce/urls.py @@ -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"), +] \ No newline at end of file diff --git a/backend/commerce/views.py b/backend/commerce/views.py new file mode 100644 index 0000000..ec5d5c2 --- /dev/null +++ b/backend/commerce/views.py @@ -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) \ No newline at end of file diff --git a/backend/configuration/__init__.py b/backend/configuration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/configuration/admin.py b/backend/configuration/admin.py new file mode 100644 index 0000000..f9c7e96 --- /dev/null +++ b/backend/configuration/admin.py @@ -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) diff --git a/backend/configuration/apps.py b/backend/configuration/apps.py new file mode 100644 index 0000000..eab8aa0 --- /dev/null +++ b/backend/configuration/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ConfigurationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'configuration' diff --git a/backend/configuration/migrations/0001_initial.py b/backend/configuration/migrations/0001_initial.py new file mode 100644 index 0000000..b996c63 --- /dev/null +++ b/backend/configuration/migrations/0001_initial.py @@ -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.')), + ], + ), + ] diff --git a/backend/configuration/migrations/0002_appconfig_background_image_appconfig_contact_email_and_more.py b/backend/configuration/migrations/0002_appconfig_background_image_appconfig_contact_email_and_more.py new file mode 100644 index 0000000..56151a9 --- /dev/null +++ b/backend/configuration/migrations/0002_appconfig_background_image_appconfig_contact_email_and_more.py @@ -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), + ), + ] diff --git a/backend/configuration/migrations/__init__.py b/backend/configuration/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/configuration/models.py b/backend/configuration/models.py new file mode 100644 index 0000000..53bd2b9 --- /dev/null +++ b/backend/configuration/models.py @@ -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) \ No newline at end of file diff --git a/backend/configuration/serializers.py b/backend/configuration/serializers.py new file mode 100644 index 0000000..7e2e529 --- /dev/null +++ b/backend/configuration/serializers.py @@ -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: + 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} diff --git a/backend/configuration/tests.py b/backend/configuration/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/configuration/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/configuration/urls.py b/backend/configuration/urls.py new file mode 100644 index 0000000..6dad495 --- /dev/null +++ b/backend/configuration/urls.py @@ -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'), +] \ No newline at end of file diff --git a/backend/configuration/views.py b/backend/configuration/views.py new file mode 100644 index 0000000..77b9bca --- /dev/null +++ b/backend/configuration/views.py @@ -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: , deleted_at: , 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) diff --git a/backend/dockerfile b/backend/dockerfile new file mode 100644 index 0000000..1dbabf7 --- /dev/null +++ b/backend/dockerfile @@ -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 . . diff --git a/backend/globalstaticfiles/js/index.js b/backend/globalstaticfiles/js/index.js new file mode 100644 index 0000000..fbe7bfa --- /dev/null +++ b/backend/globalstaticfiles/js/index.js @@ -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; +} \ No newline at end of file diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..bab9d28 --- /dev/null +++ b/backend/manage.py @@ -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() diff --git a/backend/populate_db.py b/backend/populate_db.py new file mode 100644 index 0000000..3221734 --- /dev/null +++ b/backend/populate_db.py @@ -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.") diff --git a/backend/product/__init__.py b/backend/product/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/product/admin.py b/backend/product/admin.py new file mode 100644 index 0000000..73b130b --- /dev/null +++ b/backend/product/admin.py @@ -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) \ No newline at end of file diff --git a/backend/product/apps.py b/backend/product/apps.py new file mode 100644 index 0000000..235a333 --- /dev/null +++ b/backend/product/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProductConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'product' diff --git a/backend/product/migrations/0001_initial.py b/backend/product/migrations/0001_initial.py new file mode 100644 index 0000000..726906c --- /dev/null +++ b/backend/product/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/backend/product/migrations/0002_alter_product_code.py b/backend/product/migrations/0002_alter_product_code.py new file mode 100644 index 0000000..3bd02c0 --- /dev/null +++ b/backend/product/migrations/0002_alter_product_code.py @@ -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'), + ), + ] diff --git a/backend/product/migrations/__init__.py b/backend/product/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/product/models.py b/backend/product/models.py new file mode 100644 index 0000000..5a533ba --- /dev/null +++ b/backend/product/models.py @@ -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}" \ No newline at end of file diff --git a/backend/product/serializers.py b/backend/product/serializers.py new file mode 100644 index 0000000..b0286d9 --- /dev/null +++ b/backend/product/serializers.py @@ -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 diff --git a/backend/product/tests.py b/backend/product/tests.py new file mode 100644 index 0000000..408e6f5 --- /dev/null +++ b/backend/product/tests.py @@ -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() diff --git a/backend/product/urls.py b/backend/product/urls.py new file mode 100644 index 0000000..4c8f909 --- /dev/null +++ b/backend/product/urls.py @@ -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)), +] \ No newline at end of file diff --git a/backend/product/views.py b/backend/product/views.py new file mode 100644 index 0000000..d4b460e --- /dev/null +++ b/backend/product/views.py @@ -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")] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..657bd90 --- /dev/null +++ b/backend/requirements.txt @@ -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 \ No newline at end of file diff --git a/backend/servicedesk/__init__.py b/backend/servicedesk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/servicedesk/admin.py b/backend/servicedesk/admin.py new file mode 100644 index 0000000..a5da8f9 --- /dev/null +++ b/backend/servicedesk/admin.py @@ -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) \ No newline at end of file diff --git a/backend/servicedesk/apps.py b/backend/servicedesk/apps.py new file mode 100644 index 0000000..eca6f7c --- /dev/null +++ b/backend/servicedesk/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ServicedeskConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'servicedesk' diff --git a/backend/servicedesk/filters.py b/backend/servicedesk/filters.py new file mode 100644 index 0000000..e72d41e --- /dev/null +++ b/backend/servicedesk/filters.py @@ -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"] diff --git a/backend/servicedesk/migrations/0001_initial.py b/backend/servicedesk/migrations/0001_initial.py new file mode 100644 index 0000000..79ffc82 --- /dev/null +++ b/backend/servicedesk/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/backend/servicedesk/migrations/__init__.py b/backend/servicedesk/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/servicedesk/models.py b/backend/servicedesk/models.py new file mode 100644 index 0000000..7e2fa54 --- /dev/null +++ b/backend/servicedesk/models.py @@ -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()})" + \ No newline at end of file diff --git a/backend/servicedesk/serializers.py b/backend/servicedesk/serializers.py new file mode 100644 index 0000000..bcec498 --- /dev/null +++ b/backend/servicedesk/serializers.py @@ -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 diff --git a/backend/servicedesk/tests.py b/backend/servicedesk/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/servicedesk/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/servicedesk/urls.py b/backend/servicedesk/urls.py new file mode 100644 index 0000000..ffb247b --- /dev/null +++ b/backend/servicedesk/urls.py @@ -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)), +] diff --git a/backend/servicedesk/views.py b/backend/servicedesk/views.py new file mode 100644 index 0000000..c56bf5a --- /dev/null +++ b/backend/servicedesk/views.py @@ -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) diff --git a/backend/templates/emails/create_password.html b/backend/templates/emails/create_password.html new file mode 100644 index 0000000..a0e427a --- /dev/null +++ b/backend/templates/emails/create_password.html @@ -0,0 +1,15 @@ + + + + + Váš přístup do systému e-Rezervace + + +

Dobrý den {{ username }},

+

byl vám vytvořen účet v systému e-Rezervace.

+

Přihlašte se kliknutím na následující odkaz:

+

{{ login_url }}

+
+

S pozdravem,
Váš tým

+ + diff --git a/backend/templates/emails/create_password.txt b/backend/templates/emails/create_password.txt new file mode 100644 index 0000000..0654f21 --- /dev/null +++ b/backend/templates/emails/create_password.txt @@ -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 diff --git a/backend/templates/html/index.html b/backend/templates/html/index.html new file mode 100644 index 0000000..5b7a001 --- /dev/null +++ b/backend/templates/html/index.html @@ -0,0 +1,74 @@ +{% load static %} + + + + + + + Home - Backend + + + + + {% if user.is_authenticated %} +

Logged as: {{user.username}} | Role: {{user.role}}

+ {% endif %} + + + + + + + \ No newline at end of file diff --git a/backend/templates/login.html b/backend/templates/login.html new file mode 100644 index 0000000..e69de29 diff --git a/backend/trznice/__init__.py b/backend/trznice/__init__.py new file mode 100644 index 0000000..fb989c4 --- /dev/null +++ b/backend/trznice/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/backend/trznice/admin.py b/backend/trznice/admin.py new file mode 100644 index 0000000..53f63c9 --- /dev/null +++ b/backend/trznice/admin.py @@ -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) diff --git a/backend/trznice/asgi.py b/backend/trznice/asgi.py new file mode 100644 index 0000000..bfb1653 --- /dev/null +++ b/backend/trznice/asgi.py @@ -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() diff --git a/backend/trznice/celery.py b/backend/trznice/celery.py new file mode 100644 index 0000000..272ef5f --- /dev/null +++ b/backend/trznice/celery.py @@ -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 \ No newline at end of file diff --git a/backend/trznice/models.py b/backend/trznice/models.py new file mode 100644 index 0000000..a4e05d8 --- /dev/null +++ b/backend/trznice/models.py @@ -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 + +""" diff --git a/backend/trznice/settings.py b/backend/trznice/settings.py new file mode 100644 index 0000000..cbae117 --- /dev/null +++ b/backend/trznice/settings.py @@ -0,0 +1,951 @@ +""" +Django settings for trznice project. + +Generated by 'django-admin startproject' using Django 5.1.3. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" +import os +from typing import Dict, Any +from pathlib import Path + +from django.core.management.utils import get_random_secret_key +from django.db import OperationalError, connections + +from datetime import timedelta + +from dotenv import load_dotenv +load_dotenv() # Pouze načte proměnné lokálně, pokud nejsou dostupné + +#---------------- ENV VARIABLES USECASE-------------- +# v jiné app si to importneš skrz: from django.conf import settings +# a použiješ takto: settings.FRONTEND_URL + +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") +#-------------------------BASE ⚙️------------------------ + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Pavel +# from django.conf.locale.en import formats as en_formats + +DATETIME_INPUT_FORMATS = [ + "%Y-%m-%d", # '2025-07-25' + "%Y-%m-%d %H:%M", # '2025-07-25 14:30' + "%Y-%m-%d %H:%M:%S", # '2025-07-25 14:30:59' + "%Y-%m-%dT%H:%M", # '2025-07-25T14:30' + "%Y-%m-%dT%H:%M:%S", # '2025-07-25T14:30:59' +] + +LANGUAGE_CODE = 'cs' + +TIME_ZONE = 'Europe/Prague' + +USE_I18N = True + +USE_TZ = True + + + + +# SECURITY WARNING: don't run with debug turned on in production! +if os.getenv("DEBUG", "") == "True": + DEBUG = True +else: + DEBUG = False + +print(f"\nDEBUG state: {str(DEBUG)}\nDEBUG .env raw: {os.getenv('DEBUG', '')}\n") + +#-----------------------BASE END⚙️-------------------------- + +#--------------- URLS 🌐 ------------------- + +ASGI_APPLICATION = 'trznice.asgi.application' #daphne +ROOT_URLCONF = 'trznice.urls' +LOGIN_URL = '/admin' #nastavení Login adresy + +#----------------------------------------- + + + +#----------------------------------- LOGS ------------------------------------------- +#slouží pro tisknutí do konzole v dockeru skrz: logger.debug("content") +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {name}: {message}", + "style": "{", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG" if DEBUG else "INFO", + }, +} + +""" +import logging + +# Vytvoř si logger podle názvu souboru (modulu) +logger = logging.getLogger(__name__) + + +logger.debug("Ladicí zpráva – vidíš jen když je DEBUG = True") +logger.info("Informace – např. že uživatel klikl na tlačítko") +logger.warning("Varování – něco nečekaného, ale ne kritického") +logger.error("Chyba – něco se pokazilo, ale aplikace jede dál") +logger.critical("Kritická chyba – selhání systému, třeba pád služby") +""" + +#---------------------------------- END LOGS --------------------------------------- + +#-------------------------------------SECURITY 🔐------------------------------------ + +if DEBUG: + SECRET_KEY = 'pernament' +else: + SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", get_random_secret_key()) + +# Honor reverse proxy host/port even without SSL +USE_X_FORWARDED_HOST = True +# Optionally honor proto if you terminate SSL at proxy +# SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +SESSION_COOKIE_AGE = 86400 # one day + + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +AUTHENTICATION_BACKENDS = [ + #'trznice.backend.EmailOrUsernameModelBackend', #custom backend z authentication aplikace + 'django.contrib.auth.backends.ModelBackend', +] + +#--------------------------------END SECURITY 🔐------------------------------------- + +#-------------------------------------CORS + HOSTs 🌐🔐------------------------------------ + +ALLOWED_HOSTS = ["*"] + +from urllib.parse import urlparse +parsed = urlparse(FRONTEND_URL) + +CSRF_TRUSTED_ORIGINS = [ + f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}", + + "http://192.168.67.98", + "https://itsolutions.vontor.cz", + "https://react.vontor.cz", + + "http://localhost:5173", + "http://localhost:3000", + + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", + + #server + "http://192.168.67.98", + "https://itsolutions.vontor.cz", + "https://react.vontor.cz", + + #nginx docker (local) + "http://localhost", + "http://localhost:80", + "http://127.0.0.1", +] + +if DEBUG: + CORS_ALLOWED_ORIGINS = [ + f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}", + + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", + + #server + "http://192.168.67.98", + "https://itsolutions.vontor.cz", + "https://react.vontor.cz", + + #nginx docker (local) + "http://localhost", + "http://localhost:80", + "http://127.0.0.1", + ] +else: + CORS_ALLOWED_ORIGINS = [ + "http://192.168.67.98", + "https://itsolutions.vontor.cz", + "https://react.vontor.cz", + ] + +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = False # Tohle musí být false, když používáš credentials + +SESSION_COOKIE_SAMESITE = None +CSRF_COOKIE_SAMESITE = None + +print("CORS_ALLOWED_ORIGINS =", CORS_ALLOWED_ORIGINS) +print("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS) +print("ALLOWED_HOSTS =", ALLOWED_HOSTS) + + + +#--------------------------------END CORS + HOSTs 🌐🔐--------------------------------- + + +#--------------------------------------SSL 🧾------------------------------------ + +if os.getenv("SSL", "") == "True": + USE_SSL = True +else: + USE_SSL = False + + +if USE_SSL is True: + print("SSL turned on!") + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_SSL_REDIRECT = True + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + # USE_X_FORWARDED_HOST stays True (set above) + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +else: + SESSION_COOKIE_SECURE = False + CSRF_COOKIE_SECURE = False + SECURE_SSL_REDIRECT = False + SECURE_BROWSER_XSS_FILTER = False + SECURE_CONTENT_TYPE_NOSNIFF = False + # USE_X_FORWARDED_HOST stays True (set above) +print(f"\nUsing SSL: {USE_SSL}\n") + +#--------------------------------END-SSL 🧾--------------------------------- + + + + + +#-------------------------------------REST FRAMEWORK 🛠️------------------------------------ + +# ⬇️ Základní lifetime konfigurace +ACCESS_TOKEN_LIFETIME = timedelta(minutes=60) +REFRESH_TOKEN_LIFETIME = timedelta(days=5) + +# ⬇️ Nastavení SIMPLE_JWT podle režimu +if DEBUG: + SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME, + "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME, + + "AUTH_COOKIE": "access_token", + "AUTH_COOKIE_REFRESH": "refresh_token", + + "AUTH_COOKIE_DOMAIN": None, + "AUTH_COOKIE_SECURE": False, + "AUTH_COOKIE_HTTP_ONLY": True, + + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + + "AUTH_COOKIE_PATH": "/", + "AUTH_COOKIE_SAMESITE": "Lax", # change to "None" only if you serve via HTTPS; keep Lax if using same-origin + # ...existing code... + + } +else: + SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME, + "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME, + + "AUTH_COOKIE": "access_token", + "AUTH_COOKIE_REFRESH": "refresh_token", # ensure refresh cookie is recognized/used + "AUTH_COOKIE_DOMAIN": None, + "AUTH_COOKIE_SECURE": True, # HTTPS only + "AUTH_COOKIE_HTTP_ONLY": True, + "AUTH_COOKIE_PATH": "/", + "AUTH_COOKIE_SAMESITE": "None", # potřebné pro cross-origin + + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + } + + +REST_FRAMEWORK = { + "DATETIME_FORMAT": "%Y-%m-%d %H:%M", + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'account.tokens.CookieJWTAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', # <-- allow Bearer Authorization + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.AllowAny', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], +} + + +#--------------------------------END REST FRAMEWORK 🛠️------------------------------------- + + + +#-------------------------------------APPS 📦------------------------------------ +MY_CREATED_APPS = [ + 'account', + 'booking', + 'product', + 'servicedesk', + 'commerce', + 'configuration', +] + +INSTALLED_APPS = [ + 'daphne', #asgi bude fungovat lokálně (musí být na začátku) + + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'corsheaders', #cors + + 'django_celery_beat', #slouží k plánování úkolů pro Celery + + + #'chat.apps.GlobalChatCheck', #tohle se spusti při každé django inicializaci (migration, createmigration, runserver) + + #'authentication', + + 'storages',# Adds support for external storage services like Amazon S3 via django-storages + 'django_filters', + + 'channels' ,# django channels + + 'rest_framework', + 'rest_framework_api_key', + 'rest_framework_simplejwt.token_blacklist', + + 'drf_spectacular', #rest framework, grafické zobrazení + + #Nastavení stránky + #'constance', + #'constance.backends.database', + + 'django.contrib.sitemaps', + + 'tinymce', + + + #kvůli bugum je lepší to dát na poslední místo v INSTALLED_APPS + 'django_cleanup.apps.CleanupConfig', #app která maže nepoužité soubory(media) z databáze na S3 +] + +#skládaní dohromady INSTALLED_APPS +INSTALLED_APPS = INSTALLED_APPS[:-1] + MY_CREATED_APPS + INSTALLED_APPS[-1:] + +# -------------------------------------END APPS 📦------------------------------------ + + + + + +#-------------------------------------MIDDLEWARE 🧩------------------------------------ +# Middleware is a framework of hooks into Django's request/response processing. + +MIDDLEWARE = [ + # Middleware that allows your backend to accept requests from other domains (CORS) + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + + + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + + #CUSTOM + #'tools.middleware.CustomMaxUploadSizeMiddleware', + + + 'whitenoise.middleware.WhiteNoiseMiddleware',# díky tomu funguje načítaní static files +] + +#--------------------------------END MIDDLEWARE 🧩--------------------------------- + + + + + +#-------------------------------------CACHE + CHANNELS(ws) 📡🗄️------------------------------------ + +# Caching settings for Redis (using Docker's internal network name for Redis) +if DEBUG is False: + #PRODUCTION + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': 'redis://redis:6379/0', # Using the service name `redis` from Docker Compose + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + 'PASSWORD': os.getenv('REDIS_PASSWORD'), # Make sure to set REDIS_PASSWORD in your environment + }, + } + } + + # WebSockets Channel Layers (using Redis in production) + CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': { + 'hosts': [('redis', 6379)], # Use `redis` service in Docker Compose + }, + } + } + +else: + #DEVELOPMENT + # Use in-memory channel layer for development (when DEBUG is True) + CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels.layers.InMemoryChannelLayer', + } + } + + # Use in-memory cache for development (when DEBUG is True) + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + } + } + +#--------------------------------END CACHE + CHANNELS(ws) 📡🗄️--------------------------------- + +#-------------------------------------CELERY 📅------------------------------------ + +# CELERY_BROKER_URL = 'redis://localhost:6379/0' +CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL") +CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND") + +try: + import redis + # test connection + r = redis.Redis(host='localhost', port=6379, db=0) + r.ping() +except Exception: + CELERY_BROKER_URL = 'memory://' + +CELERY_ACCEPT_CONTENT = os.getenv("CELERY_ACCEPT_CONTENT") +CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER") +CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE") + +CELERY_BEAT_SCHEDULER = os.getenv("CELERY_BEAT_SCHEDULER") +# if DEBUG: +# CELERY_BROKER_URL = 'redis://localhost:6379/0' +# try: +# import redis +# # test connection +# r = redis.Redis(host='localhost', port=6379, db=0) +# r.ping() +# except Exception: +# CELERY_BROKER_URL = 'memory://' + +# CELERY_ACCEPT_CONTENT = ['json'] +# CELERY_TASK_SERIALIZER = 'json' +# CELERY_TIMEZONE = 'Europe/Prague' + +# CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' + + # from celery.schedules import crontab + + # CELERY_BEAT_SCHEDULE = { + # 'hard_delete_soft_deleted_monthly': { + # 'task': 'trznice.tasks.hard_delete_soft_deleted_records', + # 'schedule': crontab(minute=0, hour=0, day_of_month=1), # každý první den v měsíci o půlnoci + # }, + # 'delete_old_reservations_monthly': { + # 'task': 'account.tasks.delete_old_reservations', + # 'schedule': crontab(minute=0, hour=1, day_of_month=1), # každý první den v měsíci v 1:00 ráno + # }, + # } +# else: +# # Nebo nastav dummy broker, aby se úlohy neodesílaly +# CELERY_BROKER_URL = 'memory://' # broker v paměti, pro testování bez Redis + +#-------------------------------------END CELERY 📅------------------------------------ + + +#-------------------------------------DATABASE 💾------------------------------------ + +# Nastavuje výchozí typ primárního klíče pro modely. +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# říka že se úkladá do databáze, místo do cookie +SESSION_ENGINE = 'django.contrib.sessions.backends.db' + +USE_DOCKER_DB = os.getenv("USE_DOCKER_DB", "False") in ["True", "true", "1", True] + +if USE_DOCKER_DB is False: + # DEV + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', # Database engine + 'NAME': BASE_DIR / 'db.sqlite3', # Path to the SQLite database file + } + } +else: + #DOCKER + DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE'), + 'NAME': os.getenv('POSTGRES_DB'), + 'USER': os.getenv('POSTGRES_USER'), + 'PASSWORD': os.getenv('POSTGRES_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } + } + +print(f"\nUsing Docker DB: {USE_DOCKER_DB}\nDatabase settings: {DATABASES}\n") + +AUTH_USER_MODEL = 'account.CustomUser' #class CustomUser(AbstractUser) best practice to use AbstractUser + +#--------------------------------END DATABASE 💾--------------------------------- + +#--------------------------------------PAGE SETTINGS ------------------------------------- +CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend' + +# Configuration for Constance(variables) +CONSTANCE_CONFIG = { + 'BITCOIN_WALLET': ('', 'Public BTC wallet address'), + 'SUPPORT_EMAIL': ('admin@example.com', 'Support email'), +} + +#--------------------------------------EMAIL 📧-------------------------------------- + +if DEBUG: + # DEVELOPMENT + EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # Use console backend for development + # EMAILY SE BUDOU POSÍLAT DO KONZOLE!!! +else: + # PRODUCTION + EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + +EMAIL_HOST = os.getenv("EMAIL_HOST_DEV") +EMAIL_PORT = int(os.getenv("EMAIL_PORT_DEV", 465)) +EMAIL_USE_TLS = True # ❌ Keep this OFF when using SSL +EMAIL_USE_SSL = False # ✅ Must be True for port 465 +EMAIL_HOST_USER = os.getenv("EMAIL_USER_DEV") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD_DEV") +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER +EMAIL_TIMEOUT = 10 + +print("---------EMAIL----------\nEMAIL_HOST =", os.getenv("EMAIL_HOST_DEV")) +print("EMAIL_PORT =", os.getenv("EMAIL_PORT_DEV")) +print("EMAIL_USER =", os.getenv("EMAIL_USER_DEV")) +print("EMAIL_USER_PASSWORD =", os.getenv("EMAIL_USER_PASSWORD_DEV"), "\n------------------------") + +#----------------------------------EMAIL END 📧------------------------------------- + + + + +#-------------------------------------TEMPLATES 🗂️------------------------------------ + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + "DIRS": [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +#--------------------------------END TEMPLATES 🗂️--------------------------------- + + + + + + + +#-------------------------------------MEDIA + STATIC 🖼️, AWS ☁️------------------------------------ + +# nastavení složky pro globalstaticfiles (static složky django hledá samo) +STATICFILES_DIRS = [ + BASE_DIR / 'globalstaticfiles', +] + + + +if os.getenv("USE_AWS", "") == "True": + USE_AWS = True +else: + USE_AWS = False + +print(f"\n-------------- USE_AWS: {USE_AWS} --------------") + +if USE_AWS is False: + # DEVELOPMENT + + + # Development: Use local file system storage for static files + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } + + # Media and Static URL for local dev + MEDIA_URL = os.getenv("MEDIA_URL", "/media/") # URL prefix for media files + MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # Local folder for user-uploaded files + + STATIC_URL = '/static/' + + # Local folder for collected static files + STATIC_ROOT = BASE_DIR / 'collectedstaticfiles' + +elif USE_AWS: + # PRODUCTION + + AWS_LOCATION = "static" + + # Production: Use S3 storage + STORAGES = { + "default": { + "BACKEND" : "storages.backends.s3boto3.S3StaticStorage", + }, + + "staticfiles": { + "BACKEND" : "storages.backends.s3boto3.S3StaticStorage", + }, + } + + # Media and Static URL for AWS S3 + MEDIA_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/media/' + STATIC_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/static/' + + CSRF_TRUSTED_ORIGINS.append(STATIC_URL) + + # Static files should be collected to a local directory and then uploaded to S3 + STATIC_ROOT = BASE_DIR / 'collectedstaticfiles' + + AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID') + AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') + AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME') + AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', 'us-east-1') # Default to 'us-east-1' if not set + AWS_S3_SIGNATURE_VERSION = 's3v4' # Use AWS Signature Version 4 + AWS_S3_USE_SSL = True + AWS_S3_FILE_OVERWRITE = True + AWS_DEFAULT_ACL = None # Set to None to avoid setting a default ACL + + + +print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------") + +#--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️--------------------------------- + + + +#-------------------------------------TINY MCE ✍️------------------------------------ + +TINYMCE_JS_URL = 'https://cdn.tiny.cloud/1/no-api-key/tinymce/7/tinymce.min.js' + +TINYMCE_DEFAULT_CONFIG = { + "height": "320px", + "width": "960px", + "menubar": "file edit view insert format tools table help", + "plugins": "advlist autolink lists link image charmap print preview anchor searchreplace visualblocks code " + "fullscreen insertdatetime media table paste code help wordcount spellchecker", + "toolbar": "undo redo | bold italic underline strikethrough | fontselect fontsizeselect formatselect | alignleft " + "aligncenter alignright alignjustify | outdent indent | numlist bullist checklist | forecolor " + "backcolor casechange permanentpen formatpainter removeformat | pagebreak | charmap emoticons | " + "fullscreen preview save print | insertfile image media pageembed template link anchor codesample | " + "a11ycheck ltr rtl | showcomments addcomment code", + "custom_undo_redo_levels": 10, +} +TINYMCE_SPELLCHECKER = True +TINYMCE_COMPRESSOR = True + +#--------------------------------END-TINY-MCE-SECTION ✍️--------------------------------- + + + +#-------------------------------------DRF SPECTACULAR 📊------------------------------------ + +SPECTACULAR_DEFAULTS: Dict[str, Any] = { + # A regex specifying the common denominator for all operation paths. If + # SCHEMA_PATH_PREFIX is set to None, drf-spectacular will attempt to estimate + # a common prefix. Use '' to disable. + # Mainly used for tag extraction, where paths like '/api/v1/albums' with + # a SCHEMA_PATH_PREFIX regex '/api/v[0-9]' would yield the tag 'albums'. + 'SCHEMA_PATH_PREFIX': None, + + # Remove matching SCHEMA_PATH_PREFIX from operation path. Usually used in + # conjunction with appended prefixes in SERVERS. + 'SCHEMA_PATH_PREFIX_TRIM': False, + + # Insert a manual path prefix to the operation path, e.g. '/service/backend'. + # Use this for example to align paths when the API is mounted as a sub-resource + # behind a proxy and Django is not aware of that. Alternatively, prefixes can + # also specified via SERVERS, but this makes the operation path more explicit. + 'SCHEMA_PATH_PREFIX_INSERT': '', + + # Coercion of {pk} to {id} is controlled by SCHEMA_COERCE_PATH_PK. Additionally, + # some libraries (e.g. drf-nested-routers) use "_pk" suffixed path variables. + # This setting globally coerces path variables like "{user_pk}" to "{user_id}". + 'SCHEMA_COERCE_PATH_PK_SUFFIX': False, + + # Schema generation parameters to influence how components are constructed. + # Some schema features might not translate well to your target. + # Demultiplexing/modifying components might help alleviate those issues. + 'DEFAULT_GENERATOR_CLASS': 'drf_spectacular.generators.SchemaGenerator', + + # Create separate components for PATCH endpoints (without required list) + 'COMPONENT_SPLIT_PATCH': True, + + # Split components into request and response parts where appropriate + # This setting is highly recommended to achieve the most accurate API + # description, however it comes at the cost of having more components. + 'COMPONENT_SPLIT_REQUEST': True, + + # Aid client generator targets that have trouble with read-only properties. + 'COMPONENT_NO_READ_ONLY_REQUIRED': False, + + # Adds "minLength: 1" to fields that do not allow blank strings. Deactivated + # by default because serializers do not strictly enforce this on responses and + # so "minLength: 1" may not always accurately describe API behavior. + # Gets implicitly enabled by COMPONENT_SPLIT_REQUEST, because this can be + # accurately modeled when request and response components are separated. + 'ENFORCE_NON_BLANK_FIELDS': False, + + # This version string will end up the in schema header. The default OpenAPI + # version is 3.0.3, which is heavily tested. We now also support 3.1.0, + # which contains the same features and a few mandatory, but minor changes. + 'OAS_VERSION': '3.0.3', + + # Configuration for serving a schema subset with SpectacularAPIView + 'SERVE_URLCONF': None, + + # complete public schema or a subset based on the requesting user + 'SERVE_PUBLIC': True, + + # include schema endpoint into schema + 'SERVE_INCLUDE_SCHEMA': True, + + # list of authentication/permission classes for spectacular's views. + 'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'], #account.permissions.AdminOnly + + # None will default to DRF's AUTHENTICATION_CLASSES + 'SERVE_AUTHENTICATION': None, + + # Dictionary of general configuration to pass to the SwaggerUI({ ... }) + # https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/ + # The settings are serialized with json.dumps(). If you need customized JS, use a + # string instead. The string must then contain valid JS and is passed unchanged. + 'SWAGGER_UI_SETTINGS': { + 'deepLinking': True, + }, + + # Initialize SwaggerUI with additional OAuth2 configuration. + # https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/ + 'SWAGGER_UI_OAUTH2_CONFIG': {}, + + # Dictionary of general configuration to pass to the Redoc.init({ ... }) + # https://redocly.com/docs/redoc/config/#functional-settings + # The settings are serialized with json.dumps(). If you need customized JS, use a + # string instead. The string must then contain valid JS and is passed unchanged. + 'REDOC_UI_SETTINGS': {}, + + # CDNs for swagger and redoc. You can change the version or even host your + # own depending on your requirements. For self-hosting, have a look at + # the sidecar option in the README. + 'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest', + 'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest/favicon-32x32.png', + 'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@latest', + + # Append OpenAPI objects to path and components in addition to the generated objects + 'APPEND_PATHS': {}, + 'APPEND_COMPONENTS': {}, + + + # Postprocessing functions that run at the end of schema generation. + # must satisfy interface result = hook(generator, request, public, result) + 'POSTPROCESSING_HOOKS': [ + 'drf_spectacular.hooks.postprocess_schema_enums' + ], + + # Preprocessing functions that run before schema generation. + # must satisfy interface result = hook(endpoints=result) where result + # is a list of Tuples (path, path_regex, method, callback). + # Example: 'drf_spectacular.hooks.preprocess_exclude_path_format' + 'PREPROCESSING_HOOKS': [], + + # Determines how operations should be sorted. If you intend to do sorting with a + # PREPROCESSING_HOOKS, be sure to disable this setting. If configured, the sorting + # is applied after the PREPROCESSING_HOOKS. Accepts either + # True (drf-spectacular's alpha-sorter), False, or a callable for sort's key arg. + 'SORT_OPERATIONS': True, + + # enum name overrides. dict with keys "YourEnum" and their choice values "field.choices" + # e.g. {'SomeEnum': ['A', 'B'], 'OtherEnum': 'import.path.to.choices'} + 'ENUM_NAME_OVERRIDES': {}, + + # Adds "blank" and "null" enum choices where appropriate. disable on client generation issues + 'ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE': True, + + # Add/Append a list of (``choice value`` - choice name) to the enum description string. + 'ENUM_GENERATE_CHOICE_DESCRIPTION': True, + + # Optional suffix for generated enum. + # e.g. {'ENUM_SUFFIX': "Type"} would produce an enum name 'StatusType'. + 'ENUM_SUFFIX': 'Enum', + + # function that returns a list of all classes that should be excluded from doc string extraction + 'GET_LIB_DOC_EXCLUDES': 'drf_spectacular.plumbing.get_lib_doc_excludes', + + # Function that returns a mocked request for view processing. For CLI usage + # original_request will be None. + # interface: request = build_mock_request(method, path, view, original_request, **kwargs) + 'GET_MOCK_REQUEST': 'drf_spectacular.plumbing.build_mock_request', + + # Camelize names like "operationId" and path parameter names + # Camelization of the operation schema itself requires the addition of + # 'drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields' + # to POSTPROCESSING_HOOKS. Please note that the hook depends on + # ``djangorestframework_camel_case``, while CAMELIZE_NAMES itself does not. + 'CAMELIZE_NAMES': False, + + # Changes the location of the action/method on the generated OperationId. For example, + # "POST": "group_person_list", "group_person_create" + # "PRE": "list_group_person", "create_group_person" + 'OPERATION_ID_METHOD_POSITION': 'POST', + + # Determines if and how free-form 'additionalProperties' should be emitted in the schema. Some + # code generator targets are sensitive to this. None disables generic 'additionalProperties'. + # allowed values are 'dict', 'bool', None + 'GENERIC_ADDITIONAL_PROPERTIES': 'dict', + + # Path converter schema overrides (e.g. ). Can be used to either modify default + # behavior or provide a schema for custom converters registered with register_converter(...). + # Takes converter labels as keys and either basic python types, OpenApiType, or raw schemas + # as values. Example: {'aint': OpenApiTypes.INT, 'bint': str, 'cint': {'type': ...}} + 'PATH_CONVERTER_OVERRIDES': {}, + + # Determines whether operation parameters should be sorted alphanumerically or just in + # the order they arrived. Accepts either True, False, or a callable for sort's key arg. + 'SORT_OPERATION_PARAMETERS': True, + + # @extend_schema allows to specify status codes besides 200. This functionality is usually used + # to describe error responses, which rarely make use of list mechanics. Therefore, we suppress + # listing (pagination and filtering) on non-2XX status codes by default. Toggle this to enable + # list responses with ListSerializers/many=True irrespective of the status code. + 'ENABLE_LIST_MECHANICS_ON_NON_2XX': False, + + # This setting allows you to deviate from the default manager by accessing a different model + # property. We use "objects" by default for compatibility reasons. Using "_default_manager" + # will likely fix most issues, though you are free to choose any name. + "DEFAULT_QUERY_MANAGER": 'objects', + + # Controls which authentication methods are exposed in the schema. If not None, will hide + # authentication classes that are not contained in the whitelist. Use full import paths + # like ['rest_framework.authentication.TokenAuthentication', ...]. + # Empty list ([]) will hide all authentication methods. The default None will show all. + 'AUTHENTICATION_WHITELIST': None, + # Controls which parsers are exposed in the schema. Works analog to AUTHENTICATION_WHITELIST. + # List of allowed parsers or None to allow all. + 'PARSER_WHITELIST': None, + # Controls which renderers are exposed in the schema. Works analog to AUTHENTICATION_WHITELIST. + # rest_framework.renderers.BrowsableAPIRenderer is ignored by default if whitelist is None + 'RENDERER_WHITELIST': None, + + # Option for turning off error and warn messages + 'DISABLE_ERRORS_AND_WARNINGS': False, + + # Runs exemplary schema generation and emits warnings as part of "./manage.py check --deploy" + 'ENABLE_DJANGO_DEPLOY_CHECK': True, + + # General schema metadata. Refer to spec for valid inputs + # https://spec.openapis.org/oas/v3.0.3#openapi-object + 'TITLE': 'e-Tržnice API', + 'DESCRIPTION': 'This is the API documentation for e-Tržnice.', + 'TOS': None, + # Optional: MAY contain "name", "url", "email" + 'CONTACT': {}, + # Optional: MUST contain "name", MAY contain URL + + 'LICENSE': {}, + # Statically set schema version. May also be an empty string. When used together with + # view versioning, will become '0.0.0 (v2)' for 'v2' versioned requests. + # Set VERSION to None if only the request version should be rendered. + 'VERSION': '1.0.0', + # Optional list of servers. + # Each entry MUST contain "url", MAY contain "description", "variables" + # e.g. [{'url': 'https://example.com/v1', 'description': 'Text'}, ...] + 'SERVERS': [], + # Tags defined in the global scope + 'TAGS': [], + # Optional: List of OpenAPI 3.1 webhooks. Each entry should be an import path to an + # OpenApiWebhook instance. + 'WEBHOOKS': [], + # Optional: MUST contain 'url', may contain "description" + 'EXTERNAL_DOCS': {}, + + # Arbitrary specification extensions attached to the schema's info object. + # https://swagger.io/specification/#specification-extensions + 'EXTENSIONS_INFO': {}, + + # Arbitrary specification extensions attached to the schema's root object. + # https://swagger.io/specification/#specification-extensions + 'EXTENSIONS_ROOT': {}, + + # Oauth2 related settings. used for example by django-oauth2-toolkit. + # https://spec.openapis.org/oas/v3.0.3#oauth-flows-object + 'OAUTH2_FLOWS': [], + 'OAUTH2_AUTHORIZATION_URL': None, + 'OAUTH2_TOKEN_URL': None, + 'OAUTH2_REFRESH_URL': None, + 'OAUTH2_SCOPES': None, +} diff --git a/backend/trznice/urls.py b/backend/trznice/urls.py new file mode 100644 index 0000000..87fc6e1 --- /dev/null +++ b/backend/trznice/urls.py @@ -0,0 +1,59 @@ +""" +URL configuration for trznice project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.contrib.auth import views as auth_views +from django.conf.urls.static import static +from django.conf import settings + +from rest_framework import permissions + +from . import views + +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, + SpectacularRedocView, +) + +from .admin import custom_admin_site + + +urlpatterns = [ + + path('login/', auth_views.LoginView.as_view(), name='login'), # pro Swagger + path('logout/', auth_views.LogoutView.as_view(), name='logout'), + + # path('admin/', admin.site.urls), + path("admin/", custom_admin_site.urls), # override default admin + + path('api/account/', include('account.urls')), + path('api/booking/', include('booking.urls')), + path('api/', include('product.urls')), + path('api/service-tickets/', include('servicedesk.urls')), + path('api/commerce/', include('commerce.urls')), + path('api/config/', include('configuration.urls')), + + #rest framework, map of api + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path("swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), + + path('', views.index, name='index'), + path('test/email', views.test_mail, name='test-email') + +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/trznice/utils.py b/backend/trznice/utils.py new file mode 100644 index 0000000..d7f0427 --- /dev/null +++ b/backend/trznice/utils.py @@ -0,0 +1,12 @@ +from rest_framework.fields import DateTimeField +from datetime import datetime + + +def truncate_to_minutes(dt: datetime) -> datetime: + return dt.replace(second=0, microsecond=0) + + +class RoundedDateTimeField(DateTimeField): + def to_internal_value(self, value): + dt = super().to_internal_value(value) + return truncate_to_minutes(dt) \ No newline at end of file diff --git a/backend/trznice/views.py b/backend/trznice/views.py new file mode 100644 index 0000000..bd0922d --- /dev/null +++ b/backend/trznice/views.py @@ -0,0 +1,38 @@ +from django.shortcuts import render, redirect +from django.core.mail import send_mail +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +import json + +def index(request): + return render(request, "html/index.html", context={'user': request.user}) + + +@csrf_exempt +def test_mail(request): + if request.method != "POST": + return JsonResponse({"error": "Only POST allowed"}, status=405) + + try: + data = json.loads(request.body) + recipient = data.get("recipient") + if not recipient: + return JsonResponse({"error": "Missing recipient"}, status=400) + + send_mail( + subject="Test", + message="Django test mail", + from_email=None, # použije defaultní FROM_EMAIL ze settings + recipient_list=[recipient], + fail_silently=False, + ) + + return JsonResponse({"success": f"E-mail sent to {recipient}"}) + + except Exception as e: + import traceback + traceback.print_exc() # vypíše do konzole + return JsonResponse({"error": str(e)}, status=500) + + + diff --git a/backend/trznice/wsgi.py b/backend/trznice/wsgi.py new file mode 100644 index 0000000..8c7012c --- /dev/null +++ b/backend/trznice/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for trznice project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings') + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..81b8e8b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,117 @@ +services: + backend: + container_name: backend-e-trznice + build: + context: ./backend + dockerfile: Dockerfile + restart: always + env_file: + - ./backend/.env + networks: + - app_network + depends_on: + - db + - redis + volumes: + - static-data:/app/collectedstaticfiles + - media-data:/app/media + command: sh -c " + python manage.py check && + python manage.py collectstatic --clear --noinput --verbosity 3 && + python manage.py makemigrations --noinput && + python manage.py migrate --verbosity 3 --noinput && + gunicorn -k uvicorn.workers.UvicornWorker trznice.asgi:application --bind 0.0.0.0:8000" + ports: + - "8000:8000" + + db: + image: postgres:15-alpine + container_name: postgres-e-trznice + restart: always + env_file: + - ./backend/.env + volumes: + - db-data:/var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - app_network + + + redis: #extremly fast db, stores data in RAM memory + container_name: redis-e-trznice + image: redis:alpine + restart: always + env_file: + - ./backend/.env + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD} + volumes: + - redis-data:/data + expose: + - "6379" + networks: + - app_network + + celery: #task queue for handling asynchronous/hard tasks + container_name: celery-e-trznice + build: + context: ./backend + command: celery -A trznice worker --loglevel=info + volumes: + - ./backend:/code + env_file: + - ./backend/.env + depends_on: + - redis + - db + - backend + networks: + - app_network + + celery-beat: #periodic tasks scheduler + container_name: celery-beat-e-trznice + build: + context: ./backend + command: celery -A trznice beat --loglevel=info + volumes: + - ./backend:/code + env_file: + - ./backend/.env + depends_on: + - redis + - db + - backend + networks: + - app_network + +#end of backend services ----------------------- + + nginx: #web server, reverse proxy, serves static files + container_name: nginx-e-trznice + build: + context: ./frontend + dockerfile: Dockerfile.prod + env_file: + - ./frontend/.env + ports: + - 3000:80 + depends_on: + - backend + networks: + - app_network + volumes: + - static-data:/app/collectedstaticfiles # static (Django) + - media-data:/app/media # media (Django) + + +networks: + app_network: + driver: bridge + +volumes: + redis-data: + db-data: + static-data: + media-data: + diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f2e3d31 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.git \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..c0d9db8 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,16 @@ +# Step 1: Build React (Vite) app +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +# If package-lock.json exists, npm ci is faster and reproducible +RUN npm ci || npm install +COPY . . +ENV NODE_ENV=production +RUN npm run build + +# Step 2: Nginx runtime +FROM nginx:1.27-alpine +COPY ./nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e69de29 diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..cee1e2c --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..eb42cb6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..689c381 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4298 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@apidevtools/swagger-parser": "^10.0.2", + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/react-fontawesome": "^0.2.2", + "@mantine/core": "^8.2.3", + "@mantine/dates": "^8.2.3", + "@mantine/hooks": "^8.2.3", + "@tabler/icons-react": "^3.34.1", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.10.0", + "bootstrap": "^5.3.7", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dayjs": "^1.11.13", + "lodash": "^4.17.21", + "qrcode.react": "^4.2.0", + "react": "^19.1.0", + "react-bootstrap": "^2.10.10", + "react-dom": "^19.1.0", + "react-grid-layout": "^1.5.2", + "react-qr-code": "^2.0.18", + "react-router-dom": "^7.7.1", + "use-debounce": "^10.0.5" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "postcss": "^8.5.6", + "postcss-preset-mantine": "^1.18.0", + "postcss-simple-vars": "^7.0.1", + "vite": "^7.0.5" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.7.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz", + "integrity": "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.1.1.tgz", + "integrity": "sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "11.7.2", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.2", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz", + "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.2" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", + "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", + "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", + "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", + "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, + "node_modules/@mantine/core": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.2.3.tgz", + "integrity": "sha512-8vR1xAhNzL4g9/8fANhWpjFuguDbubMFo0sgw4WjO32x4PuBrmckGP9qwCopgYfWt8F49sXiVI8UDZG66gzkHA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.28", + "clsx": "^2.1.1", + "react-number-format": "^5.4.3", + "react-remove-scroll": "^2.6.2", + "react-textarea-autosize": "8.5.9", + "type-fest": "^4.27.0" + }, + "peerDependencies": { + "@mantine/hooks": "8.2.3", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/dates": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.2.3.tgz", + "integrity": "sha512-NSFhczcyIGfFK5VmNnHfepuoYOvl1RVwMumotnqbyoikPlWmTPwvLd7yeDAWHEoutqqmL5IKWvh1yyemwH8zPg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "@mantine/core": "8.2.3", + "@mantine/hooks": "8.2.3", + "dayjs": ">=1.0.0", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/hooks": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.2.3.tgz", + "integrity": "sha512-RBXAYqmxLk2DBIqN2DnWa9ShFEL1zpbQb0kgc8JIERmJyNwTjHKjHBDgX0jD7oeZjYfGh/g8du2MQEgF9BpsfQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.9.tgz", + "integrity": "sha512-2P5thfjfPy/np18e5wD4WPt8ydNXhij1jwA8oehxZTFqlgVMGXzcWKxTb4RtJrLFsqPO7RUQTiY8QJk0M4Vy2g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz", + "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz", + "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz", + "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz", + "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz", + "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz", + "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz", + "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz", + "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz", + "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz", + "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz", + "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz", + "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz", + "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz", + "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz", + "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", + "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz", + "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz", + "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz", + "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz", + "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tabler/icons": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.34.1.tgz", + "integrity": "sha512-9gTnUvd7Fd/DmQgr3MKY+oJLa1RfNsQo8c/ir3TJAWghOuZXodbtbVp0QBY2DxWuuvrSZFys0HEbv1CoiI5y6A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.34.1.tgz", + "integrity": "sha512-Ld6g0NqOO05kyyHsfU8h787PdHBm7cFmOycQSIrGp45XcXYDuOK2Bs0VC4T2FWSKZ6bx5g04imfzazf/nqtk1A==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.34.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", + "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.19", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bootstrap": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", + "integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.179", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", + "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", + "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.30.1", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-mixins": { + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/postcss-mixins/-/postcss-mixins-12.1.2.tgz", + "integrity": "sha512-90pSxmZVfbX9e5xCv7tI5RV1mnjdf16y89CJKbf/hD7GyOz1FCxcYMl8ZYA8Hc56dbApTKKmU9HfvgfWdCxlwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-js": "^4.0.1", + "postcss-simple-vars": "^7.0.1", + "sugarss": "^5.0.0", + "tinyglobby": "^0.2.14" + }, + "engines": { + "node": "^20.0 || ^22.0 || >=24.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-7.0.2.tgz", + "integrity": "sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-preset-mantine": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/postcss-preset-mantine/-/postcss-preset-mantine-1.18.0.tgz", + "integrity": "sha512-sP6/s1oC7cOtBdl4mw/IRKmKvYTuzpRrH/vT6v9enMU/EQEQ31eQnHcWtFghOXLH87AAthjL/Q75rLmin1oZoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-mixins": "^12.0.0", + "postcss-nested": "^7.0.2" + }, + "peerDependencies": { + "postcss": ">=8.0.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-simple-vars": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-simple-vars/-/postcss-simple-vars-7.0.1.tgz", + "integrity": "sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.1" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==", + "license": "MIT" + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-bootstrap": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", + "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-draggable": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.2.tgz", + "integrity": "sha512-vT7xmQqszTT+sQw/LfisrEO4le1EPNnSEMVHy6sBZyzS3yGkMywdOd+5iEFFwQwt0NSaGkxuRmYwa1JsP6OJdw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "node_modules/react-number-format": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", + "integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==", + "license": "MIT", + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-qr-code": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz", + "integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", + "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + }, + "peerDependencies": { + "react": ">= 16.3" + } + }, + "node_modules/react-router": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.7.1.tgz", + "integrity": "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.7.1.tgz", + "integrity": "sha512-bavdk2BA5r3MYalGKZ01u8PGuDBloQmzpBZVhDLrOOv1N943Wq6dcM9GhB3x8b7AbqPMEezauv4PeGkAJfy7FQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.7.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", + "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.44.2", + "@rollup/rollup-android-arm64": "4.44.2", + "@rollup/rollup-darwin-arm64": "4.44.2", + "@rollup/rollup-darwin-x64": "4.44.2", + "@rollup/rollup-freebsd-arm64": "4.44.2", + "@rollup/rollup-freebsd-x64": "4.44.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", + "@rollup/rollup-linux-arm-musleabihf": "4.44.2", + "@rollup/rollup-linux-arm64-gnu": "4.44.2", + "@rollup/rollup-linux-arm64-musl": "4.44.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-musl": "4.44.2", + "@rollup/rollup-linux-s390x-gnu": "4.44.2", + "@rollup/rollup-linux-x64-gnu": "4.44.2", + "@rollup/rollup-linux-x64-musl": "4.44.2", + "@rollup/rollup-win32-arm64-msvc": "4.44.2", + "@rollup/rollup-win32-ia32-msvc": "4.44.2", + "@rollup/rollup-win32-x64-msvc": "4.44.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sugarss": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-5.0.1.tgz", + "integrity": "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.3.3" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-debounce": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.5.tgz", + "integrity": "sha512-Q76E3lnIV+4YT9AHcrHEHYmAd9LKwUAbPXDm7FlqVGDHiSOhX3RDjT8dm0AxbJup6WgOb1YEcKyCr11kBJR5KQ==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", + "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9cb9aa6 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,51 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@apidevtools/swagger-parser": "^10.0.2", + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/react-fontawesome": "^0.2.2", + "@mantine/core": "^8.2.3", + "@mantine/dates": "^8.2.3", + "@mantine/hooks": "^8.2.3", + "@tabler/icons-react": "^3.34.1", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.10.0", + "bootstrap": "^5.3.7", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dayjs": "^1.11.13", + "lodash": "^4.17.21", + "qrcode.react": "^4.2.0", + "react": "^19.1.0", + "react-bootstrap": "^2.10.10", + "react-dom": "^19.1.0", + "react-grid-layout": "^1.5.2", + "react-qr-code": "^2.0.18", + "react-router-dom": "^7.7.1", + "use-debounce": "^10.0.5" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "postcss": "^8.5.6", + "postcss-preset-mantine": "^1.18.0", + "postcss-simple-vars": "^7.0.1", + "vite": "^7.0.5" + } +} diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs new file mode 100644 index 0000000..c759b74 --- /dev/null +++ b/frontend/postcss.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: { + 'postcss-preset-mantine': {}, + 'postcss-simple-vars': { + variables: { + 'mantine-breakpoint-xs': '36em', + 'mantine-breakpoint-sm': '48em', + 'mantine-breakpoint-md': '62em', + 'mantine-breakpoint-lg': '75em', + 'mantine-breakpoint-xl': '88em', + }, + }, + }, +}; \ No newline at end of file diff --git a/frontend/public/img/bg.png b/frontend/public/img/bg.png new file mode 100644 index 0000000000000000000000000000000000000000..cc6a318774fcb62df745703547432790162e3762 GIT binary patch literal 389241 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%0Dme?R1Y-$Fb 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