From d160410cd90fefca19881959ef4a0c02e78ec9ae Mon Sep 17 00:00:00 2001 From: muhammadvadud Date: Fri, 19 Sep 2025 15:19:32 +0500 Subject: [PATCH] first commit --- .cruft.json | 28 + .devcontainer/devcontainer.json | 19 + .dockerignore | 2 + .env.example | 69 ++ .flake8 | 3 + .gitignore | 158 ++++ Jenkinsfile | 177 ++++ Makefile | 43 + README.MD | 2 + config/__init__.py | 3 + config/asgi.py | 23 + config/celery.py | 16 + config/conf/__init__.py | 12 + config/conf/apps.py | 22 + config/conf/cache.py | 26 + config/conf/celery.py | 7 + config/conf/channels.py | 8 + config/conf/ckeditor.py | 147 ++++ config/conf/cron.py | 0 config/conf/jwt.py | 36 + config/conf/logs.py | 29 + config/conf/modules.py | 1 + config/conf/navigation.py | 31 + config/conf/rest_framework.py | 9 + config/conf/spectacular.py | 31 + config/conf/storage.py | 23 + config/conf/unfold.py | 95 +++ config/env.py | 29 + config/settings/__init__.py | 0 config/settings/common.py | 174 ++++ config/settings/local.py | 11 + config/settings/production.py | 6 + config/settings/test.py | 15 + config/urls.py | 46 ++ config/wsgi.py | 8 + core/__init__.py | 0 core/apps/__init__.py | 0 core/apps/accounts/__init__.py | 0 core/apps/accounts/admin/__init__.py | 4 + core/apps/accounts/admin/core.py | 18 + core/apps/accounts/admin/likes.py | 12 + core/apps/accounts/admin/participant.py | 12 + core/apps/accounts/admin/user.py | 53 ++ core/apps/accounts/apps.py | 9 + core/apps/accounts/choices/__init__.py | 1 + core/apps/accounts/choices/user.py | 12 + core/apps/accounts/filters/__init__.py | 2 + core/apps/accounts/filters/likes.py | 13 + core/apps/accounts/filters/participant.py | 13 + core/apps/accounts/forms/__init__.py | 2 + core/apps/accounts/forms/likes.py | 10 + core/apps/accounts/forms/participant.py | 10 + core/apps/accounts/managers/__init__.py | 1 + core/apps/accounts/managers/user.py | 20 + core/apps/accounts/migrations/0001_initial.py | 103 +++ core/apps/accounts/migrations/0002_initial.py | 42 + core/apps/accounts/migrations/__init__.py | 0 core/apps/accounts/models/__init__.py | 5 + core/apps/accounts/models/likes.py | 27 + core/apps/accounts/models/participant.py | 53 ++ core/apps/accounts/models/reset_token.py | 15 + core/apps/accounts/models/user.py | 33 + core/apps/accounts/permissions/__init__.py | 2 + core/apps/accounts/permissions/likes.py | 12 + core/apps/accounts/permissions/participant.py | 12 + core/apps/accounts/seeder/__init__.py | 1 + core/apps/accounts/seeder/core.py | 10 + core/apps/accounts/serializers/__init__.py | 6 + core/apps/accounts/serializers/auth.py | 60 ++ .../accounts/serializers/change_password.py | 6 + .../accounts/serializers/likes/__init__.py | 1 + core/apps/accounts/serializers/likes/likes.py | 28 + .../serializers/participant/__init__.py | 1 + .../serializers/participant/participant.py | 28 + .../apps/accounts/serializers/set_password.py | 6 + core/apps/accounts/serializers/user.py | 23 + core/apps/accounts/signals/__init__.py | 3 + core/apps/accounts/signals/likes.py | 8 + core/apps/accounts/signals/participant.py | 8 + core/apps/accounts/signals/user.py | 10 + core/apps/accounts/tasks/__init__.py | 1 + core/apps/accounts/tasks/sms.py | 28 + core/apps/accounts/test/__init__.py | 0 core/apps/accounts/test/test_auth.py | 116 +++ .../accounts/test/test_change_password.py | 58 ++ core/apps/accounts/tests/__init__.py | 2 + core/apps/accounts/tests/test_likes.py | 47 ++ core/apps/accounts/tests/test_participant.py | 47 ++ core/apps/accounts/translation/__init__.py | 2 + core/apps/accounts/translation/likes.py | 8 + core/apps/accounts/translation/participant.py | 8 + core/apps/accounts/urls.py | 25 + core/apps/accounts/validators/__init__.py | 2 + core/apps/accounts/validators/likes.py | 8 + core/apps/accounts/validators/participant.py | 8 + core/apps/accounts/views/__init__.py | 3 + core/apps/accounts/views/auth.py | 209 +++++ core/apps/accounts/views/likes.py | 21 + core/apps/accounts/views/participant.py | 25 + core/apps/api/__init__.py | 0 core/apps/api/admin/__init__.py | 0 core/apps/api/apps.py | 6 + core/apps/api/migrations/__init__.py | 0 core/apps/api/models/__init__.py | 0 core/apps/api/serializers/__init__.py | 0 core/apps/api/tests/__init__.py | 0 core/apps/api/urls.py | 9 + core/apps/api/views/__init__.py | 0 core/apps/blog/__init__.py | 0 core/apps/blog/admin/__init__.py | 1 + core/apps/blog/admin/post.py | 36 + core/apps/blog/apps.py | 6 + core/apps/blog/filters/__init__.py | 1 + core/apps/blog/filters/post.py | 43 + core/apps/blog/forms/__init__.py | 1 + core/apps/blog/forms/post.py | 31 + core/apps/blog/migrations/0001_initial.py | 78 ++ core/apps/blog/migrations/__init__.py | 0 core/apps/blog/models/__init__.py | 1 + core/apps/blog/models/post.py | 92 +++ core/apps/blog/permissions/__init__.py | 1 + core/apps/blog/permissions/post.py | 45 + core/apps/blog/serializers/__init__.py | 1 + core/apps/blog/serializers/post/__init__.py | 3 + core/apps/blog/serializers/post/category.py | 28 + core/apps/blog/serializers/post/post.py | 29 + core/apps/blog/serializers/post/tag.py | 28 + core/apps/blog/signals/__init__.py | 1 + core/apps/blog/signals/post.py | 20 + core/apps/blog/tests/__init__.py | 1 + core/apps/blog/tests/test_post.py | 173 ++++ core/apps/blog/translation/__init__.py | 1 + core/apps/blog/translation/post.py | 23 + core/apps/blog/urls.py | 9 + core/apps/blog/validators/__init__.py | 1 + core/apps/blog/validators/post.py | 29 + core/apps/blog/views/__init__.py | 1 + core/apps/blog/views/post.py | 59 ++ core/apps/logs/.gitignore | 2 + core/apps/payments/__init__.py | 0 core/apps/payments/admin/__init__.py | 0 core/apps/payments/apps.py | 6 + core/apps/payments/migrations/__init__.py | 0 core/apps/payments/models/__init__.py | 0 core/apps/payments/serializers/__init__.py | 0 core/apps/payments/tests/__init__.py | 0 core/apps/payments/urls.py | 9 + core/apps/payments/views/__init__.py | 0 core/apps/shared/__init__.py | 0 core/apps/shared/admin/__init__.py | 1 + core/apps/shared/admin/settings.py | 20 + core/apps/shared/apps.py | 6 + core/apps/shared/enums/__init__.py | 17 + core/apps/shared/migrations/0001_initial.py | 46 ++ core/apps/shared/migrations/__init__.py | 0 core/apps/shared/models/__init__.py | 1 + core/apps/shared/models/settings.py | 31 + core/apps/shared/serializers/__init__.py | 1 + .../shared/serializers/settings/__init__.py | 1 + .../shared/serializers/settings/settings.py | 7 + core/apps/shared/tests/__init__.py | 1 + core/apps/shared/tests/test_settings.py | 16 + core/apps/shared/urls.py | 11 + core/apps/shared/utils/__init__.py | 1 + core/apps/shared/utils/settings.py | 17 + core/apps/shared/views/__init__.py | 1 + core/apps/shared/views/settings.py | 53 ++ core/apps/tickets/__init__.py | 0 core/apps/tickets/admin/__init__.py | 4 + core/apps/tickets/admin/extra_services.py | 23 + core/apps/tickets/admin/hotel.py | 20 + core/apps/tickets/admin/tariff.py | 12 + core/apps/tickets/admin/tickets.py | 80 ++ core/apps/tickets/apps.py | 7 + core/apps/tickets/filters/__init__.py | 4 + core/apps/tickets/filters/extra_services.py | 23 + core/apps/tickets/filters/hotel.py | 14 + core/apps/tickets/filters/tariff.py | 13 + core/apps/tickets/filters/tickets.py | 23 + core/apps/tickets/forms/__init__.py | 4 + core/apps/tickets/forms/extra_services.py | 17 + core/apps/tickets/forms/hotel.py | 10 + core/apps/tickets/forms/paid_services.py | 10 + core/apps/tickets/forms/tariff.py | 10 + core/apps/tickets/forms/tickets.py | 17 + core/apps/tickets/migrations/0001_initial.py | 298 +++++++ core/apps/tickets/migrations/__init__.py | 0 core/apps/tickets/models/__init__.py | 4 + core/apps/tickets/models/extra_services.py | 49 ++ core/apps/tickets/models/hotel.py | 60 ++ core/apps/tickets/models/tariff.py | 27 + core/apps/tickets/models/tickets.py | 322 ++++++++ core/apps/tickets/permissions/__init__.py | 4 + .../tickets/permissions/extra_services.py | 23 + core/apps/tickets/permissions/hotel.py | 12 + core/apps/tickets/permissions/tariff.py | 12 + core/apps/tickets/permissions/tickets.py | 23 + core/apps/tickets/serializers/__init__.py | 4 + .../serializers/extra_services/__init__.py | 2 + .../extra_services/extra_services.py | 28 + .../extra_services/paid_services.py | 28 + .../tickets/serializers/hotel/__init__.py | 1 + core/apps/tickets/serializers/hotel/hotel.py | 28 + .../tickets/serializers/tariff/__init__.py | 1 + .../apps/tickets/serializers/tariff/tariff.py | 28 + .../tickets/serializers/tickets/__init__.py | 2 + .../serializers/tickets/ticketorder.py | 28 + .../tickets/serializers/tickets/tickets.py | 132 +++ core/apps/tickets/signals/__init__.py | 4 + core/apps/tickets/signals/extra_services.py | 12 + core/apps/tickets/signals/hotel.py | 8 + core/apps/tickets/signals/tariff.py | 8 + core/apps/tickets/signals/tickets.py | 12 + core/apps/tickets/tests/__init__.py | 5 + .../apps/tickets/tests/test_extra_services.py | 89 ++ core/apps/tickets/tests/test_hotel.py | 47 ++ core/apps/tickets/tests/test_tariff.py | 47 ++ core/apps/tickets/tests/test_tickets.py | 89 ++ core/apps/tickets/translation/__init__.py | 4 + .../tickets/translation/extra_services.py | 13 + core/apps/tickets/translation/hotel.py | 8 + core/apps/tickets/translation/tariff.py | 8 + core/apps/tickets/translation/tickets.py | 13 + core/apps/tickets/urls.py | 15 + core/apps/tickets/validators/__init__.py | 4 + .../apps/tickets/validators/extra_services.py | 15 + core/apps/tickets/validators/hotel.py | 8 + core/apps/tickets/validators/tariff.py | 8 + core/apps/tickets/validators/tickets.py | 15 + core/apps/tickets/views/__init__.py | 4 + core/apps/tickets/views/extra_services.py | 42 + core/apps/tickets/views/hotel.py | 21 + core/apps/tickets/views/tariff.py | 21 + core/apps/tickets/views/tickets.py | 42 + core/services/__init__.py | 3 + core/services/otp.py | 136 +++ core/services/sms.py | 63 ++ core/services/user.py | 64 ++ core/utils/__init__.py | 3 + core/utils/cache.py | 18 + core/utils/console.py | 79 ++ core/utils/core.py | 6 + core/utils/storage.py | 33 + docker-compose.prod.yml | 59 ++ docker-compose.test.yml | 44 + docker-compose.yml | 56 ++ docker/Dockerfile.nginx | 3 + docker/Dockerfile.web | 22 + jst.json | 8 + k8s/config.yaml | 60 ++ k8s/db-deployment.yaml | 33 + k8s/db-service.yaml | 11 + k8s/django-deployment.yaml | 33 + k8s/django-service.yaml | 11 + k8s/nginx-deployment.yaml | 39 + k8s/nginx-service.yaml | 12 + k8s/volume.yaml | 35 + manage.py | 24 + pyproject.toml | 27 + requirements.txt | 46 ++ resources/.gitignore | 1 + resources/layout/.flake8 | 3 + resources/layout/Dockerfile.alpine | 13 + resources/layout/Dockerfile.nginx | 3 + resources/layout/mypy.ini | 5 + resources/layout/nginx.conf | 54 ++ resources/locale/.gitkeep | 0 resources/locale/en/LC_MESSAGES/django.po | 49 ++ resources/locale/ru/LC_MESSAGES/django.po | 51 ++ resources/locale/uz/LC_MESSAGES/django.po | 55 ++ resources/logs/.gitignore | 2 + resources/media/.gitignore | 2 + resources/scripts/backup.sh | 5 + resources/scripts/entrypoint-server.sh | 17 + resources/scripts/entrypoint.sh | 18 + resources/static/css/app.css | 0 resources/static/css/error.css | 109 +++ resources/static/css/input.css | 3 + resources/static/css/jazzmin.css | 5 + resources/static/css/output.css | 772 ++++++++++++++++++ resources/static/images/default_avatar.jpg | Bin 0 -> 5363 bytes resources/static/images/logo.png | Bin 0 -> 39362 bytes resources/static/js/alpine.js | 9 + resources/static/js/app.js | 1 + resources/static/js/counter.js | 3 + resources/static/js/customer.js | 49 ++ resources/static/js/vite-refresh.js | 9 + .../static/vite/assets/appCss-w40geAFS.js | 0 .../static/vite/assets/appJs-YH6iAcjX.js | 6 + .../static/vite/assets/outCss-r8J2MRAR.css | 1 + resources/static/vite/manifest.json | 17 + resources/templates/400.html | 148 ++++ resources/templates/401.html | 147 ++++ resources/templates/403.html | 145 ++++ resources/templates/404.html | 147 ++++ resources/templates/405.html | 145 ++++ resources/templates/408.html | 145 ++++ resources/templates/500.html | 145 ++++ resources/templates/502.html | 145 ++++ resources/templates/503.html | 145 ++++ resources/templates/504.html | 145 ++++ resources/templates/admin/index.html | 40 + resources/templates/registration/login.html | 15 + resources/templates/user/home.html | 48 ++ stack.yaml | 162 ++++ 305 files changed, 9509 insertions(+) create mode 100644 .cruft.json create mode 100644 .devcontainer/devcontainer.json create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 Jenkinsfile create mode 100644 Makefile create mode 100644 README.MD create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/celery.py create mode 100644 config/conf/__init__.py create mode 100644 config/conf/apps.py create mode 100644 config/conf/cache.py create mode 100644 config/conf/celery.py create mode 100644 config/conf/channels.py create mode 100644 config/conf/ckeditor.py create mode 100644 config/conf/cron.py create mode 100644 config/conf/jwt.py create mode 100644 config/conf/logs.py create mode 100644 config/conf/modules.py create mode 100644 config/conf/navigation.py create mode 100644 config/conf/rest_framework.py create mode 100644 config/conf/spectacular.py create mode 100644 config/conf/storage.py create mode 100644 config/conf/unfold.py create mode 100644 config/env.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/common.py create mode 100644 config/settings/local.py create mode 100644 config/settings/production.py create mode 100644 config/settings/test.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 core/__init__.py create mode 100644 core/apps/__init__.py create mode 100644 core/apps/accounts/__init__.py create mode 100644 core/apps/accounts/admin/__init__.py create mode 100644 core/apps/accounts/admin/core.py create mode 100644 core/apps/accounts/admin/likes.py create mode 100644 core/apps/accounts/admin/participant.py create mode 100644 core/apps/accounts/admin/user.py create mode 100644 core/apps/accounts/apps.py create mode 100644 core/apps/accounts/choices/__init__.py create mode 100644 core/apps/accounts/choices/user.py create mode 100644 core/apps/accounts/filters/__init__.py create mode 100644 core/apps/accounts/filters/likes.py create mode 100644 core/apps/accounts/filters/participant.py create mode 100644 core/apps/accounts/forms/__init__.py create mode 100644 core/apps/accounts/forms/likes.py create mode 100644 core/apps/accounts/forms/participant.py create mode 100644 core/apps/accounts/managers/__init__.py create mode 100644 core/apps/accounts/managers/user.py create mode 100644 core/apps/accounts/migrations/0001_initial.py create mode 100644 core/apps/accounts/migrations/0002_initial.py create mode 100644 core/apps/accounts/migrations/__init__.py create mode 100644 core/apps/accounts/models/__init__.py create mode 100644 core/apps/accounts/models/likes.py create mode 100644 core/apps/accounts/models/participant.py create mode 100644 core/apps/accounts/models/reset_token.py create mode 100644 core/apps/accounts/models/user.py create mode 100644 core/apps/accounts/permissions/__init__.py create mode 100644 core/apps/accounts/permissions/likes.py create mode 100644 core/apps/accounts/permissions/participant.py create mode 100644 core/apps/accounts/seeder/__init__.py create mode 100644 core/apps/accounts/seeder/core.py create mode 100644 core/apps/accounts/serializers/__init__.py create mode 100644 core/apps/accounts/serializers/auth.py create mode 100644 core/apps/accounts/serializers/change_password.py create mode 100644 core/apps/accounts/serializers/likes/__init__.py create mode 100644 core/apps/accounts/serializers/likes/likes.py create mode 100644 core/apps/accounts/serializers/participant/__init__.py create mode 100644 core/apps/accounts/serializers/participant/participant.py create mode 100644 core/apps/accounts/serializers/set_password.py create mode 100644 core/apps/accounts/serializers/user.py create mode 100644 core/apps/accounts/signals/__init__.py create mode 100644 core/apps/accounts/signals/likes.py create mode 100644 core/apps/accounts/signals/participant.py create mode 100644 core/apps/accounts/signals/user.py create mode 100644 core/apps/accounts/tasks/__init__.py create mode 100644 core/apps/accounts/tasks/sms.py create mode 100644 core/apps/accounts/test/__init__.py create mode 100644 core/apps/accounts/test/test_auth.py create mode 100644 core/apps/accounts/test/test_change_password.py create mode 100644 core/apps/accounts/tests/__init__.py create mode 100644 core/apps/accounts/tests/test_likes.py create mode 100644 core/apps/accounts/tests/test_participant.py create mode 100644 core/apps/accounts/translation/__init__.py create mode 100644 core/apps/accounts/translation/likes.py create mode 100644 core/apps/accounts/translation/participant.py create mode 100644 core/apps/accounts/urls.py create mode 100644 core/apps/accounts/validators/__init__.py create mode 100644 core/apps/accounts/validators/likes.py create mode 100644 core/apps/accounts/validators/participant.py create mode 100644 core/apps/accounts/views/__init__.py create mode 100644 core/apps/accounts/views/auth.py create mode 100644 core/apps/accounts/views/likes.py create mode 100644 core/apps/accounts/views/participant.py create mode 100644 core/apps/api/__init__.py create mode 100644 core/apps/api/admin/__init__.py create mode 100644 core/apps/api/apps.py create mode 100644 core/apps/api/migrations/__init__.py create mode 100644 core/apps/api/models/__init__.py create mode 100644 core/apps/api/serializers/__init__.py create mode 100644 core/apps/api/tests/__init__.py create mode 100644 core/apps/api/urls.py create mode 100644 core/apps/api/views/__init__.py create mode 100644 core/apps/blog/__init__.py create mode 100644 core/apps/blog/admin/__init__.py create mode 100644 core/apps/blog/admin/post.py create mode 100644 core/apps/blog/apps.py create mode 100644 core/apps/blog/filters/__init__.py create mode 100644 core/apps/blog/filters/post.py create mode 100644 core/apps/blog/forms/__init__.py create mode 100644 core/apps/blog/forms/post.py create mode 100644 core/apps/blog/migrations/0001_initial.py create mode 100644 core/apps/blog/migrations/__init__.py create mode 100644 core/apps/blog/models/__init__.py create mode 100644 core/apps/blog/models/post.py create mode 100644 core/apps/blog/permissions/__init__.py create mode 100644 core/apps/blog/permissions/post.py create mode 100644 core/apps/blog/serializers/__init__.py create mode 100644 core/apps/blog/serializers/post/__init__.py create mode 100644 core/apps/blog/serializers/post/category.py create mode 100644 core/apps/blog/serializers/post/post.py create mode 100644 core/apps/blog/serializers/post/tag.py create mode 100644 core/apps/blog/signals/__init__.py create mode 100644 core/apps/blog/signals/post.py create mode 100644 core/apps/blog/tests/__init__.py create mode 100644 core/apps/blog/tests/test_post.py create mode 100644 core/apps/blog/translation/__init__.py create mode 100644 core/apps/blog/translation/post.py create mode 100644 core/apps/blog/urls.py create mode 100644 core/apps/blog/validators/__init__.py create mode 100644 core/apps/blog/validators/post.py create mode 100644 core/apps/blog/views/__init__.py create mode 100644 core/apps/blog/views/post.py create mode 100644 core/apps/logs/.gitignore create mode 100644 core/apps/payments/__init__.py create mode 100644 core/apps/payments/admin/__init__.py create mode 100644 core/apps/payments/apps.py create mode 100644 core/apps/payments/migrations/__init__.py create mode 100644 core/apps/payments/models/__init__.py create mode 100644 core/apps/payments/serializers/__init__.py create mode 100644 core/apps/payments/tests/__init__.py create mode 100644 core/apps/payments/urls.py create mode 100644 core/apps/payments/views/__init__.py create mode 100644 core/apps/shared/__init__.py create mode 100644 core/apps/shared/admin/__init__.py create mode 100644 core/apps/shared/admin/settings.py create mode 100644 core/apps/shared/apps.py create mode 100644 core/apps/shared/enums/__init__.py create mode 100644 core/apps/shared/migrations/0001_initial.py create mode 100644 core/apps/shared/migrations/__init__.py create mode 100644 core/apps/shared/models/__init__.py create mode 100644 core/apps/shared/models/settings.py create mode 100644 core/apps/shared/serializers/__init__.py create mode 100644 core/apps/shared/serializers/settings/__init__.py create mode 100644 core/apps/shared/serializers/settings/settings.py create mode 100644 core/apps/shared/tests/__init__.py create mode 100644 core/apps/shared/tests/test_settings.py create mode 100644 core/apps/shared/urls.py create mode 100644 core/apps/shared/utils/__init__.py create mode 100644 core/apps/shared/utils/settings.py create mode 100644 core/apps/shared/views/__init__.py create mode 100644 core/apps/shared/views/settings.py create mode 100644 core/apps/tickets/__init__.py create mode 100644 core/apps/tickets/admin/__init__.py create mode 100644 core/apps/tickets/admin/extra_services.py create mode 100644 core/apps/tickets/admin/hotel.py create mode 100644 core/apps/tickets/admin/tariff.py create mode 100644 core/apps/tickets/admin/tickets.py create mode 100644 core/apps/tickets/apps.py create mode 100644 core/apps/tickets/filters/__init__.py create mode 100644 core/apps/tickets/filters/extra_services.py create mode 100644 core/apps/tickets/filters/hotel.py create mode 100644 core/apps/tickets/filters/tariff.py create mode 100644 core/apps/tickets/filters/tickets.py create mode 100644 core/apps/tickets/forms/__init__.py create mode 100644 core/apps/tickets/forms/extra_services.py create mode 100644 core/apps/tickets/forms/hotel.py create mode 100644 core/apps/tickets/forms/paid_services.py create mode 100644 core/apps/tickets/forms/tariff.py create mode 100644 core/apps/tickets/forms/tickets.py create mode 100644 core/apps/tickets/migrations/0001_initial.py create mode 100644 core/apps/tickets/migrations/__init__.py create mode 100644 core/apps/tickets/models/__init__.py create mode 100644 core/apps/tickets/models/extra_services.py create mode 100644 core/apps/tickets/models/hotel.py create mode 100644 core/apps/tickets/models/tariff.py create mode 100644 core/apps/tickets/models/tickets.py create mode 100644 core/apps/tickets/permissions/__init__.py create mode 100644 core/apps/tickets/permissions/extra_services.py create mode 100644 core/apps/tickets/permissions/hotel.py create mode 100644 core/apps/tickets/permissions/tariff.py create mode 100644 core/apps/tickets/permissions/tickets.py create mode 100644 core/apps/tickets/serializers/__init__.py create mode 100644 core/apps/tickets/serializers/extra_services/__init__.py create mode 100644 core/apps/tickets/serializers/extra_services/extra_services.py create mode 100644 core/apps/tickets/serializers/extra_services/paid_services.py create mode 100644 core/apps/tickets/serializers/hotel/__init__.py create mode 100644 core/apps/tickets/serializers/hotel/hotel.py create mode 100644 core/apps/tickets/serializers/tariff/__init__.py create mode 100644 core/apps/tickets/serializers/tariff/tariff.py create mode 100644 core/apps/tickets/serializers/tickets/__init__.py create mode 100644 core/apps/tickets/serializers/tickets/ticketorder.py create mode 100644 core/apps/tickets/serializers/tickets/tickets.py create mode 100644 core/apps/tickets/signals/__init__.py create mode 100644 core/apps/tickets/signals/extra_services.py create mode 100644 core/apps/tickets/signals/hotel.py create mode 100644 core/apps/tickets/signals/tariff.py create mode 100644 core/apps/tickets/signals/tickets.py create mode 100644 core/apps/tickets/tests/__init__.py create mode 100644 core/apps/tickets/tests/test_extra_services.py create mode 100644 core/apps/tickets/tests/test_hotel.py create mode 100644 core/apps/tickets/tests/test_tariff.py create mode 100644 core/apps/tickets/tests/test_tickets.py create mode 100644 core/apps/tickets/translation/__init__.py create mode 100644 core/apps/tickets/translation/extra_services.py create mode 100644 core/apps/tickets/translation/hotel.py create mode 100644 core/apps/tickets/translation/tariff.py create mode 100644 core/apps/tickets/translation/tickets.py create mode 100644 core/apps/tickets/urls.py create mode 100644 core/apps/tickets/validators/__init__.py create mode 100644 core/apps/tickets/validators/extra_services.py create mode 100644 core/apps/tickets/validators/hotel.py create mode 100644 core/apps/tickets/validators/tariff.py create mode 100644 core/apps/tickets/validators/tickets.py create mode 100644 core/apps/tickets/views/__init__.py create mode 100644 core/apps/tickets/views/extra_services.py create mode 100644 core/apps/tickets/views/hotel.py create mode 100644 core/apps/tickets/views/tariff.py create mode 100644 core/apps/tickets/views/tickets.py create mode 100644 core/services/__init__.py create mode 100644 core/services/otp.py create mode 100644 core/services/sms.py create mode 100644 core/services/user.py create mode 100644 core/utils/__init__.py create mode 100644 core/utils/cache.py create mode 100644 core/utils/console.py create mode 100644 core/utils/core.py create mode 100644 core/utils/storage.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.test.yml create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile.nginx create mode 100644 docker/Dockerfile.web create mode 100644 jst.json create mode 100644 k8s/config.yaml create mode 100644 k8s/db-deployment.yaml create mode 100644 k8s/db-service.yaml create mode 100644 k8s/django-deployment.yaml create mode 100644 k8s/django-service.yaml create mode 100644 k8s/nginx-deployment.yaml create mode 100644 k8s/nginx-service.yaml create mode 100644 k8s/volume.yaml create mode 100644 manage.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 resources/.gitignore create mode 100644 resources/layout/.flake8 create mode 100644 resources/layout/Dockerfile.alpine create mode 100644 resources/layout/Dockerfile.nginx create mode 100644 resources/layout/mypy.ini create mode 100644 resources/layout/nginx.conf create mode 100644 resources/locale/.gitkeep create mode 100644 resources/locale/en/LC_MESSAGES/django.po create mode 100644 resources/locale/ru/LC_MESSAGES/django.po create mode 100644 resources/locale/uz/LC_MESSAGES/django.po create mode 100644 resources/logs/.gitignore create mode 100644 resources/media/.gitignore create mode 100644 resources/scripts/backup.sh create mode 100644 resources/scripts/entrypoint-server.sh create mode 100644 resources/scripts/entrypoint.sh create mode 100644 resources/static/css/app.css create mode 100644 resources/static/css/error.css create mode 100644 resources/static/css/input.css create mode 100644 resources/static/css/jazzmin.css create mode 100644 resources/static/css/output.css create mode 100644 resources/static/images/default_avatar.jpg create mode 100644 resources/static/images/logo.png create mode 100644 resources/static/js/alpine.js create mode 100644 resources/static/js/app.js create mode 100644 resources/static/js/counter.js create mode 100644 resources/static/js/customer.js create mode 100644 resources/static/js/vite-refresh.js create mode 100644 resources/static/vite/assets/appCss-w40geAFS.js create mode 100644 resources/static/vite/assets/appJs-YH6iAcjX.js create mode 100644 resources/static/vite/assets/outCss-r8J2MRAR.css create mode 100644 resources/static/vite/manifest.json create mode 100644 resources/templates/400.html create mode 100644 resources/templates/401.html create mode 100644 resources/templates/403.html create mode 100644 resources/templates/404.html create mode 100644 resources/templates/405.html create mode 100644 resources/templates/408.html create mode 100644 resources/templates/500.html create mode 100644 resources/templates/502.html create mode 100644 resources/templates/503.html create mode 100644 resources/templates/504.html create mode 100644 resources/templates/admin/index.html create mode 100644 resources/templates/registration/login.html create mode 100644 resources/templates/user/home.html create mode 100644 stack.yaml diff --git a/.cruft.json b/.cruft.json new file mode 100644 index 0000000..6043c22 --- /dev/null +++ b/.cruft.json @@ -0,0 +1,28 @@ +{ + "template": "https://github.com/JscorpTech/django", + "commit": "8de1cd6688d4f4747415488a878bc6775b1c44b6", + "checkout": null, + "context": { + "cookiecutter": { + "cacheops": true, + "silk": true, + "storage": true, + "rosetta": true, + "channels": true, + "ckeditor": true, + "modeltranslation": true, + "parler": false, + "project_name": "simple-travel", + "settings_module": "config.settings.local", + "runner": "wsgi", + "script": "entrypoint.sh", + "key": "key", + "port": "8081", + "phone": "998888112309", + "password": "2309", + "max_line_length": "120", + "project_slug": "simple_travel" + } + }, + "directory": null +} \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..6c59faa --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "Existing Dockerfile", + "build": { + "context": "..", + "dockerfile": "../Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/python:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "PKief.material-icon-theme", + "zhuangtongfa.material-theme" + ] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd148e5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +venv/ +resources/staticfiles/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0db72d --- /dev/null +++ b/.env.example @@ -0,0 +1,69 @@ +# Django configs +DJANGO_SECRET_KEY=key +DEBUG=True +DJANGO_SETTINGS_MODULE=config.settings.local +COMMAND=sh ./resources/scripts/entrypoint.sh +PORT=8081 +#! debug | prod +PROJECT_ENV=debug +PROTOCOL_HTTPS=False + +# OTP configs +OTP_SIZE=4 +OTP_PROD=false +OTP_DEFAULT=1111 + +# Databse configs +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases +DB_ENGINE=django.db.backends.postgresql_psycopg2 +DB_NAME=django +DB_USER=postgres +DB_PASSWORD=2309 +DB_HOST=db +DB_PORT=5432 + +# Cache +CACHE_BACKEND=django.core.cache.backends.redis.RedisCache +REDIS_URL=redis://redis:6379 + + +CACHE_ENABLED=False + +CACHE_TIMEOUT=120 + +# Vite settings +VITE_LIVE=False +VITE_PORT=5173 +VITE_HOST=127.0.0.1 + +# Sms service +SMS_API_URL=https://notify.eskiz.uz/api +SMS_LOGIN=admin@gmail.com +SMS_PASSWORD=key + +# Addition + +ALLOWED_HOSTS=127.0.0.1,web +CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8081 + + +OTP_MODULE=core.services.otp +OTP_SERVICE=EskizService + + +# Storage +STORAGE_ID=id +STORAGE_KEY=key +STORAGE_URL=example.com + +#! MINIO | AWS | FILE +STORAGE_DEFAULT=FILE + +#! MINIO | AWS | STATIC +STORAGE_STATIC=STATIC + +STORAGE_BUCKET_MEDIA=name +STORAGE_BUCKET_STATIC=name +STORAGE_PATH=127.0.0.1:8081/bucket/ +STORAGE_PROTOCOL=http: + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..95f57f4 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 120 +ignore = E701, E704, W503 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e46f1af --- /dev/null +++ b/.gitignore @@ -0,0 +1,158 @@ +node_modules + +# OS ignores +*.DS_Store + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +poetry.lock + +# 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 + +# 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 + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__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 +.idea/ + +# Visual Studio Code +.vscode diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..2b3ed35 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,177 @@ +pipeline { + agent any + + environment { + PROD_ENV = "/opt/env/.env.simple_travel" + IMAGE_NAME = "simple_travel" + TEST_TAG = "test" + PROD_TAG = "latest" + CONTAINER_DB = "simple_travel_db_test" + CONTAINER_WEB = "simple_travel_web_test" + CONTAINER_REDIS = "simple_travel_redis_test" + STACK_NAME = "simple_travel" + } + + stages { + stage('Check Commit Message') { + steps { + script { + def commitMsg = sh( + script: "git log -1 --pretty=%B", + returnStdout: true + ).trim() + + if (commitMsg.contains("[ci skip]")) { + echo "Commit message contains [ci skip], aborting pipeline 🚫" + currentBuild.result = 'ABORTED' + error("Pipeline aborted because of [ci skip]") + } + } + } + } + stage('Checkout Code') { + steps { + git branch: 'main', credentialsId: 'ssh', url: 'git@github.com:JscorpTech/simple_travel.git' + } + } + stage('Build Image') { + steps { + sh ''' + if [ -e ${PROD_ENV} ]; then + echo env exists + else + mkdir -p $(dirname ${PROD_ENV}) + cp ./.env.example ${PROD_ENV} + fi + cp ${PROD_ENV} ./.env + ''' + sh """ + docker build -t ${IMAGE_NAME}:${PROD_TAG} -f ./docker/Dockerfile.web . + """ + } + } + + + stage('Start Test DB') { + steps { + sh """ + docker run -d --rm --name ${CONTAINER_DB} -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=testdb postgres:16 + docker run -d --rm --name ${CONTAINER_REDIS} redis + echo "⏳ Waiting for database..." + for i in {1..30}; do + if docker exec ${CONTAINER_DB} pg_isready -U postgres >/dev/null 2>&1; then + echo "✅ Database ready" + break + fi + echo "Database not ready yet... retrying..." + sleep 2 + done + """ + } + } + + stage('Run Migrations & Tests') { + steps { + sh """ + docker run --rm --name ${CONTAINER_WEB} --link ${CONTAINER_DB}:db --link ${CONTAINER_REDIS}:redis \ + -e DB_HOST=db \ + -e DB_PORT=5432 \ + -e DB_NAME=testdb \ + -e DB_USER=postgres \ + -e DB_PASSWORD=postgres \ + -e DJANGO_SETTINGS_MODULE=config.settings.test \ + ${IMAGE_NAME}:${PROD_TAG} \ + sh -c "python manage.py migrate && pytest -v" + """ + } + } + + stage('Publish to DockerHub') { + when { + expression { currentBuild.currentResult == "SUCCESS" } + } + steps { + withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + echo "${DOCKER_PASS}" | docker login -u "${DOCKER_USER}" --password-stdin + docker tag ${IMAGE_NAME}:${PROD_TAG} ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER} + docker tag ${IMAGE_NAME}:${PROD_TAG} ${DOCKER_USER}/${IMAGE_NAME}:${PROD_TAG} + docker push ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER} + docker push ${DOCKER_USER}/${IMAGE_NAME}:${PROD_TAG} + ''' + } + } + } + stage("Update stack.yaml") { + when { + expression { currentBuild.currentResult == "SUCCESS" } + } + steps { + withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh """ + sed -i 's|image: ${DOCKER_USER}/${IMAGE_NAME}:.*|image: ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER}|' stack.yaml + """ + // git config --global user.email "admin@jscorp.uz" + // git config --global user.name "Jenkins" + // if ! git diff --quiet stack.yaml; then + // git add stack.yaml + // git commit -m "feat(swarm) Update image tag to ${BUILD_NUMBER} [ci skip]" + // git push origin main + // else + // echo "No changes in stack.yaml" + // fi + } + + } + } + stage('Deploy stack') { + when { + expression { currentBuild.currentResult == "SUCCESS" } + } + steps { + withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + docker stack deploy -c stack.yaml ${STACK_NAME} + ''' + } + } + } + + } + + post { + always { + sh """ + docker stop ${CONTAINER_DB} || true + docker stop ${CONTAINER_REDIS} || true + """ + echo "Pipeline finished: ${currentBuild.currentResult}" + } + + success { + withCredentials([ + string(credentialsId: 'bot-token', variable: 'BOT_TOKEN'), + string(credentialsId: 'chat-id', variable: 'CHAT_ID') + ]) { + sh ''' + curl -s -X POST https://api.telegram.org/bot${BOT_TOKEN}/sendMessage \ + -d chat_id=${CHAT_ID} \ + -d text="✅ SUCCESS: ${JOB_NAME} #${BUILD_NUMBER}" + ''' + } + } + + failure { + withCredentials([ + string(credentialsId: 'bot-token', variable: 'BOT_TOKEN'), + string(credentialsId: 'chat-id', variable: 'CHAT_ID') + ]) { + sh ''' + curl -s -X POST https://api.telegram.org/bot${BOT_TOKEN}/sendMessage \ + -d chat_id=${CHAT_ID} \ + -d text="🚨 FAILED: ${JOB_NAME} #${BUILD_NUMBER}" + ''' + } + } + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4559c0d --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ + +start: up seed + +up: + docker compose up -d + +down: + docker compose down + +build: + docker compose build + +rebuild: down build up + +deploy: down build up makemigrate + +deploy-prod: + docker compose -f docker-compose.prod.yml down + docker compose -f docker-compose.prod.yml up -d + docker compose -f docker-compose.prod.yml exec web python manage.py makemigrations --noinput + docker compose -f docker-compose.prod.yml exec web python manage.py migrate + +logs: + docker compose logs -f + +makemigration: + docker compose exec web python manage.py makemigrations --noinput + +migrate: + docker compose exec web python manage.py migrate + +seed: + docker compose exec web python manage.py seed + +reset_db: + docker compose exec web python manage.py reset_db --no-input + +makemigrate: makemigration migrate + +fresh: reset_db makemigrate seed + +test: + docker compose exec web pytest -v diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..153f280 --- /dev/null +++ b/README.MD @@ -0,0 +1,2 @@ +# JST-DJANGO +[Docs](https://docs.jscorp.uz) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..801fff4 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app + +__all__ = ["app"] diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..f715434 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,23 @@ +import os + +from django.core.asgi import get_asgi_application + +asgi_application = get_asgi_application() +from config.env import env # noqa + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) + + +from channels.routing import ProtocolTypeRouter # noqa +from channels.routing import URLRouter # noqa + +# from core.apps.websocket.urls import websocket_urlpatterns # noqa +# from core.apps.websocket.middlewares import JWTAuthMiddlewareStack # noqa + +application = ProtocolTypeRouter( + { + "http": asgi_application, + # "websocket": JWTAuthMiddlewareStack(URLRouter(websocket_urlpatterns)), + } +) + diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..1578806 --- /dev/null +++ b/config/celery.py @@ -0,0 +1,16 @@ +""" +Celery configurations +""" + +import os + +import celery +from config.env import env + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) + +app = celery.Celery("config") + +app.config_from_object("django.conf:settings", namespace="CELERY") + +app.autodiscover_tasks() diff --git a/config/conf/__init__.py b/config/conf/__init__.py new file mode 100644 index 0000000..d8f1296 --- /dev/null +++ b/config/conf/__init__.py @@ -0,0 +1,12 @@ +from .cache import * # noqa +from .celery import * # noqa +from .cron import * # noqa +from .jwt import * # noqa +from .logs import * # noqa +from .rest_framework import * # noqa +from .unfold import * # noqa +from .spectacular import * # noqa + +from .ckeditor import * # noqa +from .storage import * # noqa +from .channels import * # noqa \ No newline at end of file diff --git a/config/conf/apps.py b/config/conf/apps.py new file mode 100644 index 0000000..7621efe --- /dev/null +++ b/config/conf/apps.py @@ -0,0 +1,22 @@ +from config.env import env + +APPS = [ + "channels", + "cacheops", + "rosetta", + "django_ckeditor_5", + + "drf_spectacular", + "rest_framework", + "corsheaders", + "django_filters", + "django_redis", + "rest_framework_simplejwt", + "django_core", + "core.apps.accounts.apps.AccountsConfig", +] + +if env.bool("SILK_ENEBLED", False): + APPS += [ + "silk", + ] diff --git a/config/conf/cache.py b/config/conf/cache.py new file mode 100644 index 0000000..cd724b5 --- /dev/null +++ b/config/conf/cache.py @@ -0,0 +1,26 @@ +from config.env import env + +CACHES = { + "default": { + "BACKEND": env.str("CACHE_BACKEND"), + "LOCATION": env.str("REDIS_URL"), + "TIMEOUT": env.str("CACHE_TIMEOUT"), + }, +} + +CACHE_MIDDLEWARE_SECONDS = env("CACHE_TIMEOUT") + + +CACHEOPS_REDIS = env.str("REDIS_URL") +CACHEOPS_DEFAULTS = { + "timeout": env.str("CACHE_TIMEOUT"), +} +CACHEOPS = { + # !NOTE: api => "you app name" + # "api.*": { + # "ops": "all", # Barcha turdagi so'rovlarni keshga olish + # "timeout": 60 * 5, # 5 daqiqa davomida saqlash + # }, +} +CACHEOPS_DEGRADE_ON_FAILURE = True +CACHEOPS_ENABLED = env.bool("CACHE_ENABLED", False) diff --git a/config/conf/celery.py b/config/conf/celery.py new file mode 100644 index 0000000..5f46855 --- /dev/null +++ b/config/conf/celery.py @@ -0,0 +1,7 @@ +CELERY_BEAT_SCHEDULE = { + # "test": { + # "task": "core.apps.home.tasks.demo.add", + # "schedule": 5.0, + # "args": (1, 2) + # }, +} diff --git a/config/conf/channels.py b/config/conf/channels.py new file mode 100644 index 0000000..d9f0597 --- /dev/null +++ b/config/conf/channels.py @@ -0,0 +1,8 @@ +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [("redis", 6379)], + }, + }, +} \ No newline at end of file diff --git a/config/conf/ckeditor.py b/config/conf/ckeditor.py new file mode 100644 index 0000000..654019c --- /dev/null +++ b/config/conf/ckeditor.py @@ -0,0 +1,147 @@ +import os +from pathlib import Path + +STATIC_URL = "/resources/static/" +MEDIA_URL = "/resources/media/" +MEDIA_ROOT = os.path.join(Path().parent.parent, "media") + +customColorPalette = [ + {"color": "hsl(4, 90%, 58%)", "label": "Red"}, + {"color": "hsl(340, 82%, 52%)", "label": "Pink"}, + {"color": "hsl(291, 64%, 42%)", "label": "Purple"}, + {"color": "hsl(262, 52%, 47%)", "label": "Deep Purple"}, + {"color": "hsl(231, 48%, 48%)", "label": "Indigo"}, + {"color": "hsl(207, 90%, 54%)", "label": "Blue"}, +] + +CKEDITOR_5_CONFIGS = { + "default": { + "toolbar": [ + "heading", + "|", + "bold", + "italic", + "link", + "bulletedList", + "numberedList", + "blockQuote", + "imageUpload", + ], + }, + "extends": { + "blockToolbar": [ + "paragraph", + "heading1", + "heading2", + "heading3", + "|", + "bulletedList", + "numberedList", + "|", + "blockQuote", + ], + "toolbar": [ + "heading", + "|", + "outdent", + "indent", + "|", + "bold", + "italic", + "link", + "underline", + "strikethrough", + "code", + "subscript", + "superscript", + "highlight", + "|", + "codeBlock", + "sourceEditing", + "insertImage", + "bulletedList", + "numberedList", + "todoList", + "|", + "blockQuote", + "imageUpload", + "|", + "fontSize", + "fontFamily", + "fontColor", + "fontBackgroundColor", + "mediaEmbed", + "removeFormat", + "insertTable", + ], + "image": { + "toolbar": [ + "imageTextAlternative", + "|", + "imageStyle:alignLeft", + "imageStyle:alignRight", + "imageStyle:alignCenter", + "imageStyle:side", + "|", + ], + "styles": [ + "full", + "side", + "alignLeft", + "alignRight", + "alignCenter", + ], + }, + "table": { + "contentToolbar": [ + "tableColumn", + "tableRow", + "mergeTableCells", + "tableProperties", + "tableCellProperties", + ], + "tableProperties": { + "borderColors": customColorPalette, + "backgroundColors": customColorPalette, + }, + "tableCellProperties": { + "borderColors": customColorPalette, + "backgroundColors": customColorPalette, + }, + }, + "heading": { + "options": [ + { + "model": "paragraph", + "title": "Paragraph", + "class": "ck-heading_paragraph", + }, + { + "model": "heading1", + "view": "h1", + "title": "Heading 1", + "class": "ck-heading_heading1", + }, + { + "model": "heading2", + "view": "h2", + "title": "Heading 2", + "class": "ck-heading_heading2", + }, + { + "model": "heading3", + "view": "h3", + "title": "Heading 3", + "class": "ck-heading_heading3", + }, + ] + }, + }, + "list": { + "properties": { + "styles": "true", + "startIndex": "true", + "reversed": "true", + } + }, +} diff --git a/config/conf/cron.py b/config/conf/cron.py new file mode 100644 index 0000000..e69de29 diff --git a/config/conf/jwt.py b/config/conf/jwt.py new file mode 100644 index 0000000..15d8970 --- /dev/null +++ b/config/conf/jwt.py @@ -0,0 +1,36 @@ +from datetime import timedelta + +from config.env import env + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=30), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "UPDATE_LAST_LOGIN": False, + "ALGORITHM": "HS256", + "SIGNING_KEY": env("DJANGO_SECRET_KEY"), + "VERIFYING_KEY": "", + "AUDIENCE": None, + "ISSUER": None, + "JSON_ENCODER": None, + "JWK_URL": None, + "LEEWAY": 0, + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + "JTI_CLAIM": "jti", + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": timedelta(minutes=60), + "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=30), + "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer", + "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer", + "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer", + "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer", + "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", + "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", +} diff --git a/config/conf/logs.py b/config/conf/logs.py new file mode 100644 index 0000000..5245ac6 --- /dev/null +++ b/config/conf/logs.py @@ -0,0 +1,29 @@ +# settings.py faylida + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {message}", + "style": "{", + }, + }, + "handlers": { + "daily_rotating_file": { + "level": "INFO", + "class": "logging.handlers.TimedRotatingFileHandler", + "filename": "resources/logs/django.log", # Fayl nomi (kunlik fayllar uchun avtomatik yoziladi) + "when": "midnight", # Har kecha log fayli yangilanadi + "backupCount": 30, # 30 kunlik loglar saqlanadi, 1 oydan keyin eski fayllar o'chiriladi + "formatter": "verbose", + }, + }, + "loggers": { + "django": { + "handlers": ["daily_rotating_file"], + "level": "INFO", + "propagate": True, + }, + }, +} diff --git a/config/conf/modules.py b/config/conf/modules.py new file mode 100644 index 0000000..eb95b0d --- /dev/null +++ b/config/conf/modules.py @@ -0,0 +1 @@ +MODULES = ["core.apps.shared", "core.apps.tickets", "core.apps.payments", "core.apps.api", "core.apps.blog"] diff --git a/config/conf/navigation.py b/config/conf/navigation.py new file mode 100644 index 0000000..2083489 --- /dev/null +++ b/config/conf/navigation.py @@ -0,0 +1,31 @@ +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +PAGES = [ + { + "seperator": False, + "items": [ + { + "title": _("Home page"), + "icon": "home", + "link": reverse_lazy("admin:index"), + } + ], + }, + { + "title": _("Auth"), + "separator": True, # Top border + "items": [ + { + "title": _("Users"), + "icon": "group", + "link": reverse_lazy("admin:http_user_changelist"), + }, + { + "title": _("Group"), + "icon": "group", + "link": reverse_lazy("admin:auth_group_changelist"), + }, + ], + }, +] diff --git a/config/conf/rest_framework.py b/config/conf/rest_framework.py new file mode 100644 index 0000000..fbec6fe --- /dev/null +++ b/config/conf/rest_framework.py @@ -0,0 +1,9 @@ +from typing import Any, Union + +REST_FRAMEWORK: Union[Any] = { + "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_PAGINATION_CLASS": "django_core.paginations.CustomPagination", + "PAGE_SIZE": 10, +} diff --git a/config/conf/spectacular.py b/config/conf/spectacular.py new file mode 100644 index 0000000..2be491b --- /dev/null +++ b/config/conf/spectacular.py @@ -0,0 +1,31 @@ +SPECTACULAR_SETTINGS = { + "TITLE": "Your Project API", + "DESCRIPTION": "Your project description", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "CAMELIZE_NAMES": True, + "POSTPROCESSING_HOOKS": ["config.conf.spectacular.custom_postprocessing_hook"], +} + + +def custom_postprocessing_hook(result, generator, request, public): + """ + Customizes the API schema to wrap all responses in a standard format. + """ + for path, methods in result.get("paths", {}).items(): + for method, operation in methods.items(): + if "responses" in operation: + for status_code, response in operation["responses"].items(): + if "content" in response: + for content_type, content in response["content"].items(): + # Wrap original schema + original_schema = content.get("schema", {}) + response["content"][content_type]["schema"] = { + "type": "object", + "properties": { + "status": {"type": "boolean", "example": True}, + "data": original_schema, + }, + "required": ["status", "data"], + } + return result diff --git a/config/conf/storage.py b/config/conf/storage.py new file mode 100644 index 0000000..5d59a85 --- /dev/null +++ b/config/conf/storage.py @@ -0,0 +1,23 @@ +from config.env import env +from core.utils.storage import Storage + +AWS_ACCESS_KEY_ID = env.str("STORAGE_ID") +AWS_SECRET_ACCESS_KEY = env.str("STORAGE_KEY") +AWS_S3_ENDPOINT_URL = env.str("STORAGE_URL") +AWS_S3_CUSTOM_DOMAIN = env.str("STORAGE_PATH") +AWS_S3_URL_PROTOCOL = env.str("STORAGE_PROTOCOL", "https:") +AWS_S3_FILE_OVERWRITE = False + +default_storage = Storage(env.str("STORAGE_DEFAULT"), "default") +static_storage = Storage(env.str("STORAGE_STATIC"), "static") + +STORAGES = { + "default": { + "BACKEND": default_storage.get_backend(), + "OPTIONS": default_storage.get_options(), + }, + "staticfiles": { + "BACKEND": static_storage.get_backend(), + "OPTIONS": static_storage.get_options(), + }, +} diff --git a/config/conf/unfold.py b/config/conf/unfold.py new file mode 100644 index 0000000..8984f1c --- /dev/null +++ b/config/conf/unfold.py @@ -0,0 +1,95 @@ +from django.conf import settings +from django.templatetags.static import static +from django.utils.translation import gettext_lazy as _ + +from . import navigation + + +def environment_callback(request): + if settings.DEBUG: + return [_("Development"), "primary"] + + return [_("Production"), "primary"] + + +UNFOLD = { + "DASHBOARD_CALLBACK": "django_core.views.dashboard_callback", + "SITE_TITLE": "Django", + "SITE_HEADER": "Django", + "SITE_URL": "/", + # "SITE_DROPDOWN": [ + # {"icon": "local_library", "title": "Django", "link": "https://example.com"}, + # ], + "SITE_ICON": { + # "light": lambda request: static("images/pedagog.svg"), + # "dark": lambda request: static("images/pedagog.svg"), + }, + # "SITE_FAVICONS": [ + # { + # "rel": "icon", + # "sizes": "32x32", + # "type": "image/svg+xml", + # "href": lambda request: static("images/pedagog.svg"), + # }, + # ], + "SITE_SYMBOL": "speed", + "SHOW_HISTORY": True, + "SHOW_VIEW_ON_SITE": True, + "SHOW_BACK_BUTTON": True, + "SHOW_LANGUAGES": True, + "ENVIRONMENT": "core.config.unfold.environment_callback", + # "LOGIN": { + # "image": lambda request: static("images/login.png"), + # }, + "BORDER_RADIUS": "10px", + "COLORS": { + "base": { + "50": "250 250 250", + "100": "244 244 245", + "200": "228 228 231", + "300": "212 212 216", + "400": "161 161 170", + "500": "113 113 122", + "600": "82 82 91", + "700": "63 63 70", + "800": "39 39 42", + "900": "24 24 27", + "950": "9 9 11", + }, + "font": { + "subtle-light": "var(--color-base-500)", # text-base-500 + "subtle-dark": "var(--color-base-400)", # text-base-400 + "default-light": "var(--color-base-600)", # text-base-600 + "default-dark": "var(--color-base-300)", # text-base-300 + "important-light": "var(--color-base-900)", # text-base-900 + "important-dark": "var(--color-base-100)", # text-base-100 + }, + "primary": { + "50": "230 245 255", + "100": "180 225 255", + "200": "130 205 255", + "300": "80 185 255", + "400": "40 165 255", + "500": "0 145 255", + "600": "0 115 204", + "700": "0 85 153", + "800": "0 55 102", + "900": "0 30 51", + "950": "0 15 25", + }, + }, + "EXTENSIONS": { + "modeltranslation": { + "flags": { + "uz": "🇺🇿", + "ru": "🇷🇺", + "en": "🇬🇧", + }, + }, + }, + "SIDEBAR": { + "show_search": True, + "show_all_applications": True, + # "navigation": navigation.PAGES, + }, +} diff --git a/config/env.py b/config/env.py new file mode 100644 index 0000000..8665829 --- /dev/null +++ b/config/env.py @@ -0,0 +1,29 @@ +""" +Default value for environ variable +""" + +import os + +import environ + +environ.Env.read_env(os.path.join(".env")) + +env = environ.Env( + DEBUG=(bool, False), + CACHE_TIME=(int, 180), + OTP_EXPIRE_TIME=(int, 2), + VITE_LIVE=(bool, False), + ALLOWED_HOSTS=(str, "localhost"), + CSRF_TRUSTED_ORIGINS=(str, "localhost"), + DJANGO_SETTINGS_MODULE=(str, "config.settings.local"), + CACHE_TIMEOUT=(int, 120), + CACHE_ENABLED=(bool, False), + VITE_PORT=(int, 5173), + VITE_HOST=(str, "vite"), + NGROK_AUTHTOKEN=(str, "TOKEN"), + BOT_TOKEN=(str, "TOKEN"), + OTP_MODULE="core.services.otp", + OTP_SERVICE="EskizService", + PROJECT_ENV=(str, "prod"), + SILK_ENEBLED=(bool, False), +) diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/common.py b/config/settings/common.py new file mode 100644 index 0000000..f148a6f --- /dev/null +++ b/config/settings/common.py @@ -0,0 +1,174 @@ +import os +import pathlib +from typing import List, Union + +from config.conf import * # noqa +from config.conf.apps import APPS +from config.conf.modules import MODULES +from config.env import env +from django.utils.translation import gettext_lazy as _ +from rich.traceback import install + +install(show_locals=True) +BASE_DIR = pathlib.Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = env.str("DJANGO_SECRET_KEY") +DEBUG = env.bool("DEBUG") + +ALLOWED_HOSTS: Union[List[str]] = ["*"] + +if env.bool("PROTOCOL_HTTPS", False): + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +DATABASES = { + "default": { + "ENGINE": env.str("DB_ENGINE"), + "NAME": env.str("DB_NAME"), + "USER": env.str("DB_USER"), + "PASSWORD": env.str("DB_PASSWORD"), + "HOST": env.str("DB_HOST"), + "PORT": env.str("DB_PORT"), + } +} + +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.BCryptPasswordHasher", +] + +INSTALLED_APPS = [ + "modeltranslation", + "unfold", + "unfold.contrib.filters", + "unfold.contrib.forms", + "unfold.contrib.guardian", + "unfold.contrib.simple_history", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + APPS + +MODULES = [app for app in MODULES if isinstance(app, str)] + +for module_path in MODULES: + INSTALLED_APPS.append("{}.apps.ModuleConfig".format(module_path)) + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", # Cors middleware + "django.middleware.locale.LocaleMiddleware", # Locale middleware + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] +if env.bool("SILK_ENEBLED", False): + MIDDLEWARE += [ + "silk.middleware.SilkyMiddleware", + ] + + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "resources/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", + ], + }, + }, +] +# fmt: off + +WSGI_APPLICATION = "config.wsgi.application" + +# fmt: on + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.{}".format(validator) + } for validator in [ + "UserAttributeSimilarityValidator", + "MinimumLengthValidator", + "CommonPasswordValidator", + "NumericPasswordValidator" + ] +] + +TIME_ZONE = "Asia/Tashkent" +USE_I18N = True +USE_TZ = True +STATIC_URL = "resources/static/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Date formats +## +DATE_FORMAT = "d.m.y" +TIME_FORMAT = "H:i:s" +DATE_INPUT_FORMATS = ["%d.%m.%Y", "%Y.%d.%m", "%Y.%d.%m"] + + +SEEDERS = ["core.apps.accounts.seeder.UserSeeder"] + +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "resources/static"), +] + +CORS_ORIGIN_ALLOW_ALL = True + +STATIC_ROOT = os.path.join(BASE_DIR, "resources/staticfiles") +VITE_APP_DIR = os.path.join(BASE_DIR, "resources/static/vite") + +LANGUAGES = ( + ("ru", _("Russia")), + ("en", _("English")), + ("uz", _("Uzbek")), +) +LOCALE_PATHS = [os.path.join(BASE_DIR, "resources/locale")] + +LANGUAGE_CODE = "uz" + +MEDIA_ROOT = os.path.join(BASE_DIR, "resources/media") # Media files +MEDIA_URL = "/resources/media/" + +AUTH_USER_MODEL = "accounts.User" + +CELERY_BROKER_URL = env("REDIS_URL") +CELERY_RESULT_BACKEND = env("REDIS_URL") + +ALLOWED_HOSTS += env("ALLOWED_HOSTS").split(",") +CSRF_TRUSTED_ORIGINS = env("CSRF_TRUSTED_ORIGINS").split(",") +SILKY_AUTHORISATION = True +SILKY_PYTHON_PROFILER = True + +MODELTRANSLATION_LANGUAGES = ("uz", "ru", "en") +MODELTRANSLATION_DEFAULT_LANGUAGE = "uz" + + + +JST_LANGUAGES = [ + { + "code": "uz", + "name": "Uzbek", + "is_default": True, + }, + { + "code": "en", + "name": "English", + }, + { + "code": "ru", + "name": "Russia", + } +] diff --git a/config/settings/local.py b/config/settings/local.py new file mode 100644 index 0000000..7394e18 --- /dev/null +++ b/config/settings/local.py @@ -0,0 +1,11 @@ +from config.settings.common import * # noqa +from config.settings.common import (ALLOWED_HOSTS, INSTALLED_APPS, + REST_FRAMEWORK) + +INSTALLED_APPS += ["django_extensions"] + +ALLOWED_HOSTS += ["127.0.0.1", "192.168.100.26"] + +REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { + "user": "60/min", +} diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..d802ed7 --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,6 @@ +from config.settings.common import * # noqa +from config.settings.common import ALLOWED_HOSTS, REST_FRAMEWORK + +ALLOWED_HOSTS += ["192.168.100.26", "80.90.178.156"] + +REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {"user": "60/min"} diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..cc2a481 --- /dev/null +++ b/config/settings/test.py @@ -0,0 +1,15 @@ +from config.settings.common import * # noqa + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, +} diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..0e82539 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,46 @@ +""" +All urls configurations tree +""" + +from django.conf import settings +from django.contrib import admin +from django.http import HttpResponse +from django.urls import include, path, re_path +from django.views.static import serve +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + +from config.env import env + + +def home(request): + return HttpResponse("OK") + + +urlpatterns = [ + path("health/", home), + path("api/v1/", include("core.apps.accounts.urls")), + path("api/v1/", include("core.apps.api.urls")), + path("api/v1/", include("core.apps.payments.urls")), + path("api/v1/", include("core.apps.tickets.urls")), + path("api/v1/", include("core.apps.shared.urls")), + path("api/", include("core.apps.blog.urls")), +] +urlpatterns += [ + path("admin/", admin.site.urls), + path("accounts/", include("django.contrib.auth.urls")), + path("i18n/", include("django.conf.urls.i18n")), + path("rosetta/", include("rosetta.urls")), + path("ckeditor5/", include("django_ckeditor_5.urls"), name="ck_editor_5_upload_file"), +] +if env.bool("SILK_ENEBLED", False): + urlpatterns += [path("silk/", include("silk.urls", namespace="silk"))] +if env.str("PROJECT_ENV") == "debug": + urlpatterns += [ + path("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"), + ] +urlpatterns += [ + re_path("static/(?P.*)", serve, {"document_root": settings.STATIC_ROOT}), + re_path("media/(?P.*)", serve, {"document_root": settings.MEDIA_ROOT}), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..ab1662c --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,8 @@ +import os + +from config.env import env +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) + +application = get_wsgi_application() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/__init__.py b/core/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/__init__.py b/core/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/admin/__init__.py b/core/apps/accounts/admin/__init__.py new file mode 100644 index 0000000..8d11538 --- /dev/null +++ b/core/apps/accounts/admin/__init__.py @@ -0,0 +1,4 @@ +from .core import * # noqa +from .likes import * # noqa +from .participant import * # noqa +from .user import * # noqa diff --git a/core/apps/accounts/admin/core.py b/core/apps/accounts/admin/core.py new file mode 100644 index 0000000..4a807e7 --- /dev/null +++ b/core/apps/accounts/admin/core.py @@ -0,0 +1,18 @@ +""" +Admin panel register +""" + +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth import models as db_models +from django_core.models import SmsConfirm + +from ..admin import user +from .user import SmsConfirmAdmin + +admin.site.unregister(db_models.Group) +admin.site.register(db_models.Group, user.GroupAdmin) +admin.site.register(db_models.Permission, user.PermissionAdmin) + +admin.site.register(get_user_model(), user.CustomUserAdmin) +admin.site.register(SmsConfirm, SmsConfirmAdmin) diff --git a/core/apps/accounts/admin/likes.py b/core/apps/accounts/admin/likes.py new file mode 100644 index 0000000..43599c1 --- /dev/null +++ b/core/apps/accounts/admin/likes.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.accounts.models import LikesModel + + +@admin.register(LikesModel) +class LikesAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/accounts/admin/participant.py b/core/apps/accounts/admin/participant.py new file mode 100644 index 0000000..b84fb66 --- /dev/null +++ b/core/apps/accounts/admin/participant.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.accounts.models import ParticipantModel + + +@admin.register(ParticipantModel) +class ParticipantAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/accounts/admin/user.py b/core/apps/accounts/admin/user.py new file mode 100644 index 0000000..a71eb38 --- /dev/null +++ b/core/apps/accounts/admin/user.py @@ -0,0 +1,53 @@ +from django.contrib.auth import admin +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin +from unfold.forms import AdminPasswordChangeForm # UserCreationForm, +from unfold.forms import UserChangeForm + + +class CustomUserAdmin(admin.UserAdmin, ModelAdmin): + change_password_form = AdminPasswordChangeForm + # add_form = UserCreationForm + form = UserChangeForm + list_display = ( + "first_name", + "last_name", + "phone", + "role", + ) + autocomplete_fields = ["groups", "user_permissions"] + fieldsets = ((None, {"fields": ("phone",)}),) + ( + (None, {"fields": ("username", "password")}), + (_("Personal info"), {"fields": ("first_name", "last_name", "email")}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + "role", + "avatar", + ), + }, + ), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ) + + +class PermissionAdmin(ModelAdmin): + list_display = ("name",) + search_fields = ("name",) + + +class GroupAdmin(ModelAdmin): + list_display = ["name"] + search_fields = ["name"] + autocomplete_fields = ("permissions",) + + +class SmsConfirmAdmin(ModelAdmin): + list_display = ["phone", "code", "resend_count", "try_count"] + search_fields = ["phone", "code"] diff --git a/core/apps/accounts/apps.py b/core/apps/accounts/apps.py new file mode 100644 index 0000000..a231531 --- /dev/null +++ b/core/apps/accounts/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.accounts" + + def ready(self): + from core.apps.accounts import signals # noqa diff --git a/core/apps/accounts/choices/__init__.py b/core/apps/accounts/choices/__init__.py new file mode 100644 index 0000000..1000b27 --- /dev/null +++ b/core/apps/accounts/choices/__init__.py @@ -0,0 +1 @@ +from .user import * # noqa diff --git a/core/apps/accounts/choices/user.py b/core/apps/accounts/choices/user.py new file mode 100644 index 0000000..b93b918 --- /dev/null +++ b/core/apps/accounts/choices/user.py @@ -0,0 +1,12 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class RoleChoice(models.TextChoices): + """ + User Role Choice + """ + + SUPERUSER = "superuser", _("Superuser") + ADMIN = "admin", _("Admin") + USER = "user", _("User") diff --git a/core/apps/accounts/filters/__init__.py b/core/apps/accounts/filters/__init__.py new file mode 100644 index 0000000..20a43c8 --- /dev/null +++ b/core/apps/accounts/filters/__init__.py @@ -0,0 +1,2 @@ +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/filters/likes.py b/core/apps/accounts/filters/likes.py new file mode 100644 index 0000000..5285d46 --- /dev/null +++ b/core/apps/accounts/filters/likes.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.accounts.models import LikesModel + + +class LikesFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = LikesModel + fields = [ + "name", + ] diff --git a/core/apps/accounts/filters/participant.py b/core/apps/accounts/filters/participant.py new file mode 100644 index 0000000..7ca0399 --- /dev/null +++ b/core/apps/accounts/filters/participant.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.accounts.models import ParticipantModel + + +class ParticipantFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = ParticipantModel + fields = [ + "name", + ] diff --git a/core/apps/accounts/forms/__init__.py b/core/apps/accounts/forms/__init__.py new file mode 100644 index 0000000..20a43c8 --- /dev/null +++ b/core/apps/accounts/forms/__init__.py @@ -0,0 +1,2 @@ +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/forms/likes.py b/core/apps/accounts/forms/likes.py new file mode 100644 index 0000000..dcae9c1 --- /dev/null +++ b/core/apps/accounts/forms/likes.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.accounts.models import LikesModel + + +class LikesForm(forms.ModelForm): + + class Meta: + model = LikesModel + fields = "__all__" diff --git a/core/apps/accounts/forms/participant.py b/core/apps/accounts/forms/participant.py new file mode 100644 index 0000000..2ebe41b --- /dev/null +++ b/core/apps/accounts/forms/participant.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.accounts.models import ParticipantModel + + +class ParticipantForm(forms.ModelForm): + + class Meta: + model = ParticipantModel + fields = "__all__" diff --git a/core/apps/accounts/managers/__init__.py b/core/apps/accounts/managers/__init__.py new file mode 100644 index 0000000..1000b27 --- /dev/null +++ b/core/apps/accounts/managers/__init__.py @@ -0,0 +1 @@ +from .user import * # noqa diff --git a/core/apps/accounts/managers/user.py b/core/apps/accounts/managers/user.py new file mode 100644 index 0000000..8395928 --- /dev/null +++ b/core/apps/accounts/managers/user.py @@ -0,0 +1,20 @@ +from django.contrib.auth import base_user + + +class UserManager(base_user.BaseUserManager): + def create_user(self, phone=None, password=None, **extra_fields): + user = self.model(phone=phone, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, phone, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self.create_user(phone, password, **extra_fields) diff --git a/core/apps/accounts/migrations/0001_initial.py b/core/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..84e56eb --- /dev/null +++ b/core/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# Generated by Django 5.1.3 on 2025-09-19 10:19 + +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='LikesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ], + options={ + 'verbose_name': 'LikesModel', + 'verbose_name_plural': 'LikesModels', + 'db_table': 'likes', + }, + ), + migrations.CreateModel( + name='ParticipantModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('gender', models.CharField(choices=[('Male', 'Male'), ('Female', 'Female')], default='Male', max_length=255, verbose_name='gender')), + ('first_name', models.CharField(max_length=255, verbose_name='first name')), + ('last_name', models.CharField(max_length=255, verbose_name='last name')), + ('birth_date', models.DateField(verbose_name='birth date')), + ('phone_number', models.CharField(max_length=255, verbose_name='phone number')), + ], + options={ + 'verbose_name': 'ParticipantModel', + 'verbose_name_plural': 'ParticipantModels', + 'db_table': 'participant', + }, + ), + migrations.CreateModel( + name='ParticipantPasportImageModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='participant_images/', verbose_name='image')), + ], + options={ + 'verbose_name': 'ParticipantPasportImageModel', + 'verbose_name_plural': 'ParticipantPasportImageModels', + 'db_table': 'participant_pasport_image', + }, + ), + migrations.CreateModel( + name='ResetToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('token', models.CharField(max_length=255, unique=True)), + ], + options={ + 'verbose_name': 'Reset Token', + 'verbose_name_plural': 'Reset Tokens', + }, + ), + migrations.CreateModel( + name='User', + 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')), + ('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')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('phone', models.CharField(blank=True, max_length=255, null=True, unique=True)), + ('email', models.EmailField(blank=True, max_length=254, null=True, unique=True)), + ('username', models.CharField(blank=True, max_length=255, null=True)), + ('avatar', models.ImageField(default='static/images/default_avatar.jpg', upload_to='avatars/')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('validated_at', models.DateTimeField(blank=True, null=True)), + ('role', models.CharField(choices=[('superuser', 'Superuser'), ('admin', 'Admin'), ('user', 'User')], default='user', max_length=255)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + ), + ] diff --git a/core/apps/accounts/migrations/0002_initial.py b/core/apps/accounts/migrations/0002_initial.py new file mode 100644 index 0000000..bf1972e --- /dev/null +++ b/core/apps/accounts/migrations/0002_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1.3 on 2025-09-19 10:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ('tickets', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='likesmodel', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.ticketsmodel', verbose_name='ticket'), + ), + migrations.AddField( + model_name='likesmodel', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user'), + ), + migrations.AddField( + model_name='participantpasportimagemodel', + name='participant', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participant_pasport_image', to='accounts.participantmodel'), + ), + migrations.AddField( + model_name='resettoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='likesmodel', + unique_together={('user', 'ticket')}, + ), + ] diff --git a/core/apps/accounts/migrations/__init__.py b/core/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/models/__init__.py b/core/apps/accounts/models/__init__.py new file mode 100644 index 0000000..05cb9ea --- /dev/null +++ b/core/apps/accounts/models/__init__.py @@ -0,0 +1,5 @@ +# isort: skip_file +from .user import * # noqa +from .reset_token import * # noqa +from .likes import * # noqa +from .participant import * # noqa \ No newline at end of file diff --git a/core/apps/accounts/models/likes.py b/core/apps/accounts/models/likes.py new file mode 100644 index 0000000..3a221a7 --- /dev/null +++ b/core/apps/accounts/models/likes.py @@ -0,0 +1,27 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel +from .user import User +from core.apps.tickets.models.tickets import TicketsModel + + +class LikesModel(AbstractBaseModel): + user = models.ForeignKey(User, verbose_name=_('user'), on_delete=models.CASCADE) + ticket = models.ForeignKey(TicketsModel, verbose_name=_('ticket'), on_delete=models.CASCADE) + created_at = models.DateTimeField(verbose_name=_("created at"), auto_now_add=True) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + user=User._create_fake(), + ticket=TicketsModel._create_fake(), + ) + + class Meta: + unique_together = ('user', 'ticket') + db_table = "likes" + verbose_name = _("LikesModel") + verbose_name_plural = _("LikesModels") diff --git a/core/apps/accounts/models/participant.py b/core/apps/accounts/models/participant.py new file mode 100644 index 0000000..51fb214 --- /dev/null +++ b/core/apps/accounts/models/participant.py @@ -0,0 +1,53 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel + + +class ParticipantModel(AbstractBaseModel): + GenderChoices = ( + ('Male', 'Male'), + ('Female', 'Female'), + ) + gender = models.CharField(verbose_name=_("gender"), max_length=255, choices=GenderChoices, default='Male') + first_name = models.CharField(verbose_name=_("first name"), max_length=255) + last_name = models.CharField(verbose_name=_("last name"), max_length=255) + birth_date = models.DateField(verbose_name=_("birth date")) + phone_number = models.CharField(verbose_name=_("phone number"), max_length=255) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + first_name="mock", + last_name="mock", + birth_date="2025-09-19", + phone_number="998940105669", + ) + + class Meta: + db_table = "participant" + verbose_name = _("ParticipantModel") + verbose_name_plural = _("ParticipantModels") + + +class ParticipantPasportImageModel(AbstractBaseModel): + image = models.ImageField(verbose_name=_("image"), upload_to="participant_images/") + participant = models.ForeignKey(ParticipantModel, related_name="participant_pasport_image", + on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/default_avatar.jpg", + participant=ParticipantModel._create_fake(), + ) + + class Meta: + db_table = "participant_pasport_image" + verbose_name = _("ParticipantPasportImageModel") + verbose_name_plural = _("ParticipantPasportImageModels") diff --git a/core/apps/accounts/models/reset_token.py b/core/apps/accounts/models/reset_token.py new file mode 100644 index 0000000..f9efafd --- /dev/null +++ b/core/apps/accounts/models/reset_token.py @@ -0,0 +1,15 @@ +from django.contrib.auth import get_user_model +from django.db import models +from django_core.models import AbstractBaseModel + + +class ResetToken(AbstractBaseModel): + token = models.CharField(max_length=255, unique=True) + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + + def __str__(self): + return self.token + + class Meta: + verbose_name = "Reset Token" + verbose_name_plural = "Reset Tokens" diff --git a/core/apps/accounts/models/user.py b/core/apps/accounts/models/user.py new file mode 100644 index 0000000..a1dd8e8 --- /dev/null +++ b/core/apps/accounts/models/user.py @@ -0,0 +1,33 @@ +from django.contrib.auth import models as auth_models +from django.db import models + +from ..choices import RoleChoice +from ..managers import UserManager + + +class User(auth_models.AbstractUser): + phone = models.CharField(max_length=255, unique=True, null=True, blank=True) + email = models.EmailField(unique=True, null=True, blank=True) + username = models.CharField(max_length=255, null=True, blank=True) + avatar = models.ImageField(default="static/images/default_avatar.jpg", upload_to="avatars/") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + validated_at = models.DateTimeField(null=True, blank=True) + role = models.CharField( + max_length=255, + choices=RoleChoice, + default=RoleChoice.USER, + ) + + USERNAME_FIELD = "phone" + objects = UserManager() + + def __str__(self): + return self.username + + @classmethod + def _create_fake(self): + return self.objects.create( + phone="998940105669", + username="mock", + ) diff --git a/core/apps/accounts/permissions/__init__.py b/core/apps/accounts/permissions/__init__.py new file mode 100644 index 0000000..20a43c8 --- /dev/null +++ b/core/apps/accounts/permissions/__init__.py @@ -0,0 +1,2 @@ +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/permissions/likes.py b/core/apps/accounts/permissions/likes.py new file mode 100644 index 0000000..8c263c1 --- /dev/null +++ b/core/apps/accounts/permissions/likes.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class LikesPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/accounts/permissions/participant.py b/core/apps/accounts/permissions/participant.py new file mode 100644 index 0000000..5a7d8e9 --- /dev/null +++ b/core/apps/accounts/permissions/participant.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class ParticipantPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/accounts/seeder/__init__.py b/core/apps/accounts/seeder/__init__.py new file mode 100644 index 0000000..151ee18 --- /dev/null +++ b/core/apps/accounts/seeder/__init__.py @@ -0,0 +1 @@ +from .core import * # noqa diff --git a/core/apps/accounts/seeder/core.py b/core/apps/accounts/seeder/core.py new file mode 100644 index 0000000..c487218 --- /dev/null +++ b/core/apps/accounts/seeder/core.py @@ -0,0 +1,10 @@ +""" +Create a new user/superuser +""" + +from django.contrib.auth import get_user_model + + +class UserSeeder: + def run(self): + get_user_model().objects.create_superuser("998888112309", "2309") diff --git a/core/apps/accounts/serializers/__init__.py b/core/apps/accounts/serializers/__init__.py new file mode 100644 index 0000000..d4191d3 --- /dev/null +++ b/core/apps/accounts/serializers/__init__.py @@ -0,0 +1,6 @@ +from .auth import * # noqa +from .change_password import * # noqa +from .likes import * # noqa +from .participant import * # noqa +from .set_password import * # noqa +from .user import * # noqa diff --git a/core/apps/accounts/serializers/auth.py b/core/apps/accounts/serializers/auth.py new file mode 100644 index 0000000..325eff6 --- /dev/null +++ b/core/apps/accounts/serializers/auth.py @@ -0,0 +1,60 @@ +from config.env import env +from django.contrib.auth import get_user_model +from django.utils.translation import gettext as _ +from rest_framework import exceptions, serializers + +OTP_SIZE = env.int("OTP_SIZE", 4) +class LoginSerializer(serializers.Serializer): + username = serializers.CharField(max_length=255) + password = serializers.CharField(max_length=255) + + +class RegisterSerializer(serializers.ModelSerializer): + phone = serializers.CharField(max_length=255) + + def validate_phone(self, value): + user = get_user_model().objects.filter(phone=value, validated_at__isnull=False) + if user.exists(): + raise exceptions.ValidationError(_("Phone number already registered."), code="unique") + return value + + class Meta: + model = get_user_model() + fields = ["first_name", "last_name", "phone", "password"] + extra_kwargs = { + "first_name": { + "required": True, + }, + "last_name": {"required": True}, + } + + +class ConfirmSerializer(serializers.Serializer): + code = serializers.CharField(max_length=OTP_SIZE, min_length=OTP_SIZE) + phone = serializers.CharField(max_length=255) + + +class ResetPasswordSerializer(serializers.Serializer): + phone = serializers.CharField(max_length=255) + + def validate_phone(self, value): + user = get_user_model().objects.filter(phone=value) + if user.exists(): + return value + + raise serializers.ValidationError(_("User does not exist")) + + +class ResetConfirmationSerializer(serializers.Serializer): + code = serializers.CharField(min_length=OTP_SIZE, max_length=OTP_SIZE) + phone = serializers.CharField(max_length=255) + + def validate_phone(self, value): + user = get_user_model().objects.filter(phone=value) + if user.exists(): + return value + raise serializers.ValidationError(_("User does not exist")) + + +class ResendSerializer(serializers.Serializer): + phone = serializers.CharField(max_length=255) diff --git a/core/apps/accounts/serializers/change_password.py b/core/apps/accounts/serializers/change_password.py new file mode 100644 index 0000000..79f4559 --- /dev/null +++ b/core/apps/accounts/serializers/change_password.py @@ -0,0 +1,6 @@ +from rest_framework import serializers + + +class ChangePasswordSerializer(serializers.Serializer): + old_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) diff --git a/core/apps/accounts/serializers/likes/__init__.py b/core/apps/accounts/serializers/likes/__init__.py new file mode 100644 index 0000000..e870492 --- /dev/null +++ b/core/apps/accounts/serializers/likes/__init__.py @@ -0,0 +1 @@ +from .likes import * # noqa diff --git a/core/apps/accounts/serializers/likes/likes.py b/core/apps/accounts/serializers/likes/likes.py new file mode 100644 index 0000000..4aee9cb --- /dev/null +++ b/core/apps/accounts/serializers/likes/likes.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.accounts.models import LikesModel + + +class BaseLikesSerializer(serializers.ModelSerializer): + class Meta: + model = LikesModel + fields = [ + "id", + "user", + ] + + +class ListLikesSerializer(BaseLikesSerializer): + class Meta(BaseLikesSerializer.Meta): ... + + +class RetrieveLikesSerializer(BaseLikesSerializer): + class Meta(BaseLikesSerializer.Meta): ... + + +class CreateLikesSerializer(BaseLikesSerializer): + class Meta(BaseLikesSerializer.Meta): + fields = [ + "id", + "user", + ] diff --git a/core/apps/accounts/serializers/participant/__init__.py b/core/apps/accounts/serializers/participant/__init__.py new file mode 100644 index 0000000..e031d58 --- /dev/null +++ b/core/apps/accounts/serializers/participant/__init__.py @@ -0,0 +1 @@ +from .participant import * # noqa diff --git a/core/apps/accounts/serializers/participant/participant.py b/core/apps/accounts/serializers/participant/participant.py new file mode 100644 index 0000000..f0cfd67 --- /dev/null +++ b/core/apps/accounts/serializers/participant/participant.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.accounts.models import ParticipantModel + + +class BaseParticipantSerializer(serializers.ModelSerializer): + class Meta: + model = ParticipantModel + fields = [ + "id", + "name", + ] + + +class ListParticipantSerializer(BaseParticipantSerializer): + class Meta(BaseParticipantSerializer.Meta): ... + + +class RetrieveParticipantSerializer(BaseParticipantSerializer): + class Meta(BaseParticipantSerializer.Meta): ... + + +class CreateParticipantSerializer(BaseParticipantSerializer): + class Meta(BaseParticipantSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/accounts/serializers/set_password.py b/core/apps/accounts/serializers/set_password.py new file mode 100644 index 0000000..556d530 --- /dev/null +++ b/core/apps/accounts/serializers/set_password.py @@ -0,0 +1,6 @@ +from rest_framework import serializers + + +class SetPasswordSerializer(serializers.Serializer): + password = serializers.CharField() + token = serializers.CharField(max_length=255) diff --git a/core/apps/accounts/serializers/user.py b/core/apps/accounts/serializers/user.py new file mode 100644 index 0000000..60f10d7 --- /dev/null +++ b/core/apps/accounts/serializers/user.py @@ -0,0 +1,23 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + exclude = [ + "created_at", + "updated_at", + "password", + "groups", + "user_permissions" + ] + model = get_user_model() + + +class UserUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = [ + "first_name", + "last_name" + ] diff --git a/core/apps/accounts/signals/__init__.py b/core/apps/accounts/signals/__init__.py new file mode 100644 index 0000000..d953cf6 --- /dev/null +++ b/core/apps/accounts/signals/__init__.py @@ -0,0 +1,3 @@ +from .likes import * # noqa +from .participant import * # noqa +from .user import * # noqa diff --git a/core/apps/accounts/signals/likes.py b/core/apps/accounts/signals/likes.py new file mode 100644 index 0000000..3dc1b19 --- /dev/null +++ b/core/apps/accounts/signals/likes.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.accounts.models import LikesModel + + +@receiver(post_save, sender=LikesModel) +def LikesSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/accounts/signals/participant.py b/core/apps/accounts/signals/participant.py new file mode 100644 index 0000000..43c13a5 --- /dev/null +++ b/core/apps/accounts/signals/participant.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.accounts.models import ParticipantModel + + +@receiver(post_save, sender=ParticipantModel) +def ParticipantSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/accounts/signals/user.py b/core/apps/accounts/signals/user.py new file mode 100644 index 0000000..414a614 --- /dev/null +++ b/core/apps/accounts/signals/user.py @@ -0,0 +1,10 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth import get_user_model + + +@receiver(post_save, sender=get_user_model()) +def user_signal(sender, created, instance, **kwargs): + if created and instance.username is None: + instance.username = "U%(id)s" % {"id": 1000 + instance.id} + instance.save() diff --git a/core/apps/accounts/tasks/__init__.py b/core/apps/accounts/tasks/__init__.py new file mode 100644 index 0000000..23f58fb --- /dev/null +++ b/core/apps/accounts/tasks/__init__.py @@ -0,0 +1 @@ +from .sms import * # noqa diff --git a/core/apps/accounts/tasks/sms.py b/core/apps/accounts/tasks/sms.py new file mode 100644 index 0000000..d7e8499 --- /dev/null +++ b/core/apps/accounts/tasks/sms.py @@ -0,0 +1,28 @@ +""" +Base celery tasks +""" + +import logging +import os +from importlib import import_module + +from celery import shared_task +from config.env import env +from django.utils.translation import gettext as _ + + +@shared_task +def SendConfirm(phone, code): + try: + service = getattr( + import_module(os.getenv("OTP_MODULE")), os.getenv("OTP_SERVICE") + )() + service.send_sms( + phone, env.str("OTP_MESSAGE", _("Sizning Tasdiqlash ko'dingiz: %(code)s")) % {"code": code} + ) + logging.info("Sms send: %s-%s" % (phone, code)) + except Exception as e: + logging.error( + "Error: {phone}-{code}\n\n{error}".format(phone=phone, code=code, error=e) + ) # noqa + raise Exception diff --git a/core/apps/accounts/test/__init__.py b/core/apps/accounts/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/test/test_auth.py b/core/apps/accounts/test/test_auth.py new file mode 100644 index 0000000..648c235 --- /dev/null +++ b/core/apps/accounts/test/test_auth.py @@ -0,0 +1,116 @@ +import logging +from unittest.mock import patch + +from django.test import TestCase +from django.urls import reverse +from pydantic import BaseModel +from rest_framework import status +from rest_framework.test import APIClient + +from core.apps.accounts.models import ResetToken +from django_core.models import SmsConfirm +from core.services import SmsService +from django.contrib.auth import get_user_model + + +class TokenModel(BaseModel): + access: str + refresh: str + + +class SmsViewTest(TestCase): + def setUp(self): + self.client = APIClient() + self.phone = "998999999999" + self.password = "password" + self.code = "1111" + self.token = "token" + self.user = get_user_model().objects.create_user( + phone=self.phone, first_name="John", last_name="Doe", password=self.password + ) + SmsConfirm.objects.create(phone=self.phone, code=self.code) + + def test_reg_view(self): + """Test register view.""" + data = { + "phone": "998999999991", + "first_name": "John", + "last_name": "Doe", + "password": "password", + } + with patch.object(SmsService, "send_confirm", return_value=True): + response = self.client.post(reverse("auth-register"), data=data) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual( + response.data["data"]["detail"], + "Sms %(phone)s raqamiga yuborildi" % {"phone": data["phone"]}, + ) + + def test_confirm_view(self): + """Test confirm view.""" + data = {"phone": self.phone, "code": self.code} + response = self.client.post(reverse("auth-confirm"), data=data) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + def test_invalid_confirm_view(self): + """Test confirm view.""" + data = {"phone": self.phone, "code": "1112"} + response = self.client.post(reverse("auth-confirm"), data=data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_reset_confirmation_code_view(self): + """Test reset confirmation code view.""" + data = {"phone": self.phone, "code": self.code} + response = self.client.post(reverse("auth-confirm"), data=data) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertIn("token", response.data["data"]) + + def test_reset_confirmation_code_view_invalid_code(self): + """Test reset confirmation code view with invalid code.""" + data = {"phone": self.phone, "code": "123456"} + response = self.client.post(reverse("auth-confirm"), data=data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_reset_set_password_view(self): + """Test reset set password view.""" + token = ResetToken.objects.create(user=self.user, token=self.token) + data = {"token": token.token, "password": "new_password"} + response = self.client.post(reverse("reset-password-reset-password-set"), data=data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_reset_set_password_view_invalid_token(self): + """Test reset set password view with invalid token.""" + token = "test_token" + data = {"token": token, "password": "new_password"} + with patch.object(get_user_model().objects, "filter", return_value=get_user_model().objects.none()): + response = self.client.post(reverse("reset-password-reset-password-set"), data=data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.data["data"]["detail"], "Invalid token") + + def test_resend_view(self): + """Test resend view.""" + data = {"phone": self.phone} + response = self.client.post(reverse("auth-resend"), data=data) + logging.error(response.json()) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_reset_password_view(self): + """Test reset password view.""" + data = {"phone": self.phone} + response = self.client.post(reverse("reset-password-reset-password"), data=data) + logging.error(response.json()) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_me_view(self): + """Test me view.""" + self.client.force_authenticate(user=self.user) + response = self.client.get(reverse("me-me")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_me_update_view(self): + """Test me update view.""" + self.client.force_authenticate(user=self.user) + data = {"first_name": "Updated"} + response = self.client.patch(reverse("me-user-update"), data=data) + logging.error(response.json()) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/core/apps/accounts/test/test_change_password.py b/core/apps/accounts/test/test_change_password.py new file mode 100644 index 0000000..1e8b136 --- /dev/null +++ b/core/apps/accounts/test/test_change_password.py @@ -0,0 +1,58 @@ +from core.apps.accounts.serializers import ChangePasswordSerializer +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + + +class ChangePasswordViewTest(TestCase): + def setUp(self): + self.client = APIClient() + self.phone = "9981111111" + self.password = "12345670" + self.path = reverse("change-password-change-password") + + self.user = get_user_model().objects.create_user( + phone=self.phone, password=self.password, email="test@example.com" + ) + self.client.force_authenticate(user=self.user) + + def test_change_password_success(self): + data = { + "old_password": self.password, + "new_password": "newpassword", + } + response = self.client.post(self.path, data=data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['data']["detail"], "password changed successfully") + self.assertTrue(self.user.check_password("newpassword")) + + def test_change_password_invalid_old_password(self): + data = { + "old_password": "wrongpassword", + "new_password": "newpassword", + } + response = self.client.post(self.path, data=data, format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.data['data']["detail"], "invalida password") + + def test_change_password_serializer_validation(self): + data = { + "old_password": self.password, + "new_password": "newpassword", + } + serializer = ChangePasswordSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + data = { + "old_password": self.password, + "new_password": "123", + } + serializer = ChangePasswordSerializer(data=data) + self.assertFalse(serializer.is_valid()) + + def test_change_password_view_permissions(self): + self.client.force_authenticate(user=None) + response = self.client.post(self.path, data={}, format="json") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/core/apps/accounts/tests/__init__.py b/core/apps/accounts/tests/__init__.py new file mode 100644 index 0000000..34821e2 --- /dev/null +++ b/core/apps/accounts/tests/__init__.py @@ -0,0 +1,2 @@ +from .test_likes import * # noqa +from .test_participant import * # noqa diff --git a/core/apps/accounts/tests/test_likes.py b/core/apps/accounts/tests/test_likes.py new file mode 100644 index 0000000..908ab4b --- /dev/null +++ b/core/apps/accounts/tests/test_likes.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.accounts.models import LikesModel + + +class LikesTest(TestCase): + + def _create_data(self): + return LikesModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("likes-list"), + "retrieve": reverse("likes-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("likes-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/accounts/tests/test_participant.py b/core/apps/accounts/tests/test_participant.py new file mode 100644 index 0000000..1538f1a --- /dev/null +++ b/core/apps/accounts/tests/test_participant.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.accounts.models import ParticipantModel + + +class ParticipantTest(TestCase): + + def _create_data(self): + return ParticipantModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("participant-list"), + "retrieve": reverse("participant-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("participant-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/accounts/translation/__init__.py b/core/apps/accounts/translation/__init__.py new file mode 100644 index 0000000..20a43c8 --- /dev/null +++ b/core/apps/accounts/translation/__init__.py @@ -0,0 +1,2 @@ +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/translation/likes.py b/core/apps/accounts/translation/likes.py new file mode 100644 index 0000000..2da1279 --- /dev/null +++ b/core/apps/accounts/translation/likes.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.accounts.models import LikesModel + + +@register(LikesModel) +class LikesTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/accounts/translation/participant.py b/core/apps/accounts/translation/participant.py new file mode 100644 index 0000000..c9cbc02 --- /dev/null +++ b/core/apps/accounts/translation/participant.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.accounts.models import ParticipantModel + + +@register(ParticipantModel) +class ParticipantTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/accounts/urls.py b/core/apps/accounts/urls.py new file mode 100644 index 0000000..ef45730 --- /dev/null +++ b/core/apps/accounts/urls.py @@ -0,0 +1,25 @@ +from .views import ParticipantView + +""" +Accounts app urls +""" +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt import views as jwt_views + +from .views import ChangePasswordView, MeView, RegisterView, ResetPasswordView +from .views.likes import LikesView + +router = DefaultRouter() +router.register("participant", ParticipantView, basename="participant") +router.register("auth", RegisterView, basename="auth") +router.register("auth", ResetPasswordView, basename="reset-password") +router.register("auth", MeView, basename="me") +router.register("auth", ChangePasswordView, basename="change-password") +router.register("likes", LikesView, basename="likes") +urlpatterns = [ + path("", include(router.urls)), + path("auth/token/", jwt_views.TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("auth/token/verify/", jwt_views.TokenVerifyView.as_view(), name="token_verify"), + path("auth/token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"), +] diff --git a/core/apps/accounts/validators/__init__.py b/core/apps/accounts/validators/__init__.py new file mode 100644 index 0000000..20a43c8 --- /dev/null +++ b/core/apps/accounts/validators/__init__.py @@ -0,0 +1,2 @@ +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/validators/likes.py b/core/apps/accounts/validators/likes.py new file mode 100644 index 0000000..8d3baba --- /dev/null +++ b/core/apps/accounts/validators/likes.py @@ -0,0 +1,8 @@ +# from django.core.exceptions import ValidationError + + +class LikesValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/accounts/validators/participant.py b/core/apps/accounts/validators/participant.py new file mode 100644 index 0000000..6a6c759 --- /dev/null +++ b/core/apps/accounts/validators/participant.py @@ -0,0 +1,8 @@ +# from django.core.exceptions import ValidationError + + +class ParticipantValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/accounts/views/__init__.py b/core/apps/accounts/views/__init__.py new file mode 100644 index 0000000..47abd40 --- /dev/null +++ b/core/apps/accounts/views/__init__.py @@ -0,0 +1,3 @@ +from .auth import * # noqa +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/views/auth.py b/core/apps/accounts/views/auth.py new file mode 100644 index 0000000..fff686d --- /dev/null +++ b/core/apps/accounts/views/auth.py @@ -0,0 +1,209 @@ +import uuid +from typing import Type + +from core.services import UserService, SmsService +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ +from django_core import exceptions +from drf_spectacular.utils import extend_schema +from rest_framework import status, throttling, request +from rest_framework.response import Response +from rest_framework.exceptions import PermissionDenied +from rest_framework.viewsets import GenericViewSet +from django_core.mixins import BaseViewSetMixin +from rest_framework.decorators import action +from ..serializers import ( + RegisterSerializer, + ConfirmSerializer, + ResendSerializer, + ResetPasswordSerializer, + ResetConfirmationSerializer, + SetPasswordSerializer, + UserSerializer, + UserUpdateSerializer, +) +from rest_framework.permissions import AllowAny +from django.contrib.auth.hashers import make_password +from drf_spectacular.utils import OpenApiResponse +from rest_framework.permissions import IsAuthenticated +from ..serializers import ChangePasswordSerializer + +from .. import models + + +@extend_schema(tags=["register"]) +class RegisterView(BaseViewSetMixin, GenericViewSet, UserService): + throttle_classes = [throttling.UserRateThrottle] + permission_classes = [AllowAny] + + def get_serializer_class(self): + match self.action: + case "register": + return RegisterSerializer + case "confirm": + return ConfirmSerializer + case "resend": + return ResendSerializer + case _: + return RegisterSerializer + + @action(methods=["POST"], detail=False, url_path="register") + def register(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.data + phone = data.get("phone") + # Create pending user + self.create_user(phone, data.get("first_name"), data.get("last_name"), data.get("password")) + self.send_confirmation(phone) # Send confirmation code for sms eskiz.uz + return Response( + {"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}}, + status=status.HTTP_202_ACCEPTED, + ) + + @extend_schema(summary="Auth confirm.", description="Auth confirm user.") + @action(methods=["POST"], detail=False, url_path="confirm") + def confirm(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.data + phone, code = data.get("phone"), data.get("code") + try: + if SmsService.check_confirm(phone, code=code): + token = self.validate_user(get_user_model().objects.filter(phone=phone).first()) + return Response( + data={ + "detail": _("Tasdiqlash ko'di qabul qilindi"), + "token": token, + }, + status=status.HTTP_202_ACCEPTED, + ) + except exceptions.SmsException as e: + raise PermissionDenied(e) # Response exception for APIException + except Exception as e: + raise PermissionDenied(e) # Api exception for APIException + + @action(methods=["POST"], detail=False, url_path="resend") + def resend(self, rq: Type[request.Request]): + ser = self.get_serializer(data=rq.data) + ser.is_valid(raise_exception=True) + phone = ser.data.get("phone") + self.send_confirmation(phone) + return Response({"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}}) + + +@extend_schema(tags=["reset-password"]) +class ResetPasswordView(BaseViewSetMixin, GenericViewSet, UserService): + permission_classes = [AllowAny] + + def get_serializer_class(self): + match self.action: + case "reset_password": + return ResetPasswordSerializer + case "reset_confirm": + return ResetConfirmationSerializer + case "reset_password_set": + return SetPasswordSerializer + case _: + return None + + @action(methods=["POST"], detail=False, url_path="reset-password") + def reset_password(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + phone = ser.data.get("phone") + self.send_confirmation(phone) + return Response({"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}}) + + @action(methods=["POST"], detail=False, url_path="reset-password-confirm") + def reset_confirm(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + + data = ser.data + code, phone = data.get("code"), data.get("phone") + try: + SmsService.check_confirm(phone, code) + token = models.ResetToken.objects.create( + user=get_user_model().objects.filter(phone=phone).first(), + token=str(uuid.uuid4()), + ) + return Response( + data={ + "token": token.token, + "created_at": token.created_at, + "updated_at": token.updated_at, + }, + status=status.HTTP_200_OK, + ) + except exceptions.SmsException as e: + raise PermissionDenied(str(e)) + except Exception as e: + raise PermissionDenied(str(e)) + + @action(methods=["POST"], detail=False, url_path="reset-password-set") + def reset_password_set(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.data + token = data.get("token") + password = data.get("password") + token = models.ResetToken.objects.filter(token=token) + if not token.exists(): + raise PermissionDenied(_("Invalid token")) + phone = token.first().user.phone + token.delete() + self.change_password(phone, password) + return Response({"detail": _("password updated")}, status=status.HTTP_200_OK) + + +@extend_schema(tags=["me"]) +class MeView(BaseViewSetMixin, GenericViewSet, UserService): + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + match self.action: + case "me": + return UserSerializer + case "user_update": + return UserUpdateSerializer + case _: + return None + + @action(methods=["GET", "OPTIONS"], detail=False, url_path="me") + def me(self, request): + return Response(self.get_serializer(request.user).data) + + @action(methods=["PATCH", "PUT"], detail=False, url_path="user-update") + def user_update(self, request): + ser = self.get_serializer(instance=request.user, data=request.data, partial=True) + ser.is_valid(raise_exception=True) + ser.save() + return Response({"detail": _("Malumotlar yangilandi")}) + + +@extend_schema(tags=["change-password"], description="Parolni o'zgartirish uchun") +class ChangePasswordView(BaseViewSetMixin, GenericViewSet): + serializer_class = ChangePasswordSerializer + permission_classes = (IsAuthenticated,) + + @extend_schema( + request=serializer_class, + responses={200: OpenApiResponse(ChangePasswordSerializer)}, + summary="Change user password.", + description="Change password of the authenticated user.", + ) + @action(methods=["POST"], detail=False, url_path="change-password") + def change_password(self, request, *args, **kwargs): + user = self.request.user + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + if user.check_password(request.data["old_password"]): + user.password = make_password(request.data["new_password"]) + user.save() + return Response( + data={"detail": "password changed successfully"}, + status=status.HTTP_200_OK, + ) + raise PermissionDenied(_("invalida password")) diff --git a/core/apps/accounts/views/likes.py b/core/apps/accounts/views/likes.py new file mode 100644 index 0000000..37ae43b --- /dev/null +++ b/core/apps/accounts/views/likes.py @@ -0,0 +1,21 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.accounts.models import LikesModel +from core.apps.accounts.serializers.likes import CreateLikesSerializer, ListLikesSerializer, RetrieveLikesSerializer + + +@extend_schema(tags=["likes"]) +class LikesView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = LikesModel.objects.all() + serializer_class = ListLikesSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListLikesSerializer, + "retrieve": RetrieveLikesSerializer, + "create": CreateLikesSerializer, + } diff --git a/core/apps/accounts/views/participant.py b/core/apps/accounts/views/participant.py new file mode 100644 index 0000000..40f6800 --- /dev/null +++ b/core/apps/accounts/views/participant.py @@ -0,0 +1,25 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.accounts.models import ParticipantModel +from core.apps.accounts.serializers.participant import ( + CreateParticipantSerializer, + ListParticipantSerializer, + RetrieveParticipantSerializer, +) + + +@extend_schema(tags=["participant"]) +class ParticipantView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = ParticipantModel.objects.all() + serializer_class = ListParticipantSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListParticipantSerializer, + "retrieve": RetrieveParticipantSerializer, + "create": CreateParticipantSerializer, + } diff --git a/core/apps/api/__init__.py b/core/apps/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/admin/__init__.py b/core/apps/api/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/apps.py b/core/apps/api/apps.py new file mode 100644 index 0000000..02f0401 --- /dev/null +++ b/core/apps/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.api" diff --git a/core/apps/api/migrations/__init__.py b/core/apps/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/models/__init__.py b/core/apps/api/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/serializers/__init__.py b/core/apps/api/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/tests/__init__.py b/core/apps/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/urls.py b/core/apps/api/urls.py new file mode 100644 index 0000000..5fa41be --- /dev/null +++ b/core/apps/api/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() + + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/core/apps/api/views/__init__.py b/core/apps/api/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/blog/__init__.py b/core/apps/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/blog/admin/__init__.py b/core/apps/blog/admin/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/admin/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/admin/post.py b/core/apps/blog/admin/post.py new file mode 100644 index 0000000..e12325b --- /dev/null +++ b/core/apps/blog/admin/post.py @@ -0,0 +1,36 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +@admin.register(PostModel) +class PostAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) + + +@admin.register(TagModel) +class TagAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) + + +@admin.register(CategoryModel) +class CategoryAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) + + +@admin.register(PostimagesModel) +class PostimagesAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/blog/apps.py b/core/apps/blog/apps.py new file mode 100644 index 0000000..f9dd9fe --- /dev/null +++ b/core/apps/blog/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.blog" diff --git a/core/apps/blog/filters/__init__.py b/core/apps/blog/filters/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/filters/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/filters/post.py b/core/apps/blog/filters/post.py new file mode 100644 index 0000000..ae60be8 --- /dev/null +++ b/core/apps/blog/filters/post.py @@ -0,0 +1,43 @@ +from django_filters import rest_framework as filters + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +class PostFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = PostModel + fields = [ + "name", + ] + + +class TagFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = TagModel + fields = [ + "name", + ] + + +class CategoryFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = CategoryModel + fields = [ + "name", + ] + + +class PostimagesFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = PostimagesModel + fields = [ + "name", + ] diff --git a/core/apps/blog/forms/__init__.py b/core/apps/blog/forms/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/forms/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/forms/post.py b/core/apps/blog/forms/post.py new file mode 100644 index 0000000..a80ffba --- /dev/null +++ b/core/apps/blog/forms/post.py @@ -0,0 +1,31 @@ +from django import forms + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +class PostForm(forms.ModelForm): + + class Meta: + model = PostModel + fields = "__all__" + + +class TagForm(forms.ModelForm): + + class Meta: + model = TagModel + fields = "__all__" + + +class CategoryForm(forms.ModelForm): + + class Meta: + model = CategoryModel + fields = "__all__" + + +class PostimagesForm(forms.ModelForm): + + class Meta: + model = PostimagesModel + fields = "__all__" diff --git a/core/apps/blog/migrations/0001_initial.py b/core/apps/blog/migrations/0001_initial.py new file mode 100644 index 0000000..d0af5a2 --- /dev/null +++ b/core/apps/blog/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 5.1.3 on 2025-09-19 10:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CategoryModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ], + options={ + 'verbose_name': 'CategoryModel', + 'verbose_name_plural': 'CategoryModels', + 'db_table': 'category', + }, + ), + migrations.CreateModel( + name='PostModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=255, verbose_name='name')), + ('text', models.TextField(verbose_name='text')), + ('slug', models.SlugField(max_length=255, unique=True, verbose_name='slug')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ('updated', models.DateTimeField(auto_now=True, verbose_name='updated')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_category', to='blog.categorymodel', verbose_name='category')), + ], + options={ + 'verbose_name': 'PostModel', + 'verbose_name_plural': 'PostModels', + 'db_table': 'post', + }, + ), + migrations.CreateModel( + name='PostimagesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='post/images/', verbose_name='image')), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='blog.postmodel', verbose_name='post')), + ], + options={ + 'verbose_name': 'PostimagesModel', + 'verbose_name_plural': 'PostimagesModels', + 'db_table': 'PostImages', + }, + ), + migrations.CreateModel( + name='TagModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_tags', to='blog.postmodel', verbose_name='post')), + ], + options={ + 'verbose_name': 'TagModel', + 'verbose_name_plural': 'TagModels', + 'db_table': 'tag', + }, + ), + ] diff --git a/core/apps/blog/migrations/__init__.py b/core/apps/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/blog/models/__init__.py b/core/apps/blog/models/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/models/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/models/post.py b/core/apps/blog/models/post.py new file mode 100644 index 0000000..5877076 --- /dev/null +++ b/core/apps/blog/models/post.py @@ -0,0 +1,92 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel +from django.utils.text import slugify + + +class CategoryModel(AbstractBaseModel): + name = models.CharField(verbose_name=_("name"), max_length=255) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + ) + + class Meta: + db_table = "category" + verbose_name = _("CategoryModel") + verbose_name_plural = _("CategoryModels") + + +class PostModel(AbstractBaseModel): + title = models.CharField(verbose_name=_("name"), max_length=255) + text = models.TextField(verbose_name=_("text")) + category = models.ForeignKey(CategoryModel, related_name="post_category", verbose_name=_("category"), + on_delete=models.CASCADE) + slug = models.SlugField(verbose_name=_("slug"), max_length=255, unique=True) + created = models.DateTimeField(verbose_name=_("created"), auto_now_add=True) + updated = models.DateTimeField(verbose_name=_("updated"), auto_now=True) + + def __str__(self): + return str(self.pk) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + super().save(*args, **kwargs) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + text="mock", + ) + + class Meta: + db_table = "post" + verbose_name = _("PostModel") + verbose_name_plural = _("PostModels") + + +class TagModel(AbstractBaseModel): + name = models.CharField(verbose_name=_("name"), max_length=255) + post = models.ForeignKey(PostModel, verbose_name=_("post"), related_name="post_tags", on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + post=PostModel._create_fake(), + ) + + class Meta: + db_table = "tag" + verbose_name = _("TagModel") + verbose_name_plural = _("TagModels") + + +class PostimagesModel(AbstractBaseModel): + image = models.ImageField(verbose_name=_("image"), upload_to="post/images/") + post = models.ForeignKey(PostModel, verbose_name=_("post"), related_name="post_images", on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/default_avatar.jpg", + post=PostModel._create_fake(), + ) + + class Meta: + db_table = "PostImages" + verbose_name = _("PostimagesModel") + verbose_name_plural = _("PostimagesModels") diff --git a/core/apps/blog/permissions/__init__.py b/core/apps/blog/permissions/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/permissions/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/permissions/post.py b/core/apps/blog/permissions/post.py new file mode 100644 index 0000000..b5b2998 --- /dev/null +++ b/core/apps/blog/permissions/post.py @@ -0,0 +1,45 @@ +from rest_framework import permissions + + +class PostPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True + + +class TagPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True + + +class CategoryPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True + + +class PostimagesPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/blog/serializers/__init__.py b/core/apps/blog/serializers/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/serializers/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/serializers/post/__init__.py b/core/apps/blog/serializers/post/__init__.py new file mode 100644 index 0000000..efc52e0 --- /dev/null +++ b/core/apps/blog/serializers/post/__init__.py @@ -0,0 +1,3 @@ +from .category import * # noqa +from .post import * # noqa +from .tag import * # noqa diff --git a/core/apps/blog/serializers/post/category.py b/core/apps/blog/serializers/post/category.py new file mode 100644 index 0000000..bf47b9c --- /dev/null +++ b/core/apps/blog/serializers/post/category.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.blog.models import CategoryModel + + +class BaseCategorySerializer(serializers.ModelSerializer): + class Meta: + model = CategoryModel + fields = [ + "id", + "name", + ] + + +class ListCategorySerializer(BaseCategorySerializer): + class Meta(BaseCategorySerializer.Meta): ... + + +class RetrieveCategorySerializer(BaseCategorySerializer): + class Meta(BaseCategorySerializer.Meta): ... + + +class CreateCategorySerializer(BaseCategorySerializer): + class Meta(BaseCategorySerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/blog/serializers/post/post.py b/core/apps/blog/serializers/post/post.py new file mode 100644 index 0000000..9630ebe --- /dev/null +++ b/core/apps/blog/serializers/post/post.py @@ -0,0 +1,29 @@ +from core.apps.blog.models import PostModel +from rest_framework import serializers + + +class BasePostSerializer(serializers.ModelSerializer): + class Meta: + model = PostModel + fields = [ + "id", + "name", + + ] + + +class ListPostSerializer(BasePostSerializer): + class Meta(BasePostSerializer.Meta): ... + + +class RetrievePostSerializer(BasePostSerializer): + class Meta(BasePostSerializer.Meta): ... + + +class CreatePostSerializer(BasePostSerializer): + class Meta(BasePostSerializer.Meta): + fields = [ + "id", + "name", + + ] diff --git a/core/apps/blog/serializers/post/tag.py b/core/apps/blog/serializers/post/tag.py new file mode 100644 index 0000000..21245cf --- /dev/null +++ b/core/apps/blog/serializers/post/tag.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.blog.models import TagModel + + +class BaseTagSerializer(serializers.ModelSerializer): + class Meta: + model = TagModel + fields = [ + "id", + "name", + ] + + +class ListTagSerializer(BaseTagSerializer): + class Meta(BaseTagSerializer.Meta): ... + + +class RetrieveTagSerializer(BaseTagSerializer): + class Meta(BaseTagSerializer.Meta): ... + + +class CreateTagSerializer(BaseTagSerializer): + class Meta(BaseTagSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/blog/signals/__init__.py b/core/apps/blog/signals/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/signals/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/signals/post.py b/core/apps/blog/signals/post.py new file mode 100644 index 0000000..c906be3 --- /dev/null +++ b/core/apps/blog/signals/post.py @@ -0,0 +1,20 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +@receiver(post_save, sender=PostModel) +def PostSignal(sender, instance, created, **kwargs): ... + + +@receiver(post_save, sender=TagModel) +def TagSignal(sender, instance, created, **kwargs): ... + + +@receiver(post_save, sender=CategoryModel) +def CategorySignal(sender, instance, created, **kwargs): ... + + +@receiver(post_save, sender=PostimagesModel) +def PostimagesSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/blog/tests/__init__.py b/core/apps/blog/tests/__init__.py new file mode 100644 index 0000000..3331203 --- /dev/null +++ b/core/apps/blog/tests/__init__.py @@ -0,0 +1 @@ +from .test_post import * # noqa diff --git a/core/apps/blog/tests/test_post.py b/core/apps/blog/tests/test_post.py new file mode 100644 index 0000000..352e2f4 --- /dev/null +++ b/core/apps/blog/tests/test_post.py @@ -0,0 +1,173 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +class PostTest(TestCase): + + def _create_data(self): + return PostModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("post -list"), + "retrieve": reverse("post -detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("post -detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) + + +class TagTest(TestCase): + + def _create_data(self): + return TagModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("tag-list"), + "retrieve": reverse("tag-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("tag-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) + + +class CategoryTest(TestCase): + + def _create_data(self): + return CategoryModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("category-list"), + "retrieve": reverse("category-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("category-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) + + +class PostimagesTest(TestCase): + + def _create_data(self): + return PostimagesModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("PostImages-list"), + "retrieve": reverse("PostImages-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("PostImages-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/blog/translation/__init__.py b/core/apps/blog/translation/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/translation/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/translation/post.py b/core/apps/blog/translation/post.py new file mode 100644 index 0000000..b08bbde --- /dev/null +++ b/core/apps/blog/translation/post.py @@ -0,0 +1,23 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +@register(PostModel) +class PostTranslation(TranslationOptions): + fields = [] + + +@register(TagModel) +class TagTranslation(TranslationOptions): + fields = [] + + +@register(CategoryModel) +class CategoryTranslation(TranslationOptions): + fields = [] + + +@register(PostimagesModel) +class PostimagesTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/blog/urls.py b/core/apps/blog/urls.py new file mode 100644 index 0000000..5cfe54d --- /dev/null +++ b/core/apps/blog/urls.py @@ -0,0 +1,9 @@ +from .views import CategoryView, TagView, PostView +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() +router.register("post", PostView, basename="post") +router.register("category", CategoryView, basename="category") +router.register("tag", TagView, basename="tag") +urlpatterns = [path("", include(router.urls))] diff --git a/core/apps/blog/validators/__init__.py b/core/apps/blog/validators/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/validators/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/validators/post.py b/core/apps/blog/validators/post.py new file mode 100644 index 0000000..fcb3fe4 --- /dev/null +++ b/core/apps/blog/validators/post.py @@ -0,0 +1,29 @@ +# from django.core.exceptions import ValidationError + + +class PostValidator: + def __init__(self): ... + + def __call__(self): + return True + + +class TagValidator: + def __init__(self): ... + + def __call__(self): + return True + + +class CategoryValidator: + def __init__(self): ... + + def __call__(self): + return True + + +class PostimagesValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/blog/views/__init__.py b/core/apps/blog/views/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/views/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/views/post.py b/core/apps/blog/views/post.py new file mode 100644 index 0000000..2031adb --- /dev/null +++ b/core/apps/blog/views/post.py @@ -0,0 +1,59 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.blog.models import CategoryModel, PostModel, TagModel +from core.apps.blog.serializers.post import ( + CreateCategorySerializer, + CreatePostSerializer, + CreateTagSerializer, + ListCategorySerializer, + ListPostSerializer, + ListTagSerializer, + RetrieveCategorySerializer, + RetrievePostSerializer, + RetrieveTagSerializer, +) + + +@extend_schema(tags=["post"]) +class PostView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = PostModel.objects.all() + serializer_class = ListPostSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListPostSerializer, + "retrieve": RetrievePostSerializer, + "create": CreatePostSerializer, + } + + +@extend_schema(tags=["tag"]) +class TagView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = TagModel.objects.all() + serializer_class = ListTagSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListTagSerializer, + "retrieve": RetrieveTagSerializer, + "create": CreateTagSerializer, + } + + +@extend_schema(tags=["category"]) +class CategoryView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = CategoryModel.objects.all() + serializer_class = ListCategorySerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListCategorySerializer, + "retrieve": RetrieveCategorySerializer, + "create": CreateCategorySerializer, + } diff --git a/core/apps/logs/.gitignore b/core/apps/logs/.gitignore new file mode 100644 index 0000000..a3a0c8b --- /dev/null +++ b/core/apps/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/core/apps/payments/__init__.py b/core/apps/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/admin/__init__.py b/core/apps/payments/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/apps.py b/core/apps/payments/apps.py new file mode 100644 index 0000000..9aab28f --- /dev/null +++ b/core/apps/payments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.payments" diff --git a/core/apps/payments/migrations/__init__.py b/core/apps/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/models/__init__.py b/core/apps/payments/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/serializers/__init__.py b/core/apps/payments/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/tests/__init__.py b/core/apps/payments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/urls.py b/core/apps/payments/urls.py new file mode 100644 index 0000000..5fa41be --- /dev/null +++ b/core/apps/payments/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() + + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/core/apps/payments/views/__init__.py b/core/apps/payments/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/shared/__init__.py b/core/apps/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/shared/admin/__init__.py b/core/apps/shared/admin/__init__.py new file mode 100644 index 0000000..134e613 --- /dev/null +++ b/core/apps/shared/admin/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/admin/settings.py b/core/apps/shared/admin/settings.py new file mode 100644 index 0000000..e07e246 --- /dev/null +++ b/core/apps/shared/admin/settings.py @@ -0,0 +1,20 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin, StackedInline +from core.apps.shared.models import SettingsModel, OptionsModel +from unfold.contrib.forms.widgets import ArrayWidget +from django.contrib.postgres.fields import ArrayField + + +class OptionsInline(StackedInline): + model = OptionsModel + extra = 1 + formfield_overrides = { + ArrayField: {"widget": ArrayWidget}, + } + + +@admin.register(SettingsModel) +class SettingsAdmin(ModelAdmin): + list_display = ["id", "key"] + inlines = [OptionsInline] + diff --git a/core/apps/shared/apps.py b/core/apps/shared/apps.py new file mode 100644 index 0000000..534230a --- /dev/null +++ b/core/apps/shared/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.shared" diff --git a/core/apps/shared/enums/__init__.py b/core/apps/shared/enums/__init__.py new file mode 100644 index 0000000..7e6f430 --- /dev/null +++ b/core/apps/shared/enums/__init__.py @@ -0,0 +1,17 @@ +from enum import Enum + + +class BaseEnum(Enum): + + def choices(self): + return [(x.name, x.value) for x in self] + + +class GenderEnum(BaseEnum): + MALE = "male" + FEMALE = "female" + + +class RoleEnum(BaseEnum): + ADMIN = "admin" + USER = "user" diff --git a/core/apps/shared/migrations/0001_initial.py b/core/apps/shared/migrations/0001_initial.py new file mode 100644 index 0000000..2991c7c --- /dev/null +++ b/core/apps/shared/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.3 on 2025-09-19 10:19 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SettingsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('key', models.CharField(verbose_name='key')), + ('is_public', models.BooleanField(default=False, verbose_name='is public')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ], + options={ + 'verbose_name': 'Settings', + 'verbose_name_plural': 'Settings', + 'db_table': 'settings', + }, + ), + migrations.CreateModel( + name='OptionsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=255, verbose_name='key')), + ('value', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, verbose_name='value'), size=None, verbose_name='value')), + ('settings', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='shared.settingsmodel', verbose_name='settings')), + ], + options={ + 'verbose_name': 'Options', + 'verbose_name_plural': 'Options', + 'db_table': 'options', + }, + ), + ] diff --git a/core/apps/shared/migrations/__init__.py b/core/apps/shared/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/shared/models/__init__.py b/core/apps/shared/models/__init__.py new file mode 100644 index 0000000..134e613 --- /dev/null +++ b/core/apps/shared/models/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/models/settings.py b/core/apps/shared/models/settings.py new file mode 100644 index 0000000..98537a0 --- /dev/null +++ b/core/apps/shared/models/settings.py @@ -0,0 +1,31 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel + + +class SettingsModel(AbstractBaseModel): + key = models.CharField(_("key")) + is_public = models.BooleanField(_("is public"), default=False) + description = models.TextField(_("description"), blank=True, null=True) + + class Meta: + db_table = "settings" + verbose_name = _("Settings") + verbose_name_plural = _("Settings") + + +class OptionsModel(models.Model): + settings = models.ForeignKey( + "SettingsModel", verbose_name=_("settings"), on_delete=models.CASCADE, related_name="options" + ) + key = models.CharField(_("key"), max_length=255) + value = ArrayField( + models.CharField(_("value"), max_length=255), + verbose_name=_("value"), + ) + + class Meta: + db_table = "options" + verbose_name = _("Options") + verbose_name_plural = _("Options") diff --git a/core/apps/shared/serializers/__init__.py b/core/apps/shared/serializers/__init__.py new file mode 100644 index 0000000..fdf02cf --- /dev/null +++ b/core/apps/shared/serializers/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/serializers/settings/__init__.py b/core/apps/shared/serializers/settings/__init__.py new file mode 100644 index 0000000..134e613 --- /dev/null +++ b/core/apps/shared/serializers/settings/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/serializers/settings/settings.py b/core/apps/shared/serializers/settings/settings.py new file mode 100644 index 0000000..37fd78d --- /dev/null +++ b/core/apps/shared/serializers/settings/settings.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + + +class ListLanguageSerializer(serializers.Serializer): + code = serializers.CharField(read_only=True) + name = serializers.CharField(read_only=True) + is_default = serializers.BooleanField(read_only=True, default=False) diff --git a/core/apps/shared/tests/__init__.py b/core/apps/shared/tests/__init__.py new file mode 100644 index 0000000..838c01f --- /dev/null +++ b/core/apps/shared/tests/__init__.py @@ -0,0 +1 @@ +from .test_settings import * # noqa diff --git a/core/apps/shared/tests/test_settings.py b/core/apps/shared/tests/test_settings.py new file mode 100644 index 0000000..6516a52 --- /dev/null +++ b/core/apps/shared/tests/test_settings.py @@ -0,0 +1,16 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + + +class SettingsTest(TestCase): + + def setUp(self): + self.client = APIClient() + self.urls = { + "languages": reverse("settings-languages"), + } + + def test_languages(self): + response = self.client.get(self.urls["languages"]) + self.assertEqual(response.status_code, 200) diff --git a/core/apps/shared/urls.py b/core/apps/shared/urls.py new file mode 100644 index 0000000..bc256db --- /dev/null +++ b/core/apps/shared/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import SettingsView + +router = DefaultRouter() +router.register("settings", SettingsView, basename="settings") + + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/core/apps/shared/utils/__init__.py b/core/apps/shared/utils/__init__.py new file mode 100644 index 0000000..134e613 --- /dev/null +++ b/core/apps/shared/utils/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/utils/settings.py b/core/apps/shared/utils/settings.py new file mode 100644 index 0000000..ff0c229 --- /dev/null +++ b/core/apps/shared/utils/settings.py @@ -0,0 +1,17 @@ +from core.apps.shared.models import OptionsModel +from typing import Optional +from django.utils.translation import gettext_lazy as _ + + +def get_config(settings: str, key: str, default=None) -> Optional[str]: + config = OptionsModel.objects.filter(settings__key=settings, key=key) + if not config.exists(): + return default + return config.first().value + + +def get_exchange_rate(): + exchange_rate = get_config("currency", "exchange_rate") + if exchange_rate is None: + raise Exception(_("USD kursi kiritilmagan iltimos adminga murojat qiling")) + return float(exchange_rate[0]) diff --git a/core/apps/shared/views/__init__.py b/core/apps/shared/views/__init__.py new file mode 100644 index 0000000..edbb5e5 --- /dev/null +++ b/core/apps/shared/views/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa diff --git a/core/apps/shared/views/settings.py b/core/apps/shared/views/settings.py new file mode 100644 index 0000000..d55f5c3 --- /dev/null +++ b/core/apps/shared/views/settings.py @@ -0,0 +1,53 @@ +from django_core.mixins import BaseViewSetMixin +from rest_framework.permissions import AllowAny +from rest_framework.decorators import action +from rest_framework.viewsets import GenericViewSet +from django.conf import settings +from rest_framework.response import Response +from ..serializers import ListLanguageSerializer +from drf_spectacular.utils import extend_schema, OpenApiResponse +from core.apps.shared.models import SettingsModel + + +@extend_schema(tags=["settings"]) +class SettingsView(BaseViewSetMixin, GenericViewSet): + permission_classes = [AllowAny] + + def get_serializer_class(self): + if self.action in ["languages"]: + return ListLanguageSerializer + return ListLanguageSerializer + + @extend_schema(responses={200: OpenApiResponse(response=ListLanguageSerializer(many=True))}) + @action(methods=["GET"], detail=False, url_path="languages", url_name="languages") + def languages(self, request): + return Response(self.get_serializer(settings.JST_LANGUAGES, many=True).data) + + @extend_schema( + summary="Get public settings", + responses={ + 200: OpenApiResponse( + response={ + "type": "object", + "properties": { + "example_key": { + "type": "object", + "properties": { + "example_key": {"type": "array", "items": {"type": "string"}, "example": [12300.50]} + }, + } + }, + } + ) + }, + ) + @action(methods=["GET"], detail=False, url_path="config", url_name="config") + def config(self, request): + config = SettingsModel.objects.filter(is_public=True) + response = {} + for item in config: + config_value = {} + for option in item.options.all(): + config_value[option.key] = option.value + response[item.key] = config_value + return Response(data=response) diff --git a/core/apps/tickets/__init__.py b/core/apps/tickets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/tickets/admin/__init__.py b/core/apps/tickets/admin/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/admin/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/admin/extra_services.py b/core/apps/tickets/admin/extra_services.py new file mode 100644 index 0000000..7596e86 --- /dev/null +++ b/core/apps/tickets/admin/extra_services.py @@ -0,0 +1,23 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +@admin.register(ExtraServicesModel) +class ExtraServocesAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) + + + + + +@admin.register(PaidServicesModel) +class PaidServicesAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/tickets/admin/hotel.py b/core/apps/tickets/admin/hotel.py new file mode 100644 index 0000000..e684bee --- /dev/null +++ b/core/apps/tickets/admin/hotel.py @@ -0,0 +1,20 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.tickets.models import HotelModel, HotelImagesModel + + +class HotelImagesInline(admin.TabularInline): + model = HotelImagesModel + extra = 1 + + +@admin.register(HotelModel) +class HotelAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) + inlines = ( + HotelImagesInline, + ) diff --git a/core/apps/tickets/admin/tariff.py b/core/apps/tickets/admin/tariff.py new file mode 100644 index 0000000..c98f63f --- /dev/null +++ b/core/apps/tickets/admin/tariff.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.tickets.models import TariffModel + + +@admin.register(TariffModel) +class TariffAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/tickets/admin/tickets.py b/core/apps/tickets/admin/tickets.py new file mode 100644 index 0000000..16c5633 --- /dev/null +++ b/core/apps/tickets/admin/tickets.py @@ -0,0 +1,80 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.tickets.models import ( + TicketorderModel, + TicketsAmenitiesModel, + TicketsCommentsModel, + TicketsHotelMealsModel, + TicketsImagesModel, + TicketsIncludedServicesModel, + TicketsItineraryDestinationsModel, + TicketsItineraryImagesModel, + TicketsItineraryModel, + TicketsModel, +) + + +class TicketsImagesInline(admin.TabularInline): + model = TicketsImagesModel + extra = 1 + + +class TicketsAmenitiesInline(admin.TabularInline): + model = TicketsAmenitiesModel + extra = 1 + + +class TicketsIncludedServicesInline(admin.TabularInline): + model = TicketsIncludedServicesModel + extra = 1 + + +class TicketsHotelMealsInline(admin.TabularInline): + model = TicketsHotelMealsModel + extra = 1 + + +@admin.register(TicketsModel) +class TicketsAdmin(ModelAdmin): + list_display = ("id", "title", "price", "departure_date", "destination", "passenger_count", "rating") + inlines = [ + TicketsImagesInline, + TicketsAmenitiesInline, + TicketsIncludedServicesInline, + TicketsHotelMealsInline, + ] + + +class TicketsItineraryImagesInline(admin.TabularInline): + model = TicketsItineraryImagesModel + extra = 1 + + +class TicketsItineraryDestinationsInline(admin.TabularInline): + model = TicketsItineraryDestinationsModel + extra = 1 + + +@admin.register(TicketsItineraryModel) +class TicketsItineraryAdmin(ModelAdmin): + list_display = ("id", "ticket", "title", "duration") + inlines = [TicketsItineraryImagesInline, TicketsItineraryDestinationsInline] + + +@admin.register(TicketsCommentsModel) +class TicketsCommentsAdmin(ModelAdmin): + list_display = ( + "user", + "text", + "rating", + "ticket", + ) + + +@admin.register(TicketorderModel) +class TicketorderAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/tickets/apps.py b/core/apps/tickets/apps.py new file mode 100644 index 0000000..48b1764 --- /dev/null +++ b/core/apps/tickets/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.tickets" + label = "tickets" diff --git a/core/apps/tickets/filters/__init__.py b/core/apps/tickets/filters/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/filters/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/filters/extra_services.py b/core/apps/tickets/filters/extra_services.py new file mode 100644 index 0000000..c2123bf --- /dev/null +++ b/core/apps/tickets/filters/extra_services.py @@ -0,0 +1,23 @@ +from django_filters import rest_framework as filters + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +class ExtraServicesFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = ExtraServicesModel + fields = [ + "name", + ] + + +class PaidServicesFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = PaidServicesModel + fields = [ + "name", + ] diff --git a/core/apps/tickets/filters/hotel.py b/core/apps/tickets/filters/hotel.py new file mode 100644 index 0000000..5f26e5f --- /dev/null +++ b/core/apps/tickets/filters/hotel.py @@ -0,0 +1,14 @@ +from django_filters import rest_framework as filters + +from core.apps.tickets.models import HotelModel, HotelImagesModel + + +class HotelFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = HotelModel + fields = [ + "name", + ] + diff --git a/core/apps/tickets/filters/tariff.py b/core/apps/tickets/filters/tariff.py new file mode 100644 index 0000000..0f9c29f --- /dev/null +++ b/core/apps/tickets/filters/tariff.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.tickets.models import TariffModel + + +class TariffFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = TariffModel + fields = [ + "name", + ] diff --git a/core/apps/tickets/filters/tickets.py b/core/apps/tickets/filters/tickets.py new file mode 100644 index 0000000..ceaf113 --- /dev/null +++ b/core/apps/tickets/filters/tickets.py @@ -0,0 +1,23 @@ +from django_filters import rest_framework as filters + +from core.apps.tickets.models import TicketorderModel, TicketsModel + + +class TicketsFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = TicketsModel + fields = [ + "name", + ] + + +class TicketorderFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = TicketorderModel + fields = [ + "name", + ] diff --git a/core/apps/tickets/forms/__init__.py b/core/apps/tickets/forms/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/forms/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/forms/extra_services.py b/core/apps/tickets/forms/extra_services.py new file mode 100644 index 0000000..3d53445 --- /dev/null +++ b/core/apps/tickets/forms/extra_services.py @@ -0,0 +1,17 @@ +from django import forms + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +class ExtraServicesForm(forms.ModelForm): + + class Meta: + model = ExtraServicesModel + fields = "__all__" + + +class PaidServicesForm(forms.ModelForm): + + class Meta: + model = PaidServicesModel + fields = "__all__" diff --git a/core/apps/tickets/forms/hotel.py b/core/apps/tickets/forms/hotel.py new file mode 100644 index 0000000..35798ff --- /dev/null +++ b/core/apps/tickets/forms/hotel.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.tickets.models import HotelModel + + +class HotelForm(forms.ModelForm): + + class Meta: + model = HotelModel + fields = "__all__" diff --git a/core/apps/tickets/forms/paid_services.py b/core/apps/tickets/forms/paid_services.py new file mode 100644 index 0000000..dc455f3 --- /dev/null +++ b/core/apps/tickets/forms/paid_services.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.tickets.models import PaidServicesModel + + +class PaidServicesForm(forms.ModelForm): + + class Meta: + model = PaidServicesModel + fields = "__all__" diff --git a/core/apps/tickets/forms/tariff.py b/core/apps/tickets/forms/tariff.py new file mode 100644 index 0000000..eab73d8 --- /dev/null +++ b/core/apps/tickets/forms/tariff.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.tickets.models import TariffModel + + +class TariffForm(forms.ModelForm): + + class Meta: + model = TariffModel + fields = "__all__" diff --git a/core/apps/tickets/forms/tickets.py b/core/apps/tickets/forms/tickets.py new file mode 100644 index 0000000..de47152 --- /dev/null +++ b/core/apps/tickets/forms/tickets.py @@ -0,0 +1,17 @@ +from django import forms + +from core.apps.tickets.models import TicketorderModel, TicketsModel + + +class TicketsForm(forms.ModelForm): + + class Meta: + model = TicketsModel + fields = "__all__" + + +class TicketorderForm(forms.ModelForm): + + class Meta: + model = TicketorderModel + fields = "__all__" diff --git a/core/apps/tickets/migrations/0001_initial.py b/core/apps/tickets/migrations/0001_initial.py new file mode 100644 index 0000000..4cf07fe --- /dev/null +++ b/core/apps/tickets/migrations/0001_initial.py @@ -0,0 +1,298 @@ +# Generated by Django 5.1.3 on 2025-09-19 10:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExtraServicesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ], + options={ + 'verbose_name': 'ExtraServicesModel', + 'verbose_name_plural': 'ExtraServicesModels', + 'db_table': 'extra_services', + }, + ), + migrations.CreateModel( + name='HotelModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('address', models.TextField(verbose_name='address')), + ('city', models.CharField(max_length=100, verbose_name='city')), + ('country', models.CharField(max_length=100, verbose_name='city')), + ('desc', models.TextField(blank=True, null=True, verbose_name='description')), + ('phone', models.CharField(blank=True, max_length=50, null=True, verbose_name='phone number')), + ('website', models.URLField(blank=True, null=True, verbose_name='hotel website')), + ('rating', models.FloatField(blank=True, null=True, verbose_name='rating')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'HotelModel', + 'verbose_name_plural': 'HotelModels', + 'db_table': 'hotel', + }, + ), + migrations.CreateModel( + name='PaidServicesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('price', models.IntegerField(verbose_name='price')), + ], + options={ + 'verbose_name': 'PaidServicesModel', + 'verbose_name_plural': 'PaidServicesModels', + 'db_table': 'paid_services', + }, + ), + migrations.CreateModel( + name='TariffModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(choices=[('3', 'Standart'), ('4', 'Comfort'), ('5', 'Luxury')], default=('3', 'Standart'), max_length=255)), + ], + options={ + 'verbose_name': 'TariffModel', + 'verbose_name_plural': 'TariffModels', + 'db_table': 'tariff', + }, + ), + migrations.CreateModel( + name='TicketsItineraryModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('duration', models.IntegerField(verbose_name='duration')), + ], + options={ + 'verbose_name': 'TicketsItineraryModel', + 'verbose_name_plural': 'TicketsItineraryModel', + 'db_table': 'tickets_itinerary', + }, + ), + migrations.CreateModel( + name='HotelImagesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='hotel_images/', verbose_name='image')), + ('hotel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.hotelmodel', verbose_name='hotel')), + ], + options={ + 'verbose_name': 'HotelImagesModel', + 'verbose_name_plural': 'HotelImagesModels', + 'db_table': 'hotel_images', + }, + ), + migrations.CreateModel( + name='TicketsItineraryImagesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='ticket-included-services-images/', verbose_name='image')), + ('tickets_itinerary', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_itinerary_image', to='tickets.ticketsitinerarymodel', verbose_name='tickets_itinerary')), + ], + options={ + 'verbose_name': 'TicketsItineraryImagesModel', + 'verbose_name_plural': 'TicketsItineraryImagesModel', + 'db_table': 'tickets_itinerary_images', + }, + ), + migrations.CreateModel( + name='TicketsItineraryDestinationsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('tickets_itinerary', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_itinerary_destinations', to='tickets.ticketsitinerarymodel', verbose_name='tickets_itinerary')), + ], + options={ + 'verbose_name': 'TicketsItineraryDestinationsModel', + 'verbose_name_plural': 'TicketsItineraryDestinationsModel', + 'db_table': 'tickets_itinerary_destinations', + }, + ), + migrations.CreateModel( + name='TicketsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('price', models.BigIntegerField(verbose_name='price')), + ('departure_date', models.DateField(verbose_name='departure date')), + ('departure', models.CharField(max_length=255, verbose_name='departure')), + ('destination', models.CharField(max_length=255, verbose_name='destination')), + ('departure_time', models.DateTimeField(verbose_name='departure time')), + ('travel_time', models.DateTimeField(verbose_name='travel time')), + ('passenger_count', models.IntegerField(verbose_name='passenger count')), + ('languages', models.CharField(max_length=255, verbose_name='languages')), + ('rating', models.FloatField(verbose_name='rating')), + ('hotel_info', models.TextField(verbose_name='hotel info')), + ('duration_days', models.IntegerField(verbose_name='duration days')), + ('hotel_meals', models.TextField(blank=True, null=True, verbose_name='hotel meals')), + ('slug', models.SlugField(max_length=255, unique=True, verbose_name='slug')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('tariff', models.ManyToManyField(related_name='ticket_tariffs', to='tickets.tariffmodel', verbose_name='tariff')), + ], + options={ + 'verbose_name': 'TicketsModel', + 'verbose_name_plural': 'TicketsModels', + 'db_table': 'tickets', + }, + ), + migrations.AddField( + model_name='ticketsitinerarymodel', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_itinerary', to='tickets.ticketsmodel', verbose_name='ticket'), + ), + migrations.CreateModel( + name='TicketsIncludedServicesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='ticket-included-services-images/', verbose_name='image')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('desc', models.TextField(verbose_name='description')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_included_services', to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketsIncludedServicesModel', + 'verbose_name_plural': 'TicketsIncludedServicesModel', + 'db_table': 'tickets_included_services', + }, + ), + migrations.CreateModel( + name='TicketsImagesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='ticket-images/', verbose_name='image')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_images', to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketsImagesModel', + 'verbose_name_plural': 'TicketsImagesModel', + 'db_table': 'tickets_images', + }, + ), + migrations.CreateModel( + name='TicketsHotelMealsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='ticket-hotel-meals/', verbose_name='image')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('desc', models.TextField(verbose_name='description')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_hotel_meals', to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketsHotelMealsModel', + 'verbose_name_plural': 'TicketsHotelMealsModel', + 'db_table': 'tickets_hotel_meals', + }, + ), + migrations.CreateModel( + name='TicketsCommentsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('text', models.TextField(verbose_name='text')), + ('rating', models.FloatField(verbose_name='rating')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_comments', to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketsCommentsModel', + 'verbose_name_plural': 'TicketsCommentsModel', + 'db_table': 'tickets_comments', + }, + ), + migrations.CreateModel( + name='TicketsAmenitiesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_amenities', to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketsAmenitiesModel', + 'verbose_name_plural': 'TicketsAmenitiesModel', + 'db_table': 'tickets_amenities', + }, + ), + migrations.CreateModel( + name='TicketorderModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('departure', models.CharField(max_length=255, verbose_name='departure')), + ('destination', models.CharField(max_length=255, verbose_name='destination')), + ('departure_date', models.DateField(verbose_name='departure date')), + ('arrival_time', models.DateField(verbose_name='arrival time')), + ('tariff', models.CharField(max_length=255, verbose_name='tariff')), + ('transport', models.CharField(max_length=255, verbose_name='transport')), + ('order_status', models.CharField(choices=[('pending_payment', 'Pending Payment'), ('pending_confirmation', 'Pending Confirmation'), ('cancelled', 'Cancelled'), ('confirmed', 'Confirmed'), ('completed', 'Completed')], default='pending_payment', max_length=255, verbose_name='order status')), + ('total_price', models.BigIntegerField(verbose_name='total price')), + ('extra_paid_service', models.ManyToManyField(to='tickets.paidservicesmodel', verbose_name='extra paid services')), + ('extra_service', models.ManyToManyField(to='tickets.extraservicesmodel', verbose_name='extra services')), + ('participant', models.ManyToManyField(blank=True, null=True, to='accounts.participantmodel', verbose_name='participant')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketorderModel', + 'verbose_name_plural': 'TicketorderModels', + 'db_table': 'ticketorder', + }, + ), + migrations.AddField( + model_name='paidservicesmodel', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_extra_paid_service', to='tickets.ticketsmodel', verbose_name='ticket'), + ), + migrations.AddField( + model_name='hotelmodel', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_hotel', to='tickets.ticketsmodel', verbose_name='ticket'), + ), + migrations.AddField( + model_name='extraservicesmodel', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_extra_service', to='tickets.ticketsmodel', verbose_name='ticket'), + ), + ] diff --git a/core/apps/tickets/migrations/__init__.py b/core/apps/tickets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/tickets/models/__init__.py b/core/apps/tickets/models/__init__.py new file mode 100644 index 0000000..78aded8 --- /dev/null +++ b/core/apps/tickets/models/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa \ No newline at end of file diff --git a/core/apps/tickets/models/extra_services.py b/core/apps/tickets/models/extra_services.py new file mode 100644 index 0000000..af8e70b --- /dev/null +++ b/core/apps/tickets/models/extra_services.py @@ -0,0 +1,49 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel +from ..models.tickets import TicketsModel + + +class ExtraServicesModel(AbstractBaseModel): + name = models.CharField(verbose_name=_("name"), max_length=255) + ticket = models.ForeignKey(TicketsModel, verbose_name=_("ticket"), related_name="ticket_extra_service", + on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "extra_services" + verbose_name = _("ExtraServicesModel") + verbose_name_plural = _("ExtraServicesModels") + + +class PaidServicesModel(AbstractBaseModel): + name = models.CharField(verbose_name=_("name"), max_length=255) + price = models.IntegerField(verbose_name=_("price")) + ticket = models.ForeignKey(TicketsModel, verbose_name=_("ticket"), related_name="ticket_extra_paid_service", + on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + price=40, + ticket=TicketsModel._create_fake(), + + ) + + class Meta: + db_table = "paid_services" + verbose_name = _("PaidServicesModel") + verbose_name_plural = _("PaidServicesModels") diff --git a/core/apps/tickets/models/hotel.py b/core/apps/tickets/models/hotel.py new file mode 100644 index 0000000..b7d6a25 --- /dev/null +++ b/core/apps/tickets/models/hotel.py @@ -0,0 +1,60 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel +from .tickets import TicketsModel + + +class HotelModel(AbstractBaseModel): + ticket = models.ForeignKey(TicketsModel, related_name="ticket_hotel", verbose_name=_("ticket"), + on_delete=models.CASCADE) + name = models.CharField(verbose_name=_("name"), max_length=255) + address = models.TextField(verbose_name=_("address")) + city = models.CharField(verbose_name=_("city"), max_length=100) + country = models.CharField(verbose_name=_("city"), max_length=100) + desc = models.TextField(verbose_name=_("description"), blank=True, null=True) + phone = models.CharField(verbose_name=_("phone number"), max_length=50, blank=True, null=True) + website = models.URLField(verbose_name=_("hotel website"), blank=True, null=True) + rating = models.FloatField(verbose_name=_("rating"), blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + address="mock", + city="mock", + country="mock", + desc="mock", + phone="mock", + website="https://www.default.com/", + rating=4.5, + ) + + class Meta: + db_table = "hotel" + verbose_name = _("HotelModel") + verbose_name_plural = _("HotelModels") + + +class HotelImagesModel(AbstractBaseModel): + image = models.ImageField(verbose_name=_("image"), upload_to="hotel_images/") + hotel = models.ForeignKey(HotelModel, verbose_name=_("hotel"), on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/logo.png", + hotel=HotelModel._create_fake(), + ) + + class Meta: + db_table = "hotel_images" + verbose_name = _("HotelImagesModel") + verbose_name_plural = _("HotelImagesModels") diff --git a/core/apps/tickets/models/tariff.py b/core/apps/tickets/models/tariff.py new file mode 100644 index 0000000..70c2763 --- /dev/null +++ b/core/apps/tickets/models/tariff.py @@ -0,0 +1,27 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel + + +class TariffModel(AbstractBaseModel): + Tariff_Choise = ( + ("3", "Standart"), + ("4", "Comfort"), + ("5", "Luxury"), + ) + + name = models.CharField(choices=Tariff_Choise, max_length=255, default=Tariff_Choise[0]) + + def __str__(self): + return str(self.name) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + ) + + class Meta: + db_table = "tariff" + verbose_name = _("TariffModel") + verbose_name_plural = _("TariffModels") diff --git a/core/apps/tickets/models/tickets.py b/core/apps/tickets/models/tickets.py new file mode 100644 index 0000000..be23254 --- /dev/null +++ b/core/apps/tickets/models/tickets.py @@ -0,0 +1,322 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel + +from core.apps.accounts.models import User +from core.apps.accounts.models.participant import ParticipantModel + +from .tariff import TariffModel +from django.utils.text import slugify + + +class TicketsModel(AbstractBaseModel): + """Chiptalar haqidagi asosiy ma’lumotlarni saqlaydi.""" + + title = models.CharField(verbose_name=_("title"), max_length=255) + price = models.BigIntegerField(verbose_name=_("price")) + departure_date = models.DateField(verbose_name=_("departure date")) + departure = models.CharField(verbose_name=_("departure"), max_length=255) + destination = models.CharField(verbose_name=_("destination"), max_length=255) + ############# + # Bu ikkisi aniq bo'lgandan keyin yo olib tashlanadi yoki qoladi ! + departure_time = models.DateTimeField(verbose_name=_("departure time")) + travel_time = models.DateTimeField(verbose_name=_("travel time")) + ############## + passenger_count = models.IntegerField(verbose_name=_("passenger count")) + languages = models.CharField(verbose_name=_("languages"), max_length=255) + rating = models.FloatField(verbose_name=_("rating")) + hotel_info = models.TextField(verbose_name=_("hotel info")) + duration_days = models.IntegerField(verbose_name=_("duration days")) + hotel_meals = models.TextField(verbose_name=_("hotel meals"), null=True, blank=True) + tariff = models.ManyToManyField(TariffModel, related_name="ticket_tariffs", verbose_name=_("tariff")) + slug = models.SlugField(verbose_name=_("slug"), max_length=255, unique=True) + created_at = models.DateTimeField(verbose_name=_("created at"), auto_now_add=True) + updated_at = models.DateTimeField(verbose_name=_("updated at"), auto_now=True) + + def __str__(self): + return str(self.pk) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + super().save(*args, **kwargs) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + price=150000, + departure_date="2025-10-01", + destination="mock", + departure="mock", + passenger_count=4, + rating=4.5, + hotel_info="mock", + duration_days=15, + ) + + class Meta: + db_table = "tickets" + verbose_name = _("TicketsModel") + verbose_name_plural = _("TicketsModels") + + +class TicketsImagesModel(AbstractBaseModel): + """Chipta bilan bog‘liq rasmlarni saqlaydi.""" + + image = models.ImageField(verbose_name=_("image"), upload_to="ticket-images/") + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_images", on_delete=models.CASCADE + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/logo.png", + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_images" + verbose_name = _("TicketsImagesModel") + verbose_name_plural = _("TicketsImagesModel") + + +class TicketsAmenitiesModel(AbstractBaseModel): + """Chipta ichidagi qulayliklarni saqlaydi.""" + + name = models.CharField(verbose_name=_("name"), max_length=255) + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_amenities", on_delete=models.CASCADE + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_amenities" + verbose_name = _("TicketsAmenitiesModel") + verbose_name_plural = _("TicketsAmenitiesModel") + + +class TicketsIncludedServicesModel(AbstractBaseModel): + """Chipta narxiga kiradigan xizmatlarni saqlaydi.""" + + image = models.ImageField(verbose_name=_("image"), upload_to="ticket-included-services-images/") + title = models.CharField(verbose_name=_("title"), max_length=255) + desc = models.TextField(verbose_name=_("description")) + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_included_services", on_delete=models.CASCADE + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/logo.png", + title="mock", + desc="mock", + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_included_services" + verbose_name = _("TicketsIncludedServicesModel") + verbose_name_plural = _("TicketsIncludedServicesModel") + + +class TicketsItineraryModel(AbstractBaseModel): + """Chipta bo‘yicha safar rejasini saqlaydi.""" + + title = models.CharField(verbose_name=_("title"), max_length=255) + duration = models.IntegerField(verbose_name=_("duration")) + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_itinerary", on_delete=models.CASCADE + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + title="mock", + duration=4, + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_itinerary" + verbose_name = _("TicketsItineraryModel") + verbose_name_plural = _("TicketsItineraryModel") + + +class TicketsItineraryImagesModel(AbstractBaseModel): + """Safar rejasiga oid rasmlarni saqlaydi.""" + + image = models.ImageField(verbose_name=_("image"), upload_to="ticket-included-services-images/") + tickets_itinerary = models.ForeignKey( + TicketsItineraryModel, + verbose_name=_("tickets_itinerary"), + related_name="ticket_itinerary_image", + on_delete=models.CASCADE, + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/logo.png", + tickets_itinerary=TicketsItineraryModel._create_fake(), + ) + + class Meta: + db_table = "tickets_itinerary_images" + verbose_name = _("TicketsItineraryImagesModel") + verbose_name_plural = _("TicketsItineraryImagesModel") + + +class TicketsItineraryDestinationsModel(AbstractBaseModel): + """Safar davomida boriladigan manzillarni saqlaydi.""" + + name = models.CharField(verbose_name=_("name"), max_length=255) + tickets_itinerary = models.ForeignKey( + TicketsItineraryModel, + verbose_name=_("tickets_itinerary"), + related_name="ticket_itinerary_destinations", + on_delete=models.CASCADE, + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + tickets_itinerary=TicketsItineraryModel._create_fake(), + ) + + class Meta: + db_table = "tickets_itinerary_destinations" + verbose_name = _("TicketsItineraryDestinationsModel") + verbose_name_plural = _("TicketsItineraryDestinationsModel") + + +class TicketsHotelMealsModel(AbstractBaseModel): + """Mehmonxonada beriladigan taomlarni saqlaydi.""" + + image = models.ImageField(verbose_name=_("image"), upload_to="ticket-hotel-meals/") + name = models.CharField(verbose_name=_("name"), max_length=255) + desc = models.TextField(verbose_name=_("description")) + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_hotel_meals", on_delete=models.CASCADE + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + desc="mock", + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_hotel_meals" + verbose_name = _("TicketsHotelMealsModel") + verbose_name_plural = _("TicketsHotelMealsModel") + + +class TicketsCommentsModel(AbstractBaseModel): + """Chiptaga yozilgan sharh va baholarni saqlaydi.""" + + user = models.ForeignKey(User, verbose_name=_("user"), on_delete=models.CASCADE) + text = models.TextField(verbose_name=_("text")) + rating = models.FloatField(verbose_name=_("rating")) + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_comments", on_delete=models.CASCADE + ) + created = models.DateTimeField(verbose_name=_("created"), auto_now_add=True) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + user=User._create_fake(), + text="mock", + rating=4.5, + tickets_itinerary=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_comments" + verbose_name = _("TicketsCommentsModel") + verbose_name_plural = _("TicketsCommentsModel") + + +class TicketorderModel(AbstractBaseModel): + STATUS_CHOICES = [ + ("pending_payment", "Pending Payment"), + ("pending_confirmation", "Pending Confirmation"), + ("cancelled", "Cancelled"), + ("confirmed", "Confirmed"), + ("completed", "Completed"), + ] + user = models.ForeignKey(User, verbose_name=_("user"), on_delete=models.CASCADE) + departure = models.CharField(verbose_name=_("departure"), max_length=255) + destination = models.CharField(verbose_name=_("destination"), max_length=255) + departure_date = models.DateField(verbose_name=_("departure date")) + arrival_time = models.DateField(verbose_name=_("arrival time")) + participant = models.ManyToManyField(ParticipantModel, verbose_name=_("participant"), null=True, blank=True) + ticket = models.ForeignKey(TicketsModel, verbose_name=_("ticket"), on_delete=models.CASCADE) + tariff = models.CharField(verbose_name=_("tariff"), max_length=255) + transport = models.CharField(verbose_name=_("transport"), max_length=255) + extra_service = models.ManyToManyField("tickets.ExtraServicesModel", verbose_name=_("extra services")) + extra_paid_service = models.ManyToManyField("tickets.PaidServicesModel", verbose_name=_("extra paid services")) + order_status = models.CharField(verbose_name=_("order status"), max_length=255, choices=STATUS_CHOICES, + default="pending_payment") + total_price = models.BigIntegerField(verbose_name=_("total price")) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + user=User._create_fake(), + departure="mock", + destination="mock", + departure_date="2025-12-01", + arrival_time="2025-12-01", + participant=ParticipantModel._create_fake(), + ticket=TicketsModel._create_fake(), + tariff="mock", + transport="mock", + extra_service=ExtraServicesModel._create_fake(), + extra_paid_service=PaidServicesModel._create_fake(), + total_price=40, + ) + + class Meta: + db_table = "ticketorder" + verbose_name = _("TicketorderModel") + verbose_name_plural = _("TicketorderModels") diff --git a/core/apps/tickets/permissions/__init__.py b/core/apps/tickets/permissions/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/permissions/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/permissions/extra_services.py b/core/apps/tickets/permissions/extra_services.py new file mode 100644 index 0000000..f998155 --- /dev/null +++ b/core/apps/tickets/permissions/extra_services.py @@ -0,0 +1,23 @@ +from rest_framework import permissions + + +class ExtraServicesPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True + + +class PaidServicesPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/tickets/permissions/hotel.py b/core/apps/tickets/permissions/hotel.py new file mode 100644 index 0000000..c0c92b9 --- /dev/null +++ b/core/apps/tickets/permissions/hotel.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class HotelPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/tickets/permissions/tariff.py b/core/apps/tickets/permissions/tariff.py new file mode 100644 index 0000000..440d83d --- /dev/null +++ b/core/apps/tickets/permissions/tariff.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class TariffPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/tickets/permissions/tickets.py b/core/apps/tickets/permissions/tickets.py new file mode 100644 index 0000000..9cb05f1 --- /dev/null +++ b/core/apps/tickets/permissions/tickets.py @@ -0,0 +1,23 @@ +from rest_framework import permissions + + +class TicketsPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True + + +class TicketorderPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/tickets/serializers/__init__.py b/core/apps/tickets/serializers/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/serializers/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/serializers/extra_services/__init__.py b/core/apps/tickets/serializers/extra_services/__init__.py new file mode 100644 index 0000000..bb6bf69 --- /dev/null +++ b/core/apps/tickets/serializers/extra_services/__init__.py @@ -0,0 +1,2 @@ +from .extra_services import * # noqa +from .paid_services import * # noqa diff --git a/core/apps/tickets/serializers/extra_services/extra_services.py b/core/apps/tickets/serializers/extra_services/extra_services.py new file mode 100644 index 0000000..80e61d0 --- /dev/null +++ b/core/apps/tickets/serializers/extra_services/extra_services.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.tickets.models import ExtraServicesModel + + +class BaseExtraServicesSerializer(serializers.ModelSerializer): + class Meta: + model = ExtraServicesModel + fields = [ + "id", + "name", + ] + + +class ListExtraServicesSerializer(BaseExtraServicesSerializer): + class Meta(BaseExtraServicesSerializer.Meta): ... + + +class RetrieveExtraServicesSerializer(BaseExtraServicesSerializer): + class Meta(BaseExtraServicesSerializer.Meta): ... + + +class CreateExtraServicesSerializer(BaseExtraServicesSerializer): + class Meta(BaseExtraServicesSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/tickets/serializers/extra_services/paid_services.py b/core/apps/tickets/serializers/extra_services/paid_services.py new file mode 100644 index 0000000..781e205 --- /dev/null +++ b/core/apps/tickets/serializers/extra_services/paid_services.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.tickets.models import PaidServicesModel + + +class BasePaidServicesSerializer(serializers.ModelSerializer): + class Meta: + model = PaidServicesModel + fields = [ + "id", + "name", + ] + + +class ListPaidServicesSerializer(BasePaidServicesSerializer): + class Meta(BasePaidServicesSerializer.Meta): ... + + +class RetrievePaidServicesSerializer(BasePaidServicesSerializer): + class Meta(BasePaidServicesSerializer.Meta): ... + + +class CreatePaidServicesSerializer(BasePaidServicesSerializer): + class Meta(BasePaidServicesSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/tickets/serializers/hotel/__init__.py b/core/apps/tickets/serializers/hotel/__init__.py new file mode 100644 index 0000000..e8ca454 --- /dev/null +++ b/core/apps/tickets/serializers/hotel/__init__.py @@ -0,0 +1 @@ +from .hotel import * # noqa diff --git a/core/apps/tickets/serializers/hotel/hotel.py b/core/apps/tickets/serializers/hotel/hotel.py new file mode 100644 index 0000000..9e57abd --- /dev/null +++ b/core/apps/tickets/serializers/hotel/hotel.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.tickets.models import HotelModel + + +class BaseHotelSerializer(serializers.ModelSerializer): + class Meta: + model = HotelModel + fields = [ + "id", + "name", + ] + + +class ListHotelSerializer(BaseHotelSerializer): + class Meta(BaseHotelSerializer.Meta): ... + + +class RetrieveHotelSerializer(BaseHotelSerializer): + class Meta(BaseHotelSerializer.Meta): ... + + +class CreateHotelSerializer(BaseHotelSerializer): + class Meta(BaseHotelSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/tickets/serializers/tariff/__init__.py b/core/apps/tickets/serializers/tariff/__init__.py new file mode 100644 index 0000000..d78761d --- /dev/null +++ b/core/apps/tickets/serializers/tariff/__init__.py @@ -0,0 +1 @@ +from .tariff import * # noqa diff --git a/core/apps/tickets/serializers/tariff/tariff.py b/core/apps/tickets/serializers/tariff/tariff.py new file mode 100644 index 0000000..1c56cc9 --- /dev/null +++ b/core/apps/tickets/serializers/tariff/tariff.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.tickets.models import TariffModel + + +class BaseTariffSerializer(serializers.ModelSerializer): + class Meta: + model = TariffModel + fields = [ + "id", + "name", + ] + + +class ListTariffSerializer(BaseTariffSerializer): + class Meta(BaseTariffSerializer.Meta): ... + + +class RetrieveTariffSerializer(BaseTariffSerializer): + class Meta(BaseTariffSerializer.Meta): ... + + +class CreateTariffSerializer(BaseTariffSerializer): + class Meta(BaseTariffSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/tickets/serializers/tickets/__init__.py b/core/apps/tickets/serializers/tickets/__init__.py new file mode 100644 index 0000000..8205739 --- /dev/null +++ b/core/apps/tickets/serializers/tickets/__init__.py @@ -0,0 +1,2 @@ +from .ticketorder import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/serializers/tickets/ticketorder.py b/core/apps/tickets/serializers/tickets/ticketorder.py new file mode 100644 index 0000000..e0943b5 --- /dev/null +++ b/core/apps/tickets/serializers/tickets/ticketorder.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.tickets.models import TicketorderModel + + +class BaseTicketorderSerializer(serializers.ModelSerializer): + class Meta: + model = TicketorderModel + fields = [ + "id", + "name", + ] + + +class ListTicketorderSerializer(BaseTicketorderSerializer): + class Meta(BaseTicketorderSerializer.Meta): ... + + +class RetrieveTicketorderSerializer(BaseTicketorderSerializer): + class Meta(BaseTicketorderSerializer.Meta): ... + + +class CreateTicketorderSerializer(BaseTicketorderSerializer): + class Meta(BaseTicketorderSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/tickets/serializers/tickets/tickets.py b/core/apps/tickets/serializers/tickets/tickets.py new file mode 100644 index 0000000..560a90a --- /dev/null +++ b/core/apps/tickets/serializers/tickets/tickets.py @@ -0,0 +1,132 @@ +from rest_framework import serializers +from core.apps.tickets.models import TicketsModel, TicketsImagesModel, TicketsAmenitiesModel, \ + TicketsIncludedServicesModel, TicketsItineraryModel, TicketsItineraryImagesModel, TicketsItineraryDestinationsModel, \ + TicketsHotelMealsModel, TicketsCommentsModel +from core.apps.accounts.models import User + +from core.apps.accounts.serializers.user import UserSerializer +from core.apps.tickets.models.tariff import TariffModel + + +class TicketsTariffSerializer(serializers.ModelSerializer): + class Meta: + model = TariffModel + fields = ['name'] + + +class CommentUserSerializer(UserSerializer): + class Meta: + model = User + fields = ["id", "username"] + + +class TicketsCommentsSerializer(serializers.ModelSerializer): + user = CommentUserSerializer(read_only=True) + + class Meta: + model = TicketsCommentsModel + fields = ["user", "text", "rating"] + + +class TicketsHotelMealsSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsHotelMealsModel + fields = ["image", "name", "desc"] + + +class TicketsItineraryDestinationsSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsItineraryDestinationsModel + fields = ['name'] + + +class TicketsItineraryImagesSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsItineraryImagesModel + fields = ['image'] + + +class TicketsItinerarySerializer(serializers.ModelSerializer): + ticket_itinerary_image = TicketsItineraryImagesSerializer(many=True, read_only=True) + ticket_itinerary_destinations = TicketsItineraryDestinationsSerializer(many=True, read_only=True) + + class Meta: + model = TicketsItineraryModel + fields = ["title", "duration", "ticket_itinerary_image", "ticket_itinerary_destinations"] + + +class TicketsIncludedServicesSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsIncludedServicesModel + fields = ["image", "title", "desc", ] + + +class TicketsAmenitiesSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsAmenitiesModel + fields = ["name"] + + +class TicketsImageSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsImagesModel + fields = ["image"] + + +class BaseTicketsSerializer(serializers.ModelSerializer): + ticket_images = TicketsImageSerializer(many=True, read_only=True) + ticket_amenities = TicketsAmenitiesSerializer(many=True, read_only=True) + ticket_included_services = TicketsIncludedServicesSerializer(many=True, read_only=True) + ticket_itinerary = TicketsItinerarySerializer(many=True, read_only=True) + ticket_hotel_meals = TicketsHotelMealsSerializer(many=True, read_only=True) + ticket_comments = TicketsCommentsSerializer(many=True, read_only=True) + tariff = TicketsTariffSerializer(many=True, read_only=True) + + class Meta: + model = TicketsModel + fields = [ + "id", + "title", + "price", + "departure_date", + "departure", + "destination", + "passenger_count", + "rating", + "hotel_info", + "duration_days", + "hotel_meals", + "ticket_images", + "ticket_amenities", + "ticket_included_services", + "ticket_itinerary", + "ticket_hotel_meals", + "ticket_comments", + "tariff", + + ] + + +class ListTicketsSerializer(BaseTicketsSerializer): + class Meta(BaseTicketsSerializer.Meta): ... + + +class RetrieveTicketsSerializer(BaseTicketsSerializer): + class Meta(BaseTicketsSerializer.Meta): ... + + +class CreateTicketsSerializer(BaseTicketsSerializer): + class Meta(BaseTicketsSerializer.Meta): + fields = [ + "id", + "title", + "price", + "departure_date", + "departure", + "destination", + "passenger_count", + "rating", + "hotel_info", + "duration_days", + "hotel_meals", + ] diff --git a/core/apps/tickets/signals/__init__.py b/core/apps/tickets/signals/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/signals/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/signals/extra_services.py b/core/apps/tickets/signals/extra_services.py new file mode 100644 index 0000000..8844aee --- /dev/null +++ b/core/apps/tickets/signals/extra_services.py @@ -0,0 +1,12 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +@receiver(post_save, sender=ExtraServicesModel) +def ExtraServicesSignal(sender, instance, created, **kwargs): ... + + +@receiver(post_save, sender=PaidServicesModel) +def PaidServicesSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/tickets/signals/hotel.py b/core/apps/tickets/signals/hotel.py new file mode 100644 index 0000000..9f749df --- /dev/null +++ b/core/apps/tickets/signals/hotel.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.tickets.models import HotelModel + + +@receiver(post_save, sender=HotelModel) +def HotelSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/tickets/signals/tariff.py b/core/apps/tickets/signals/tariff.py new file mode 100644 index 0000000..140d30a --- /dev/null +++ b/core/apps/tickets/signals/tariff.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.tickets.models import TariffModel + + +@receiver(post_save, sender=TariffModel) +def TariffSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/tickets/signals/tickets.py b/core/apps/tickets/signals/tickets.py new file mode 100644 index 0000000..42ad96c --- /dev/null +++ b/core/apps/tickets/signals/tickets.py @@ -0,0 +1,12 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.tickets.models import TicketorderModel, TicketsModel + + +@receiver(post_save, sender=TicketsModel) +def TicketsSignal(sender, instance, created, **kwargs): ... + + +@receiver(post_save, sender=TicketorderModel) +def TicketorderSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/tickets/tests/__init__.py b/core/apps/tickets/tests/__init__.py new file mode 100644 index 0000000..4f4ee37 --- /dev/null +++ b/core/apps/tickets/tests/__init__.py @@ -0,0 +1,5 @@ +from .test_extra_services import * # noqa +from .test_hotel import * # noqa +from .test_paid_services import * # noqa +from .test_tariff import * # noqa +from .test_tickets import * # noqa diff --git a/core/apps/tickets/tests/test_extra_services.py b/core/apps/tickets/tests/test_extra_services.py new file mode 100644 index 0000000..bc72990 --- /dev/null +++ b/core/apps/tickets/tests/test_extra_services.py @@ -0,0 +1,89 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +class ExtraServicesTest(TestCase): + + def _create_data(self): + return ExtraServicesModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("extra_servoces-list"), + "retrieve": reverse("extra_servoces-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("extra_servoces-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) + + +class PaidServicesTest(TestCase): + + def _create_data(self): + return PaidServicesModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("paid_services-list"), + "retrieve": reverse("paid_services-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("paid_services-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/tickets/tests/test_hotel.py b/core/apps/tickets/tests/test_hotel.py new file mode 100644 index 0000000..fa64d0b --- /dev/null +++ b/core/apps/tickets/tests/test_hotel.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.tickets.models import HotelModel + + +class HotelTest(TestCase): + + def _create_data(self): + return HotelModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("hotel-list"), + "retrieve": reverse("hotel-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("hotel-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/tickets/tests/test_tariff.py b/core/apps/tickets/tests/test_tariff.py new file mode 100644 index 0000000..651ea1c --- /dev/null +++ b/core/apps/tickets/tests/test_tariff.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.tickets.models import TariffModel + + +class TariffTest(TestCase): + + def _create_data(self): + return TariffModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("tariff-list"), + "retrieve": reverse("tariff-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("tariff-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/tickets/tests/test_tickets.py b/core/apps/tickets/tests/test_tickets.py new file mode 100644 index 0000000..fa03179 --- /dev/null +++ b/core/apps/tickets/tests/test_tickets.py @@ -0,0 +1,89 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.tickets.models import TicketorderModel, TicketsModel + + +class TicketsTest(TestCase): + + def _create_data(self): + return TicketsModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("tickets-list"), + "retrieve": reverse("tickets-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("tickets-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) + + +class TicketorderTest(TestCase): + + def _create_data(self): + return TicketorderModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("ticketorder-list"), + "retrieve": reverse("ticketorder-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("ticketorder-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/tickets/translation/__init__.py b/core/apps/tickets/translation/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/translation/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/translation/extra_services.py b/core/apps/tickets/translation/extra_services.py new file mode 100644 index 0000000..3fe33fd --- /dev/null +++ b/core/apps/tickets/translation/extra_services.py @@ -0,0 +1,13 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +@register(ExtraServicesModel) +class ExtraServicesTranslation(TranslationOptions): + fields = [] + + +@register(PaidServicesModel) +class PaidServicesTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/tickets/translation/hotel.py b/core/apps/tickets/translation/hotel.py new file mode 100644 index 0000000..c369c87 --- /dev/null +++ b/core/apps/tickets/translation/hotel.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.tickets.models import HotelModel + + +@register(HotelModel) +class HotelTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/tickets/translation/tariff.py b/core/apps/tickets/translation/tariff.py new file mode 100644 index 0000000..49f0743 --- /dev/null +++ b/core/apps/tickets/translation/tariff.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.tickets.models import TariffModel + + +@register(TariffModel) +class TariffTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/tickets/translation/tickets.py b/core/apps/tickets/translation/tickets.py new file mode 100644 index 0000000..15a510e --- /dev/null +++ b/core/apps/tickets/translation/tickets.py @@ -0,0 +1,13 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.tickets.models import TicketorderModel, TicketsModel + + +@register(TicketsModel) +class TicketsTranslation(TranslationOptions): + fields = [] + + +@register(TicketorderModel) +class TicketorderTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/tickets/urls.py b/core/apps/tickets/urls.py new file mode 100644 index 0000000..35d80a4 --- /dev/null +++ b/core/apps/tickets/urls.py @@ -0,0 +1,15 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ExtraServicesView, PaidServicesView, TariffView, TicketorderView +from .views.hotel import HotelView +from .views.tickets import TicketsView + +router = DefaultRouter() +router.register("ticketorder", TicketorderView, basename="ticketorder") +router.register("paid_services", PaidServicesView, basename="paid_services") +router.register("extra_services", ExtraServicesView, basename="extra_services") +router.register("tariff", TariffView, basename="tariff") +router.register("tickets", TicketsView, basename="tickets") +router.register("hotels", HotelView, basename="hotel") +urlpatterns = [path("", include(router.urls))] diff --git a/core/apps/tickets/validators/__init__.py b/core/apps/tickets/validators/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/validators/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/validators/extra_services.py b/core/apps/tickets/validators/extra_services.py new file mode 100644 index 0000000..764b859 --- /dev/null +++ b/core/apps/tickets/validators/extra_services.py @@ -0,0 +1,15 @@ +# from django.core.exceptions import ValidationError + + +class ExtraServicesValidator: + def __init__(self): ... + + def __call__(self): + return True + + +class PaidServicesValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/tickets/validators/hotel.py b/core/apps/tickets/validators/hotel.py new file mode 100644 index 0000000..c5b41d7 --- /dev/null +++ b/core/apps/tickets/validators/hotel.py @@ -0,0 +1,8 @@ +# from django.core.exceptions import ValidationError + + +class HotelValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/tickets/validators/tariff.py b/core/apps/tickets/validators/tariff.py new file mode 100644 index 0000000..2834f6c --- /dev/null +++ b/core/apps/tickets/validators/tariff.py @@ -0,0 +1,8 @@ +# from django.core.exceptions import ValidationError + + +class TariffValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/tickets/validators/tickets.py b/core/apps/tickets/validators/tickets.py new file mode 100644 index 0000000..a375ea8 --- /dev/null +++ b/core/apps/tickets/validators/tickets.py @@ -0,0 +1,15 @@ +# from django.core.exceptions import ValidationError + + +class TicketsValidator: + def __init__(self): ... + + def __call__(self): + return True + + +class TicketorderValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/tickets/views/__init__.py b/core/apps/tickets/views/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/views/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/views/extra_services.py b/core/apps/tickets/views/extra_services.py new file mode 100644 index 0000000..312f4f7 --- /dev/null +++ b/core/apps/tickets/views/extra_services.py @@ -0,0 +1,42 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel +from core.apps.tickets.serializers.extra_services import ( + CreateExtraServicesSerializer, + CreatePaidServicesSerializer, + ListExtraServicesSerializer, + ListPaidServicesSerializer, + RetrieveExtraServicesSerializer, + RetrievePaidServicesSerializer, +) + + +@extend_schema(tags=["extra_servoces"]) +class ExtraServicesView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = ExtraServicesModel.objects.all() + serializer_class = ListExtraServicesSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListExtraServicesSerializer, + "retrieve": RetrieveExtraServicesSerializer, + "create": CreateExtraServicesSerializer, + } + + +@extend_schema(tags=["paid_services"]) +class PaidServicesView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = PaidServicesModel.objects.all() + serializer_class = ListPaidServicesSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListPaidServicesSerializer, + "retrieve": RetrievePaidServicesSerializer, + "create": CreatePaidServicesSerializer, + } diff --git a/core/apps/tickets/views/hotel.py b/core/apps/tickets/views/hotel.py new file mode 100644 index 0000000..c93d45f --- /dev/null +++ b/core/apps/tickets/views/hotel.py @@ -0,0 +1,21 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.tickets.models import HotelModel +from core.apps.tickets.serializers.hotel import CreateHotelSerializer, ListHotelSerializer, RetrieveHotelSerializer + + +@extend_schema(tags=["hotel"]) +class HotelView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = HotelModel.objects.all() + serializer_class = ListHotelSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListHotelSerializer, + "retrieve": RetrieveHotelSerializer, + "create": CreateHotelSerializer, + } diff --git a/core/apps/tickets/views/tariff.py b/core/apps/tickets/views/tariff.py new file mode 100644 index 0000000..2de31b0 --- /dev/null +++ b/core/apps/tickets/views/tariff.py @@ -0,0 +1,21 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.tickets.models import TariffModel +from core.apps.tickets.serializers.tariff import CreateTariffSerializer, ListTariffSerializer, RetrieveTariffSerializer + + +@extend_schema(tags=["tariff"]) +class TariffView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = TariffModel.objects.all() + serializer_class = ListTariffSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListTariffSerializer, + "retrieve": RetrieveTariffSerializer, + "create": CreateTariffSerializer, + } diff --git a/core/apps/tickets/views/tickets.py b/core/apps/tickets/views/tickets.py new file mode 100644 index 0000000..dccfdc5 --- /dev/null +++ b/core/apps/tickets/views/tickets.py @@ -0,0 +1,42 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.tickets.models import TicketorderModel, TicketsModel +from core.apps.tickets.serializers.tickets import ( + CreateTicketorderSerializer, + CreateTicketsSerializer, + ListTicketorderSerializer, + ListTicketsSerializer, + RetrieveTicketorderSerializer, + RetrieveTicketsSerializer, +) + + +@extend_schema(tags=["tickets"]) +class TicketsView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = TicketsModel.objects.all() + serializer_class = ListTicketsSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListTicketsSerializer, + "retrieve": RetrieveTicketsSerializer, + "create": CreateTicketsSerializer, + } + + +@extend_schema(tags=["ticketorder"]) +class TicketorderView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = TicketorderModel.objects.all() + serializer_class = ListTicketorderSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListTicketorderSerializer, + "retrieve": RetrieveTicketorderSerializer, + "create": CreateTicketorderSerializer, + } diff --git a/core/services/__init__.py b/core/services/__init__.py new file mode 100644 index 0000000..fdaf3ce --- /dev/null +++ b/core/services/__init__.py @@ -0,0 +1,3 @@ +from .otp import * # noqa +from .sms import * # noqa +from .user import * # noqa diff --git a/core/services/otp.py b/core/services/otp.py new file mode 100644 index 0000000..c54a66c --- /dev/null +++ b/core/services/otp.py @@ -0,0 +1,136 @@ +import requests +from config.env import env + + +class ConsoleService: + + def __init__(self) -> None: ... + + def send_sms(self, phone_number, message): + + print(phone_number, message) + + +class EskizService: + GET = "GET" + POST = "POST" + PATCH = "PATCH" + CONTACT = "contact" + + def __init__(self, api_url=None, email=None, password=None, callback_url=None): + self.api_url = api_url or env("SMS_API_URL") + self.email = email or env("SMS_LOGIN") + self.password = password or env("SMS_PASSWORD") + self.callback_url = callback_url + self.headers = {} + + self.methods = { + "auth_user": "auth/user", + "auth_login": "auth/login", + "auth_refresh": "auth/refresh", + "send_message": "message/sms/send", + } + + def request(self, api_path, data=None, method=None, headers=None): + incoming_data = {"status": "error"} + + try: + response = requests.request( + method, + f"{self.api_url}/{api_path}", + data=data, + headers=headers, + ) + + if api_path == self.methods["auth_refresh"]: + if response.status_code == 200: + incoming_data["status"] = "success" + else: + incoming_data = response.json() + except requests.RequestException as error: + raise Exception(str(error)) + + return incoming_data + + def auth(self): + data = {"email": self.email, "password": self.password} + + return self.request(self.methods["auth_login"], data=data, method=self.POST) + + def refresh_token(self): + token = self.auth()["data"]["token"] + self.headers["Authorization"] = "Bearer " + token + + context = { + "headers": self.headers, + "method": self.PATCH, + "api_path": self.methods["auth_refresh"], + } + + return self.request( + context["api_path"], + method=context["method"], + headers=context["headers"], + ) + + def get_my_user_info(self): + token = self.auth()["data"]["token"] + self.headers["Authorization"] = "Bearer " + token + + data = { + "headers": self.headers, + "method": self.GET, + "api_path": self.methods["auth_user"], + } + + return self.request(data["api_path"], method=data["method"], headers=data["headers"]) + + def add_sms_contact(self, first_name, phone_number, group): + token = self.auth()["data"]["token"] + self.headers["Authorization"] = "Bearer " + token + + data = { + "name": first_name, + "email": self.email, + "group": group, + "mobile_phone": phone_number, + } + + context = { + "headers": self.headers, + "method": self.POST, + "api_path": self.CONTACT, + "data": data, + } + + return self.request( + context["api_path"], + data=context["data"], + method=context["method"], + headers=context["headers"], + ) + + def send_sms(self, phone_number, message): + token = self.auth()["data"]["token"] + self.headers["Authorization"] = "Bearer " + token + + data = { + "from": 4546, + "mobile_phone": phone_number, + "callback_url": self.callback_url, + "message": message, + } + + context = { + "headers": self.headers, + "method": self.POST, + "api_path": self.methods["send_message"], + "data": data, + } + + return self.request( + context["api_path"], + data=context["data"], + method=context["method"], + headers=context["headers"], + ) diff --git a/core/services/sms.py b/core/services/sms.py new file mode 100644 index 0000000..4d9d2b9 --- /dev/null +++ b/core/services/sms.py @@ -0,0 +1,63 @@ +import random +from datetime import datetime, timedelta + +from config.env import env +from core.apps.accounts.tasks.sms import SendConfirm +from django_core import exceptions, models + + +class SmsService: + @staticmethod + def send_confirm(phone): + # TODO: Deploy this change when deploying -> code = random.randint(1000, 9999) # noqa + if env.bool("OTP_PROD", False): + code = "".join(str(random.randint(0, 9)) for _ in range(env.int("OTP_SIZE", 4))) + else: + code = env.int("OTP_DEFAULT", 1111) + + sms_confirm, status = models.SmsConfirm.objects.get_or_create(phone=phone, defaults={"code": code}) + + sms_confirm.sync_limits() + + if sms_confirm.resend_unlock_time is not None: + expired = sms_confirm.interval(sms_confirm.resend_unlock_time) + exception = exceptions.SmsException(f"Resend blocked, try again in {expired}", expired=expired) + raise exception + + sms_confirm.code = code + sms_confirm.try_count = 0 + sms_confirm.resend_count += 1 + sms_confirm.phone = phone + sms_confirm.expired_time = datetime.now() + timedelta(seconds=models.SmsConfirm.SMS_EXPIRY_SECONDS) # noqa + sms_confirm.resend_unlock_time = datetime.now() + timedelta( + seconds=models.SmsConfirm.SMS_EXPIRY_SECONDS + ) # noqa + sms_confirm.save() + + SendConfirm.delay(phone, code) + return True + + @staticmethod + def check_confirm(phone, code): + sms_confirm = models.SmsConfirm.objects.filter(phone=phone).first() + + if sms_confirm is None: + raise exceptions.SmsException("Invalid confirmation code") + + sms_confirm.sync_limits() + + if sms_confirm.is_expired(): + raise exceptions.SmsException("Time for confirmation has expired") + + if sms_confirm.is_block(): + expired = sms_confirm.interval(sms_confirm.unlock_time) + raise exceptions.SmsException(f"Try again in {expired}") + + if str(sms_confirm.code) == str(code): + sms_confirm.delete() + return True + + sms_confirm.try_count += 1 + sms_confirm.save() + + raise exceptions.SmsException("Invalid confirmation code") diff --git a/core/services/user.py b/core/services/user.py new file mode 100644 index 0000000..31e4830 --- /dev/null +++ b/core/services/user.py @@ -0,0 +1,64 @@ +from datetime import datetime + +from core.services import sms +from django.contrib.auth import get_user_model, hashers +from django.utils.translation import gettext as _ +from django_core import exceptions +from rest_framework.exceptions import PermissionDenied +from rest_framework_simplejwt import tokens + + +class UserService(sms.SmsService): + def get_token(self, user): + refresh = tokens.RefreshToken.for_user(user) + + return { + "refresh": str(refresh), + "access": str(refresh.access_token), + } + + def create_user(self, phone, first_name, last_name, password): + get_user_model().objects.update_or_create( + phone=phone, + defaults={ + "phone": phone, + "first_name": first_name, + "last_name": last_name, + "password": hashers.make_password(password), + }, + ) + + def send_confirmation(self, phone) -> bool: + try: + self.send_confirm(phone) + return True + except exceptions.SmsException as e: + raise PermissionDenied(_("Qayta sms yuborish uchun kuting: {}").format(e.kwargs.get("expired"))) + except Exception: + raise PermissionDenied(_("Serverda xatolik yuz berdi")) + + def validate_user(self, user) -> dict: + """ + Create user if user not found + """ + if user.validated_at is None: + user.validated_at = datetime.now() + user.save() + token = self.get_token(user) + return token + + def is_validated(self, user) -> bool: + """ + User is validated check + """ + if user.validated_at is not None: + return True + return False + + def change_password(self, phone, password): + """ + Change password + """ + user = get_user_model().objects.filter(phone=phone).first() + user.set_password(password) + user.save() diff --git a/core/utils/__init__.py b/core/utils/__init__.py new file mode 100644 index 0000000..f10075c --- /dev/null +++ b/core/utils/__init__.py @@ -0,0 +1,3 @@ +from .cache import * # noqa +from .console import * # noqa +from .core import * # noqa diff --git a/core/utils/cache.py b/core/utils/cache.py new file mode 100644 index 0000000..2c00899 --- /dev/null +++ b/core/utils/cache.py @@ -0,0 +1,18 @@ +import hashlib + +from django.core.cache import cache + +from config.env import env + + +class Cache: + def remember(self, func, key: str, timeout=None, *args, **kwargs): + cache_enabled = env.bool("CACHE_ENABLED") + key = hashlib.md5(key.encode("utf-8")).hexdigest() + response = cache.get(key) + if not cache_enabled: + return func(*args, **kwargs) + elif response is None: + response = func(*args, **kwargs) + cache.set(key, response, env.int("CACHE_TIME") if timeout is None else timeout) + return response diff --git a/core/utils/console.py b/core/utils/console.py new file mode 100644 index 0000000..a296d66 --- /dev/null +++ b/core/utils/console.py @@ -0,0 +1,79 @@ +import logging +import os +from typing import Any, Union + +from django.conf import settings +from django.core import management + + +class Console(management.BaseCommand): + """ + Console logging class + """ + + def get_stdout(self): + base_command = management.BaseCommand() + return base_command.stdout + + def get_style(self): + base_command = management.BaseCommand() + return base_command.style + + def success(self, message): + logging.debug(message) + self.get_stdout().write(self.get_style().SUCCESS(message)) + + def error(self, message): + logging.error(message) + self.get_stdout().write(self.get_style().ERROR(message)) + + def log(self, message): + self.get_stdout().write( + self.get_style().ERROR( + "\n{line}\n{message}\n{line}\n".format( + message=message, line="=" * len(message) + ) + ) + ) + + +class BaseMake(management.BaseCommand): + path: str + + def __init__(self, *args, **options): + super().__init__(*args, **options) + self.console = Console() + + def add_arguments(self, parser): + parser.add_argument("name") + + def handle(self, *args, **options): + name = options.get("name") + if name is None: + name = "" + + stub = open(os.path.join(settings.BASE_DIR, f"resources/stub/{self.path}.stub")) + data: Union[Any] = stub.read() + stub.close() + + stub = data.replace("{{name}}", name or "") + + + core_http_path = os.path.join(settings.BASE_DIR, "core/http") + if os.path.exists( + os.path.join(core_http_path, f"{self.path}/{name.lower()}.py") + ): # noqa + self.console.error(f"{self.name} already exists") + return + + if not os.path.exists(os.path.join(core_http_path, self.path)): + os.makedirs(os.path.join(core_http_path, self.path)) + + file = open( + os.path.join(core_http_path, f"{self.path}/{name.lower()}.py"), + "w+", + ) + file.write(stub) # type: ignore + file.close() + + self.console.success(f"{self.name} created") diff --git a/core/utils/core.py b/core/utils/core.py new file mode 100644 index 0000000..04fdbed --- /dev/null +++ b/core/utils/core.py @@ -0,0 +1,6 @@ +class Helper: + """ + Helper class to handle index + """ + + pass diff --git a/core/utils/storage.py b/core/utils/storage.py new file mode 100644 index 0000000..50e6d33 --- /dev/null +++ b/core/utils/storage.py @@ -0,0 +1,33 @@ +from typing import Optional, Union + +from config.env import env + + +class Storage: + + storages = ["AWS", "MINIO", "FILE", "STATIC"] + + def __init__(self, storage: Union[str], storage_type: Union[str] = "default") -> None: + self.storage = storage + self.sorage_type = storage_type + if storage not in self.storages: + raise ValueError(f"Invalid storage type: {storage}") + + def get_backend(self) -> Optional[str]: + match self.storage: + case "AWS" | "MINIO": + return "storages.backends.s3boto3.S3Boto3Storage" + case "FILE": + return "django.core.files.storage.FileSystemStorage" + case "STATIC": + return "django.contrib.staticfiles.storage.StaticFilesStorage" + + def get_options(self) -> Optional[str]: + match self.storage: + case "AWS" | "MINIO": + if self.sorage_type == "default": + return {"bucket_name": env.str("STORAGE_BUCKET_MEDIA")} + elif self.sorage_type == "static": + return {"bucket_name": env.str("STORAGE_BUCKET_STATIC")} + case _: + return {} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2625bf3 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,59 @@ +networks: + simple_travel: + driver: bridge + +volumes: + pg_data: null + pycache: null + media: null + static: null + +services: + nginx: + networks: + - simple_travel + ports: + - ${PORT:-8001}:80 + volumes: + - ./resources/layout/nginx.conf:/etc/nginx/nginx.conf + - media:/usr/share/nginx/html/resources/media/:ro + - static:/usr/share/nginx/html/resources/staticfiles/:ro + build: + context: . + dockerfile: ./docker/Dockerfile.nginx + depends_on: + - web + web: + networks: + - simple_travel + build: + context: . + dockerfile: ./docker/Dockerfile.web + restart: always + environment: + - PYTHONPYCACHEPREFIX=/var/cache/pycache + - SCRIPT=${SCRIPT:-entrypoint.sh} + volumes: + - media:/code/resources/media/ + - static:/code/resources/staticfiles/ + - pycache:/var/cache/pycache + depends_on: + - db + - redis + db: + networks: + - simple_travel + image: postgres:16 + restart: always + environment: + POSTGRES_DB: django + POSTGRES_USER: postgres + POSTGRES_PASSWORD: '2309' + volumes: + - pg_data:/var/lib/postgresql/data + redis: + networks: + - simple_travel + restart: always + + image: redis diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..8c18d6f --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,44 @@ +networks: + simple_travel: + driver: bridge + +volumes: + pg_data: null + pycache: null + +services: + web: + networks: + - simple_travel + build: + context: . + dockerfile: ./docker/Dockerfile.web + restart: always + container_name: test_web + environment: + - PYTHONPYCACHEPREFIX=/var/cache/pycache + - SCRIPT=${SCRIPT:-entrypoint.sh} + volumes: + - pycache:/var/cache/pycache + depends_on: + - db + - redis + db: + networks: + - simple_travel + image: postgres:16 + restart: always + container_name: test_db + environment: + POSTGRES_DB: django + POSTGRES_USER: postgres + POSTGRES_PASSWORD: '2309' + volumes: + - pg_data:/var/lib/postgresql/data + redis: + container_name: test_redis + networks: + - simple_travel + restart: always + + image: redis diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..23d2842 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +networks: + simple_travel: + driver: bridge + +volumes: + pg_data: null + rabbitmq: null + pycache: null + +services: + nginx: + networks: + - simple_travel + ports: + - ${PORT:-8001}:80 + volumes: + - ./resources/layout/nginx.conf:/etc/nginx/nginx.conf + - ./resources/:/usr/share/nginx/html/resources/ + build: + context: . + dockerfile: ./docker/Dockerfile.nginx + depends_on: + - web + web: + networks: + - simple_travel + build: + context: . + dockerfile: ./docker/Dockerfile.web + restart: always + environment: + - PYTHONPYCACHEPREFIX=/var/cache/pycache + - SCRIPT=${SCRIPT:-entrypoint.sh} + volumes: + - .:/code + - pycache:/var/cache/pycache + depends_on: + - db + - redis + db: + networks: + - simple_travel + image: postgres:16 + restart: always + environment: + POSTGRES_DB: django + POSTGRES_USER: postgres + POSTGRES_PASSWORD: '2309' + volumes: + - pg_data:/var/lib/postgresql/data + redis: + networks: + - simple_travel + restart: always + + image: redis diff --git a/docker/Dockerfile.nginx b/docker/Dockerfile.nginx new file mode 100644 index 0000000..22a1599 --- /dev/null +++ b/docker/Dockerfile.nginx @@ -0,0 +1,3 @@ +FROM nginx:alpine + +COPY ./resources/layout/nginx.conf /etc/nginx/nginx.conf diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web new file mode 100644 index 0000000..44a3db8 --- /dev/null +++ b/docker/Dockerfile.web @@ -0,0 +1,22 @@ +FROM jscorptech/django:v0.5 + +ARG SCRIPT="entrypoint.sh" +ENV SCRIPT=$SCRIPT + + +WORKDIR /code + +COPY ./ /code + +RUN apk add --no-cache curl || apk add --no-cache openbsd-netcat + + +RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt + +COPY ./resources/scripts/$SCRIPT /code/$SCRIPT + +RUN chmod +x /code/resources/scripts/$SCRIPT + +CMD sh /code/resources/scripts/$SCRIPT + + diff --git a/jst.json b/jst.json new file mode 100644 index 0000000..e87c706 --- /dev/null +++ b/jst.json @@ -0,0 +1,8 @@ +{ + "dirs": { + "apps": "./core/apps/", + "locale": "./resources/locale/" + }, + "stubs": {}, + "apps": "core.apps." +} \ No newline at end of file diff --git a/k8s/config.yaml b/k8s/config.yaml new file mode 100644 index 0000000..76e98b9 --- /dev/null +++ b/k8s/config.yaml @@ -0,0 +1,60 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-config +data: + nginx.conf: | + worker_processes 1; + + events { + worker_connections 1024; + } + + http { + include mime.types; + default_type application/octet-stream; + + # Logging settings (optional) + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 1024M; + + # Server block for handling requests + server { + listen 80; + + server_name _; + + location / { + proxy_pass http://django:8000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Host $http_host; + } + location /ws/ { + proxy_pass http://django:8000; # Uvicorn serveri ishga tushadigan port + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $http_host; + } + + location /resources/static/ { + alias /usr/share/nginx/html/resources/staticfiles/; + } + + location /resources/media/ { + alias /usr/share/nginx/html/resources/media/; + } + } + } + diff --git a/k8s/db-deployment.yaml b/k8s/db-deployment.yaml new file mode 100644 index 0000000..4600325 --- /dev/null +++ b/k8s/db-deployment.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: db +spec: + replicas: 1 + selector: + matchLabels: + app: db + template: + metadata: + labels: + app: db + spec: + containers: + - name: db + image: postgres:16 + env: + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_PASSWORD + value: "2309" + - name: POSTGRES_DB + value: django + ports: + - containerPort: 5432 + volumeMounts: + - name: db + mountPath: /var/lib/postgresql/data + volumes: + - name: db + persistentVolumeClaim: + claimName: db diff --git a/k8s/db-service.yaml b/k8s/db-service.yaml new file mode 100644 index 0000000..15131d2 --- /dev/null +++ b/k8s/db-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: db +spec: + type: ClusterIP + selector: + app: db + ports: + - port: 5432 + targetPort: 5432 diff --git a/k8s/django-deployment.yaml b/k8s/django-deployment.yaml new file mode 100644 index 0000000..c6618eb --- /dev/null +++ b/k8s/django-deployment.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: django +spec: + replicas: 1 + selector: + matchLabels: + app: django + template: + metadata: + labels: + app: django + spec: + containers: + - name: django + image: "2.0" + ports: + - containerPort: 8000 + volumeMounts: + - name: assets + mountPath: /code/resources/staticfiles + - name: media + mountPath: /code/resources/media + volumes: + - name: assets + persistentVolumeClaim: + claimName: assets + - name: media + persistentVolumeClaim: + claimName: media + + diff --git a/k8s/django-service.yaml b/k8s/django-service.yaml new file mode 100644 index 0000000..9652d67 --- /dev/null +++ b/k8s/django-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: django +spec: + type: ClusterIP + selector: + app: django + ports: + - port: 8000 + targetPort: 8000 diff --git a/k8s/nginx-deployment.yaml b/k8s/nginx-deployment.yaml new file mode 100644 index 0000000..4203284 --- /dev/null +++ b/k8s/nginx-deployment.yaml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx +spec: + replicas: 2 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:latest + ports: + - containerPort: 80 + volumeMounts: + - name: nginx-config-volume + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + - name: assets + mountPath: /usr/share/nginx/html/resources/staticfiles + readOnly: true + - name: media + mountPath: /usr/share/nginx/html/resources/media + readOnly: true + volumes: + - name: assets + persistentVolumeClaim: + claimName: assets + - name: media + persistentVolumeClaim: + claimName: media + - name: nginx-config-volume + configMap: + name: nginx-config diff --git a/k8s/nginx-service.yaml b/k8s/nginx-service.yaml new file mode 100644 index 0000000..479cadf --- /dev/null +++ b/k8s/nginx-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: nginx +spec: + type: NodePort + selector: + app: nginx + ports: + - port: 80 + targetPort: 80 + nodePort: 30000 diff --git a/k8s/volume.yaml b/k8s/volume.yaml new file mode 100644 index 0000000..675b7bb --- /dev/null +++ b/k8s/volume.yaml @@ -0,0 +1,35 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: assets +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: media +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: db +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 10Gi diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..67c98de --- /dev/null +++ b/manage.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + +from config.env import env + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) + 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3947207 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[tool.black] +line-length = 120 + +[tool.isort] +profile = "black" +line_length = 120 + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.local" +python_files = "tests.py test_*.py *_tests.py" +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", + "ignore::ResourceWarning", + "ignore::Warning" # This line will ignore all warnings +] + + +[tool.flake8] +max-line-length = 120 +ignore = ["E701", "E704", "W503"] + +[tool.pyright] +typeCheckingMode = "basic" +reportMissingImports = false +reportMissingTypeStubs = false +pythonVersion = "3.12" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d0e73e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,46 @@ +backports.tarfile==1.2.0 +celery==5.4.0 +django-cors-headers==4.6.0 +django-environ==0.11.2 +django-extensions==3.2.3 +django-filter==24.3 +django-redis==5.4.0 +django-unfold==0.65.0 +djangorestframework-simplejwt==5.3.1 +drf-spectacular==0.28.0 +importlib-metadata==8.5.0 +importlib-resources==6.4.5 +inflect==7.3.1 +jaraco.collections==5.1.0 +packaging==24.2 +pip-chill==1.0.3 +platformdirs==4.3.6 +psycopg2-binary==2.9.10 +tomli==2.2.1 +uvicorn==0.32.1 +jst-django-core~=1.2.2 +rich +pydantic +bcrypt +pytest-django + + + +django-modeltranslation~=0.19.11 +django-ckeditor-5==0.2.15 +channels==4.2.0 +django-rosetta==0.10.1 +django-cacheops~=7.1 +django-silk + +# !NOTE: on-server +# gunicorn + + +django-storages +boto3 + + +# !NOTE: on-websocket +# websockets +# channels-redis diff --git a/resources/.gitignore b/resources/.gitignore new file mode 100644 index 0000000..7627088 --- /dev/null +++ b/resources/.gitignore @@ -0,0 +1 @@ +staticfiles/ \ No newline at end of file diff --git a/resources/layout/.flake8 b/resources/layout/.flake8 new file mode 100644 index 0000000..4088dec --- /dev/null +++ b/resources/layout/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 120 +ignore = E701, E704, W503 diff --git a/resources/layout/Dockerfile.alpine b/resources/layout/Dockerfile.alpine new file mode 100644 index 0000000..f57c7bc --- /dev/null +++ b/resources/layout/Dockerfile.alpine @@ -0,0 +1,13 @@ +FROM python:3.13-alpine + +ENV PYTHONPYCACHEPREFIX=/dev/null + +RUN apk update && apk add git gettext + +WORKDIR /code + +COPY requirements.txt /code/requirements.txt + +RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt + +CMD ["sh", "./entrypoint.sh"] diff --git a/resources/layout/Dockerfile.nginx b/resources/layout/Dockerfile.nginx new file mode 100644 index 0000000..22a1599 --- /dev/null +++ b/resources/layout/Dockerfile.nginx @@ -0,0 +1,3 @@ +FROM nginx:alpine + +COPY ./resources/layout/nginx.conf /etc/nginx/nginx.conf diff --git a/resources/layout/mypy.ini b/resources/layout/mypy.ini new file mode 100644 index 0000000..6c53a26 --- /dev/null +++ b/resources/layout/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +check_untyped_defs = True + +[mypy-requests.*] +ignore_missing_imports = True \ No newline at end of file diff --git a/resources/layout/nginx.conf b/resources/layout/nginx.conf new file mode 100644 index 0000000..e86631b --- /dev/null +++ b/resources/layout/nginx.conf @@ -0,0 +1,54 @@ +# Main configuration block +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + # Logging settings (optional) + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 1024M; + + # Server block for handling requests + server { + listen 80; + + server_name _; + + location / { + proxy_pass http://web:8000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Host $http_host; + } + location /ws/ { + proxy_pass http://web:8000; # Uvicorn serveri ishga tushadigan port + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $http_host; + } + + location /resources/static/ { + alias /usr/share/nginx/html/resources/staticfiles/; + } + + location /resources/media/ { + alias /usr/share/nginx/html/resources/media/; + } + } +} diff --git a/resources/locale/.gitkeep b/resources/locale/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/locale/en/LC_MESSAGES/django.po b/resources/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..acc28e8 --- /dev/null +++ b/resources/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,49 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-02-15 16:40+0500\n" +"PO-Revision-Date: 2024-02-09 15:09+0500\n" +"Last-Translator: \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Translated-Using: django-rosetta 0.10.0\n" + +#: config/settings/common.py:148 +msgid "Russia" +msgstr "" + +#: config/settings/common.py:149 +msgid "English" +msgstr "" + +#: config/settings/common.py:150 +msgid "Uzbek" +msgstr "" + +#: core/http/admin/index.py:20 +msgid "Custom Field" +msgstr "" + +#: core/http/tasks/index.py:13 +#, python-format +msgid "Sizning Tasdiqlash ko'dingiz: %(code)s" +msgstr "" + +#: resources/templates/user/home.html:18 +msgid "Django" +msgstr "" + +#: resources/templates/user/home.html:19 +msgid "Assalomu aleykum" +msgstr "" diff --git a/resources/locale/ru/LC_MESSAGES/django.po b/resources/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 0000000..a039baa --- /dev/null +++ b/resources/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,51 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-02-15 16:40+0500\n" +"PO-Revision-Date: 2024-02-09 15:09+0500\n" +"Last-Translator: \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " +"(n%100>=11 && n%100<=14)? 2 : 3);\n" +"X-Translated-Using: django-rosetta 0.10.0\n" + +#: config/settings/common.py:148 +msgid "Russia" +msgstr "" + +#: config/settings/common.py:149 +msgid "English" +msgstr "" + +#: config/settings/common.py:150 +msgid "Uzbek" +msgstr "" + +#: core/http/admin/index.py:20 +msgid "Custom Field" +msgstr "" + +#: core/http/tasks/index.py:13 +#, python-format +msgid "Sizning Tasdiqlash ko'dingiz: %(code)s" +msgstr "" + +#: resources/templates/user/home.html:18 +msgid "Django" +msgstr "" + +#: resources/templates/user/home.html:19 +msgid "Assalomu aleykum" +msgstr "" diff --git a/resources/locale/uz/LC_MESSAGES/django.po b/resources/locale/uz/LC_MESSAGES/django.po new file mode 100644 index 0000000..c3a7ef5 --- /dev/null +++ b/resources/locale/uz/LC_MESSAGES/django.po @@ -0,0 +1,55 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-02-15 16:40+0500\n" +"PO-Revision-Date: 2024-02-10 22:46+0500\n" +"Last-Translator: \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Translated-Using: django-rosetta 0.10.0\n" + +#: config/settings/common.py:148 +msgid "Russia" +msgstr "" + +#: config/settings/common.py:149 +msgid "English" +msgstr "" + +#: config/settings/common.py:150 +msgid "Uzbek" +msgstr "" + +#: core/http/admin/index.py:20 +msgid "Custom Field" +msgstr "" + +#: core/http/tasks/index.py:13 +#, python-format +msgid "Sizning Tasdiqlash ko'dingiz: %(code)s" +msgstr "" + +#: resources/templates/user/home.html:18 +msgid "Django" +msgstr "" + +#: resources/templates/user/home.html:19 +msgid "Assalomu aleykum" +msgstr "" + +#~ msgid "Home" +#~ msgstr "Bosh sahifa" + +#~ msgid "Homes" +#~ msgstr "Bosh sahifa" diff --git a/resources/logs/.gitignore b/resources/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/resources/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/resources/media/.gitignore b/resources/media/.gitignore new file mode 100644 index 0000000..a3a0c8b --- /dev/null +++ b/resources/media/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/resources/scripts/backup.sh b/resources/scripts/backup.sh new file mode 100644 index 0000000..5bac180 --- /dev/null +++ b/resources/scripts/backup.sh @@ -0,0 +1,5 @@ +file=/tmp/db-$(/usr/bin/date +\%Y-%m-%d-%H:%M:%S).sql +container=postgres +/usr/bin/docker container exec $container pg_dump -U postgres django > $file +mc cp $file b2/buket-name +rm $file \ No newline at end of file diff --git a/resources/scripts/entrypoint-server.sh b/resources/scripts/entrypoint-server.sh new file mode 100644 index 0000000..319de3e --- /dev/null +++ b/resources/scripts/entrypoint-server.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +while ! nc -z db 5432; do + sleep 2 + echo "Waiting postgress...." +done + +python3 manage.py collectstatic --noinput +python3 manage.py migrate --noinput + +gunicorn config.wsgi:application -b 0.0.0.0:8000 --workers $(($(nproc) * 2 + 1)) + + + +exit $? + + diff --git a/resources/scripts/entrypoint.sh b/resources/scripts/entrypoint.sh new file mode 100644 index 0000000..8c60554 --- /dev/null +++ b/resources/scripts/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +while ! nc -z db 5432; do + sleep 2 + echo "Waiting postgress...." +done + + +python3 manage.py collectstatic --noinput +python3 manage.py migrate --noinput + +uvicorn config.asgi:application --host 0.0.0.0 --port 8000 --reload --reload-dir core --reload-dir config + + + +exit $? + + diff --git a/resources/static/css/app.css b/resources/static/css/app.css new file mode 100644 index 0000000..e69de29 diff --git a/resources/static/css/error.css b/resources/static/css/error.css new file mode 100644 index 0000000..11201a8 --- /dev/null +++ b/resources/static/css/error.css @@ -0,0 +1,109 @@ +* { + -webkit-box-sizing: border-box; + box-sizing: border-box; +} + +body { + padding: 0; + margin: 0; +} + +#notfound { + position: relative; + height: 100vh; +} + +#notfound .notfound { + position: absolute; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + -ms-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.notfound { + max-width: 710px; + width: 100%; + padding-left: 190px; + line-height: 1.4; +} + +.notfound .notfound-404 { + position: absolute; + left: 0; + top: 0; + width: 150px; + height: 150px; +} + +.notfound .notfound-404 h1 { + font-family: 'Passion One', cursive; + color: #00b5c3; + font-size: 150px; + letter-spacing: 15.5px; + margin: 0px; + font-weight: 900; + position: absolute; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + -ms-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.notfound h2 { + font-family: 'Raleway', sans-serif; + color: #292929; + font-size: 28px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 2.5px; + margin-top: 0; +} + +.notfound p { + font-family: 'Raleway', sans-serif; + font-size: 14px; + font-weight: 400; + margin-top: 0; + margin-bottom: 15px; + color: #333; +} + +.notfound a { + font-family: 'Raleway', sans-serif; + font-size: 14px; + text-decoration: none; + text-transform: uppercase; + background: #fff; + display: inline-block; + padding: 15px 30px; + border-radius: 40px; + color: #292929; + font-weight: 700; + -webkit-box-shadow: 0px 4px 15px -5px rgba(0, 0, 0, 0.3); + box-shadow: 0px 4px 15px -5px rgba(0, 0, 0, 0.3); + -webkit-transition: 0.2s all; + transition: 0.2s all; +} + +.notfound a:hover { + color: #fff; + background-color: #00b5c3; +} + +@media only screen and (max-width: 480px) { + .notfound { + text-align: center; + } + .notfound .notfound-404 { + position: relative; + width: 100%; + margin-bottom: 15px; + } + .notfound { + padding-left: 15px; + padding-right: 15px; + } +} diff --git a/resources/static/css/input.css b/resources/static/css/input.css new file mode 100644 index 0000000..04b35af --- /dev/null +++ b/resources/static/css/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/resources/static/css/jazzmin.css b/resources/static/css/jazzmin.css new file mode 100644 index 0000000..8b8be66 --- /dev/null +++ b/resources/static/css/jazzmin.css @@ -0,0 +1,5 @@ +.login-logo img { + border-radius: 100%; + width: 100px; + height: 100px; +} \ No newline at end of file diff --git a/resources/static/css/output.css b/resources/static/css/output.css new file mode 100644 index 0000000..99bc6dc --- /dev/null +++ b/resources/static/css/output.css @@ -0,0 +1,772 @@ +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +/* +! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden]:where(:not([hidden="until-found"])) { + display: none; +} + +.static { + position: static; +} + +.m-2 { + margin: 0.5rem; +} + +.mx-4 { + margin-left: 1rem; + margin-right: 1rem; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.mb-5 { + margin-bottom: 1.25rem; +} + +.ml-1 { + margin-left: 0.25rem; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mt-5 { + margin-top: 1.25rem; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.flex { + display: flex; +} + +.grid { + display: grid; +} + +.h-\[100vh\] { + height: 100vh; +} + +.w-\[100vw\] { + width: 100vw; +} + +.w-full { + width: 100%; +} + +.cursor-pointer { + cursor: pointer; +} + +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +.items-center { + align-items: center; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: center; +} + +.gap-8 { + gap: 2rem; +} + +.gap-x-3 { + -moz-column-gap: 0.75rem; + column-gap: 0.75rem; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded { + border-radius: 0.25rem; +} + +.border { + border-width: 1px; +} + +.border-black { + --tw-border-opacity: 1; + border-color: rgb(0 0 0 / var(--tw-border-opacity)); +} + +.bg-blue-100 { + --tw-bg-opacity: 1; + background-color: rgb(219 234 254 / var(--tw-bg-opacity)); +} + +.bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity)); +} + +.p-4 { + padding: 1rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.px-\[40px\] { + padding-left: 40px; + padding-right: 40px; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.text-center { + text-align: center; +} + +.text-\[20px\] { + font-size: 20px; +} + +.text-\[25px\] { + font-size: 25px; +} + +.text-\[30px\] { + font-size: 30px; +} + +.text-\[40px\] { + font-size: 40px; +} + +.font-\[400\] { + font-weight: 400; +} + +.font-\[600\] { + font-weight: 600; +} + +.font-bold { + font-weight: 700; +} + +.font-semibold { + font-weight: 600; +} + +.uppercase { + text-transform: uppercase; +} + +.leading-normal { + line-height: 1.5; +} + +.text-black { + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +.text-blue-500 { + --tw-text-opacity: 1; + color: rgb(59 130 246 / var(--tw-text-opacity)); +} + +.text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + +@media (min-width: 640px) { + .sm\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 768px) { + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} diff --git a/resources/static/images/default_avatar.jpg b/resources/static/images/default_avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a0aed819501b9ec1fd5d231674d832dc55266fb5 GIT binary patch literal 5363 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]%23n2&SNM2Gj_-SWIm&t|Q0ArH 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 ({ + count: 2309 +}) \ No newline at end of file diff --git a/resources/static/js/customer.js b/resources/static/js/customer.js new file mode 100644 index 0000000..06bacfb --- /dev/null +++ b/resources/static/js/customer.js @@ -0,0 +1,49 @@ + +class Changer { + constructor() { + this.inputs = []; + + this.legal = [ + "bank_mfo", + "bank_name", + "bank_account", + "name", + "director_name", + "responsible_person", + "inn" + ]; + + this.physical = [ + "passport_series", + "jshir", + "first_name", + "last_name", + ] + this.legal.concat(this.physical).forEach((item) => { + this.inputs[item] = document.querySelector(`#id_${item}`).closest(".form-row"); + }) + } + toggleDisplay(showItems, hideItems) { + showItems.forEach(item => { + this.inputs[item].style.display = "block"; + }); + hideItems.forEach(item => { + this.inputs[item].style.display = "none"; + }); + }; + + change(e) { + if (e == "PHYSICAL") { + this.toggleDisplay(this.physical, this.legal); + } else if (e == "LEGAL") { + this.toggleDisplay(this.legal, this.physical); + } + } +} + +document.addEventListener("DOMContentLoaded", () => { + let obj = new Changer(); + let select = document.querySelector("#id_person_type"); + select.addEventListener("change", (e) => obj.change(e.target.value)); + obj.change(select.value); +}) \ No newline at end of file diff --git a/resources/static/js/vite-refresh.js b/resources/static/js/vite-refresh.js new file mode 100644 index 0000000..019ed44 --- /dev/null +++ b/resources/static/js/vite-refresh.js @@ -0,0 +1,9 @@ +import RefreshRuntime from 'http://localhost:5173/@react-refresh' + +if (RefreshRuntime) { + RefreshRuntime.injectIntoGlobalHook(window) + window.$RefreshReg$ = () => { + } + window.$RefreshSig$ = () => (type) => type + window.__vite_plugin_react_preamble_installed__ = true +} \ No newline at end of file diff --git a/resources/static/vite/assets/appCss-w40geAFS.js b/resources/static/vite/assets/appCss-w40geAFS.js new file mode 100644 index 0000000..e69de29 diff --git a/resources/static/vite/assets/appJs-YH6iAcjX.js b/resources/static/vite/assets/appJs-YH6iAcjX.js new file mode 100644 index 0000000..7274c06 --- /dev/null +++ b/resources/static/vite/assets/appJs-YH6iAcjX.js @@ -0,0 +1,6 @@ +var Ce=!1,Me=!1,L=[],Te=-1;function zn(e){Hn(e)}function Hn(e){L.includes(e)||L.push(e),qn()}function Mt(e){let t=L.indexOf(e);t!==-1&&t>Te&&L.splice(t,1)}function qn(){!Me&&!Ce&&(Ce=!0,queueMicrotask(Wn))}function Wn(){Ce=!1,Me=!0;for(let e=0;ee.effect(t,{scheduler:n=>{Ie?zn(n):n()}}),Tt=e.raw}function _t(e){K=e}function Vn(e){let t=()=>{};return[r=>{let i=K(r);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),q(i))},i},()=>{t()}]}function It(e,t){let n=!0,r,i=K(()=>{let o=e();JSON.stringify(o),n?r=o:queueMicrotask(()=>{t(o,r),r=o}),n=!1});return()=>q(i)}function X(e,t,n={}){e.dispatchEvent(new CustomEvent(t,{detail:n,bubbles:!0,composed:!0,cancelable:!0}))}function I(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>I(i,t));return}let n=!1;if(t(e,()=>n=!0),n)return;let r=e.firstElementChild;for(;r;)I(r,t),r=r.nextElementSibling}function O(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var ht=!1;function Yn(){ht&&O("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),ht=!0,document.body||O("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `