From b7412bbef6b1a21201ee5e6737518bfa8eee204d Mon Sep 17 00:00:00 2001 From: Fazliddin Abdurahimov Date: Tue, 5 Aug 2025 10:26:39 +0500 Subject: [PATCH] initial commit --- .cruft.json | 28 + .devcontainer/devcontainer.json | 19 + .dockerignore | 2 + .env.example | 63 ++ .flake8 | 3 + .gitignore | 158 ++++ Makefile | 49 ++ README.MD | 156 ++++ config/__init__.py | 3 + config/asgi.py | 12 + config/celery.py | 16 + config/conf/__init__.py | 11 + config/conf/apps.py | 27 + 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 | 3 + config/conf/navigation.py | 31 + config/conf/rest_framework.py | 9 + config/conf/spectacular.py | 32 + config/conf/storage.py | 23 + config/conf/unfold.py | 41 + config/env.py | 28 + config/settings/__init__.py | 0 config/settings/common.py | 172 ++++ config/settings/local.py | 11 + config/settings/production.py | 6 + config/urls.py | 58 ++ 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 | 2 + core/apps/accounts/admin/core.py | 18 + core/apps/accounts/admin/user.py | 125 +++ core/apps/accounts/apps.py | 9 + core/apps/accounts/choices/__init__.py | 1 + core/apps/accounts/choices/user.py | 12 + core/apps/accounts/managers/__init__.py | 1 + core/apps/accounts/managers/user.py | 23 + core/apps/accounts/migrations/0001_initial.py | 141 ++++ core/apps/accounts/migrations/__init__.py | 0 core/apps/accounts/models/__init__.py | 3 + core/apps/accounts/models/reset_token.py | 15 + core/apps/accounts/models/user.py | 133 +++ core/apps/accounts/permissions/users.py | 0 core/apps/accounts/seeder/__init__.py | 1 + core/apps/accounts/seeder/core.py | 10 + core/apps/accounts/serializers/__init__.py | 4 + core/apps/accounts/serializers/auth.py | 59 ++ .../accounts/serializers/change_password.py | 6 + .../apps/accounts/serializers/set_password.py | 6 + core/apps/accounts/serializers/user.py | 23 + core/apps/accounts/signals/__init__.py | 1 + core/apps/accounts/signals/user.py | 10 + 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/urls.py | 35 + core/apps/accounts/views/__init__.py | 3 + core/apps/accounts/views/auth.py | 209 +++++ core/apps/accounts/views/me.py | 70 ++ core/apps/accounts/views/users.py | 84 ++ core/apps/banks/__init__.py | 0 core/apps/banks/admin/__init__.py | 1 + core/apps/banks/admin/banks.py | 23 + core/apps/banks/apps.py | 6 + core/apps/banks/filters/__init__.py | 1 + core/apps/banks/filters/banks.py | 13 + core/apps/banks/forms/__init__.py | 1 + core/apps/banks/forms/banks.py | 10 + core/apps/banks/migrations/0001_initial.py | 65 ++ core/apps/banks/migrations/__init__.py | 0 core/apps/banks/models/__init__.py | 1 + core/apps/banks/models/banks.py | 68 ++ core/apps/banks/permissions/__init__.py | 1 + core/apps/banks/permissions/banks.py | 12 + core/apps/banks/serializers/__init__.py | 1 + core/apps/banks/serializers/banks/__init__.py | 1 + core/apps/banks/serializers/banks/banks.py | 35 + core/apps/banks/signals/__init__.py | 1 + core/apps/banks/signals/banks.py | 8 + core/apps/banks/tests/__init__.py | 1 + core/apps/banks/tests/test_banks.py | 47 ++ core/apps/banks/translation/__init__.py | 1 + core/apps/banks/translation/banks.py | 8 + core/apps/banks/urls.py | 11 + core/apps/banks/validators/__init__.py | 1 + core/apps/banks/validators/banks.py | 33 + core/apps/banks/views/__init__.py | 1 + core/apps/banks/views/banks.py | 29 + core/apps/companies/__init__.py | 0 core/apps/companies/admin/__init__.py | 3 + core/apps/companies/admin/accounts.py | 27 + core/apps/companies/admin/companies.py | 32 + core/apps/companies/admin/folders.py | 24 + core/apps/companies/apps.py | 6 + core/apps/companies/filters/__init__.py | 3 + core/apps/companies/filters/accounts.py | 13 + core/apps/companies/filters/companies.py | 13 + core/apps/companies/filters/folders.py | 13 + core/apps/companies/forms/__init__.py | 3 + core/apps/companies/forms/accounts.py | 10 + core/apps/companies/forms/companies.py | 10 + core/apps/companies/forms/folders.py | 10 + .../apps/companies/migrations/0001_initial.py | 257 ++++++ .../0002_alter_companymodel_logo_and_more.py | 23 + .../0003_companyfoldermodel_contracts.py | 21 + .../0004_alter_companymodel_logo_and_more.py | 25 + core/apps/companies/migrations/__init__.py | 0 core/apps/companies/models/__init__.py | 3 + core/apps/companies/models/accounts.py | 77 ++ core/apps/companies/models/companies.py | 184 +++++ core/apps/companies/models/folders.py | 68 ++ core/apps/companies/permissions/__init__.py | 3 + core/apps/companies/permissions/accounts.py | 12 + core/apps/companies/permissions/companies.py | 23 + core/apps/companies/permissions/folders.py | 12 + core/apps/companies/serializers/__init__.py | 3 + .../serializers/accounts/__init__.py | 1 + .../serializers/accounts/company_accounts.py | 36 + .../serializers/companies/__init__.py | 1 + .../serializers/companies/companies.py | 51 ++ .../companies/serializers/folders/__init__.py | 1 + .../serializers/folders/company_folders.py | 48 ++ core/apps/companies/signals/__init__.py | 3 + core/apps/companies/signals/accounts.py | 8 + core/apps/companies/signals/companies.py | 8 + core/apps/companies/signals/folders.py | 8 + core/apps/companies/tests/__init__.py | 3 + core/apps/companies/tests/test_accounts.py | 47 ++ core/apps/companies/tests/test_companies.py | 47 ++ core/apps/companies/tests/test_folders.py | 47 ++ core/apps/companies/translation/__init__.py | 3 + core/apps/companies/translation/accounts.py | 8 + core/apps/companies/translation/companies.py | 8 + core/apps/companies/translation/folders.py | 8 + core/apps/companies/urls.py | 18 + core/apps/companies/validators/__init__.py | 3 + core/apps/companies/validators/accounts.py | 10 + core/apps/companies/validators/companies.py | 62 ++ core/apps/companies/validators/folders.py | 10 + core/apps/companies/views/__init__.py | 3 + core/apps/companies/views/accounts.py | 35 + core/apps/companies/views/companies.py | 189 +++++ core/apps/companies/views/folders.py | 35 + core/apps/contracts/__init__.py | 0 core/apps/contracts/admin/__init__.py | 4 + core/apps/contracts/admin/attached_files.py | 24 + core/apps/contracts/admin/contracts.py | 26 + core/apps/contracts/admin/file_contents.py | 26 + core/apps/contracts/admin/owners.py | 78 ++ core/apps/contracts/apps.py | 6 + core/apps/contracts/choices/__init__.py | 0 core/apps/contracts/choices/contracts.py | 12 + core/apps/contracts/filters/__init__.py | 4 + core/apps/contracts/filters/attached_files.py | 13 + core/apps/contracts/filters/contracts.py | 13 + core/apps/contracts/filters/file_contents.py | 13 + core/apps/contracts/filters/owners.py | 13 + core/apps/contracts/forms/__init__.py | 4 + core/apps/contracts/forms/attached_files.py | 10 + core/apps/contracts/forms/contracts.py | 10 + core/apps/contracts/forms/file_contents.py | 10 + core/apps/contracts/forms/owners.py | 10 + .../apps/contracts/migrations/0001_initial.py | 347 ++++++++ core/apps/contracts/migrations/__init__.py | 0 core/apps/contracts/models/__init__.py | 4 + core/apps/contracts/models/attached_files.py | 110 +++ core/apps/contracts/models/contracts.py | 70 ++ core/apps/contracts/models/file_contents.py | 70 ++ core/apps/contracts/models/owners.py | 279 +++++++ core/apps/contracts/permissions/__init__.py | 4 + .../contracts/permissions/attached_files.py | 12 + core/apps/contracts/permissions/contracts.py | 12 + .../contracts/permissions/file_contents.py | 12 + core/apps/contracts/permissions/owners.py | 12 + core/apps/contracts/serializers/__init__.py | 4 + .../serializers/attached_files/__init__.py | 1 + .../attached_files/attached_files.py | 47 ++ .../serializers/contracts/__init__.py | 1 + .../serializers/contracts/contracts.py | 42 + .../serializers/file_contents/__init__.py | 1 + .../file_contents/file_contents.py | 51 ++ .../contracts/serializers/owners/__init__.py | 1 + .../contracts/serializers/owners/owner.py | 174 ++++ core/apps/contracts/signals/__init__.py | 4 + core/apps/contracts/signals/attached_files.py | 8 + core/apps/contracts/signals/contracts.py | 8 + core/apps/contracts/signals/file_contents.py | 8 + core/apps/contracts/signals/owners.py | 8 + core/apps/contracts/tests/__init__.py | 4 + .../contracts/tests/test_attached_files.py | 47 ++ core/apps/contracts/tests/test_contracts.py | 47 ++ .../contracts/tests/test_file_contents.py | 47 ++ core/apps/contracts/tests/test_owners.py | 47 ++ core/apps/contracts/translation/__init__.py | 4 + .../contracts/translation/attached_files.py | 8 + core/apps/contracts/translation/contracts.py | 8 + .../contracts/translation/file_contents.py | 8 + core/apps/contracts/translation/owners.py | 8 + core/apps/contracts/urls.py | 37 + core/apps/contracts/validators/__init__.py | 4 + .../contracts/validators/attached_files.py | 34 + core/apps/contracts/validators/contracts.py | 23 + .../contracts/validators/file_contents.py | 8 + core/apps/contracts/validators/owners.py | 146 ++++ core/apps/contracts/views/__init__.py | 4 + core/apps/contracts/views/attached_files.py | 35 + core/apps/contracts/views/contracts.py | 123 +++ core/apps/contracts/views/file_contents.py | 35 + core/apps/contracts/views/owners.py | 171 ++++ core/apps/logs/.gitignore | 2 + 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 | 60 ++ 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/services/__init__.py | 3 + core/services/otp.py | 136 +++ core/services/sms.py | 57 ++ core/services/user.py | 64 ++ core/utils/__init__.py | 3 + core/utils/base_model.py | 28 + core/utils/cache.py | 18 + core/utils/console.py | 79 ++ core/utils/core.py | 6 + core/utils/storage.py | 33 + docker-compose.yml | 81 ++ docker/Dockerfile.nginx | 3 + docker/Dockerfile.web | 9 + index.html | 13 + jst.json | 8 + manage.py | 24 + pyproject.toml | 21 + requirements.txt | 86 ++ resources/.gitignore | 1 + resources/layout/.flake8 | 3 + resources/layout/Dockerfile.alpine | 13 + resources/layout/Dockerfile.nginx | 3 + resources/layout/minio-setup.sh | 32 + 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 | 11 + resources/scripts/entrypoint.sh | 11 + 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/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 ++ 298 files changed, 10533 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 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/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/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/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/__init__.py create mode 100644 core/apps/accounts/models/__init__.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/users.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/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/user.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/urls.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/me.py create mode 100644 core/apps/accounts/views/users.py create mode 100644 core/apps/banks/__init__.py create mode 100644 core/apps/banks/admin/__init__.py create mode 100644 core/apps/banks/admin/banks.py create mode 100644 core/apps/banks/apps.py create mode 100644 core/apps/banks/filters/__init__.py create mode 100644 core/apps/banks/filters/banks.py create mode 100644 core/apps/banks/forms/__init__.py create mode 100644 core/apps/banks/forms/banks.py create mode 100644 core/apps/banks/migrations/0001_initial.py create mode 100644 core/apps/banks/migrations/__init__.py create mode 100644 core/apps/banks/models/__init__.py create mode 100644 core/apps/banks/models/banks.py create mode 100644 core/apps/banks/permissions/__init__.py create mode 100644 core/apps/banks/permissions/banks.py create mode 100644 core/apps/banks/serializers/__init__.py create mode 100644 core/apps/banks/serializers/banks/__init__.py create mode 100644 core/apps/banks/serializers/banks/banks.py create mode 100644 core/apps/banks/signals/__init__.py create mode 100644 core/apps/banks/signals/banks.py create mode 100644 core/apps/banks/tests/__init__.py create mode 100644 core/apps/banks/tests/test_banks.py create mode 100644 core/apps/banks/translation/__init__.py create mode 100644 core/apps/banks/translation/banks.py create mode 100644 core/apps/banks/urls.py create mode 100644 core/apps/banks/validators/__init__.py create mode 100644 core/apps/banks/validators/banks.py create mode 100644 core/apps/banks/views/__init__.py create mode 100644 core/apps/banks/views/banks.py create mode 100644 core/apps/companies/__init__.py create mode 100644 core/apps/companies/admin/__init__.py create mode 100644 core/apps/companies/admin/accounts.py create mode 100644 core/apps/companies/admin/companies.py create mode 100644 core/apps/companies/admin/folders.py create mode 100644 core/apps/companies/apps.py create mode 100644 core/apps/companies/filters/__init__.py create mode 100644 core/apps/companies/filters/accounts.py create mode 100644 core/apps/companies/filters/companies.py create mode 100644 core/apps/companies/filters/folders.py create mode 100644 core/apps/companies/forms/__init__.py create mode 100644 core/apps/companies/forms/accounts.py create mode 100644 core/apps/companies/forms/companies.py create mode 100644 core/apps/companies/forms/folders.py create mode 100644 core/apps/companies/migrations/0001_initial.py create mode 100644 core/apps/companies/migrations/0002_alter_companymodel_logo_and_more.py create mode 100644 core/apps/companies/migrations/0003_companyfoldermodel_contracts.py create mode 100644 core/apps/companies/migrations/0004_alter_companymodel_logo_and_more.py create mode 100644 core/apps/companies/migrations/__init__.py create mode 100644 core/apps/companies/models/__init__.py create mode 100644 core/apps/companies/models/accounts.py create mode 100644 core/apps/companies/models/companies.py create mode 100644 core/apps/companies/models/folders.py create mode 100644 core/apps/companies/permissions/__init__.py create mode 100644 core/apps/companies/permissions/accounts.py create mode 100644 core/apps/companies/permissions/companies.py create mode 100644 core/apps/companies/permissions/folders.py create mode 100644 core/apps/companies/serializers/__init__.py create mode 100644 core/apps/companies/serializers/accounts/__init__.py create mode 100644 core/apps/companies/serializers/accounts/company_accounts.py create mode 100644 core/apps/companies/serializers/companies/__init__.py create mode 100644 core/apps/companies/serializers/companies/companies.py create mode 100644 core/apps/companies/serializers/folders/__init__.py create mode 100644 core/apps/companies/serializers/folders/company_folders.py create mode 100644 core/apps/companies/signals/__init__.py create mode 100644 core/apps/companies/signals/accounts.py create mode 100644 core/apps/companies/signals/companies.py create mode 100644 core/apps/companies/signals/folders.py create mode 100644 core/apps/companies/tests/__init__.py create mode 100644 core/apps/companies/tests/test_accounts.py create mode 100644 core/apps/companies/tests/test_companies.py create mode 100644 core/apps/companies/tests/test_folders.py create mode 100644 core/apps/companies/translation/__init__.py create mode 100644 core/apps/companies/translation/accounts.py create mode 100644 core/apps/companies/translation/companies.py create mode 100644 core/apps/companies/translation/folders.py create mode 100644 core/apps/companies/urls.py create mode 100644 core/apps/companies/validators/__init__.py create mode 100644 core/apps/companies/validators/accounts.py create mode 100644 core/apps/companies/validators/companies.py create mode 100644 core/apps/companies/validators/folders.py create mode 100644 core/apps/companies/views/__init__.py create mode 100644 core/apps/companies/views/accounts.py create mode 100644 core/apps/companies/views/companies.py create mode 100644 core/apps/companies/views/folders.py create mode 100644 core/apps/contracts/__init__.py create mode 100644 core/apps/contracts/admin/__init__.py create mode 100644 core/apps/contracts/admin/attached_files.py create mode 100644 core/apps/contracts/admin/contracts.py create mode 100644 core/apps/contracts/admin/file_contents.py create mode 100644 core/apps/contracts/admin/owners.py create mode 100644 core/apps/contracts/apps.py create mode 100644 core/apps/contracts/choices/__init__.py create mode 100644 core/apps/contracts/choices/contracts.py create mode 100644 core/apps/contracts/filters/__init__.py create mode 100644 core/apps/contracts/filters/attached_files.py create mode 100644 core/apps/contracts/filters/contracts.py create mode 100644 core/apps/contracts/filters/file_contents.py create mode 100644 core/apps/contracts/filters/owners.py create mode 100644 core/apps/contracts/forms/__init__.py create mode 100644 core/apps/contracts/forms/attached_files.py create mode 100644 core/apps/contracts/forms/contracts.py create mode 100644 core/apps/contracts/forms/file_contents.py create mode 100644 core/apps/contracts/forms/owners.py create mode 100644 core/apps/contracts/migrations/0001_initial.py create mode 100644 core/apps/contracts/migrations/__init__.py create mode 100644 core/apps/contracts/models/__init__.py create mode 100644 core/apps/contracts/models/attached_files.py create mode 100644 core/apps/contracts/models/contracts.py create mode 100644 core/apps/contracts/models/file_contents.py create mode 100644 core/apps/contracts/models/owners.py create mode 100644 core/apps/contracts/permissions/__init__.py create mode 100644 core/apps/contracts/permissions/attached_files.py create mode 100644 core/apps/contracts/permissions/contracts.py create mode 100644 core/apps/contracts/permissions/file_contents.py create mode 100644 core/apps/contracts/permissions/owners.py create mode 100644 core/apps/contracts/serializers/__init__.py create mode 100644 core/apps/contracts/serializers/attached_files/__init__.py create mode 100644 core/apps/contracts/serializers/attached_files/attached_files.py create mode 100644 core/apps/contracts/serializers/contracts/__init__.py create mode 100644 core/apps/contracts/serializers/contracts/contracts.py create mode 100644 core/apps/contracts/serializers/file_contents/__init__.py create mode 100644 core/apps/contracts/serializers/file_contents/file_contents.py create mode 100644 core/apps/contracts/serializers/owners/__init__.py create mode 100644 core/apps/contracts/serializers/owners/owner.py create mode 100644 core/apps/contracts/signals/__init__.py create mode 100644 core/apps/contracts/signals/attached_files.py create mode 100644 core/apps/contracts/signals/contracts.py create mode 100644 core/apps/contracts/signals/file_contents.py create mode 100644 core/apps/contracts/signals/owners.py create mode 100644 core/apps/contracts/tests/__init__.py create mode 100644 core/apps/contracts/tests/test_attached_files.py create mode 100644 core/apps/contracts/tests/test_contracts.py create mode 100644 core/apps/contracts/tests/test_file_contents.py create mode 100644 core/apps/contracts/tests/test_owners.py create mode 100644 core/apps/contracts/translation/__init__.py create mode 100644 core/apps/contracts/translation/attached_files.py create mode 100644 core/apps/contracts/translation/contracts.py create mode 100644 core/apps/contracts/translation/file_contents.py create mode 100644 core/apps/contracts/translation/owners.py create mode 100644 core/apps/contracts/urls.py create mode 100644 core/apps/contracts/validators/__init__.py create mode 100644 core/apps/contracts/validators/attached_files.py create mode 100644 core/apps/contracts/validators/contracts.py create mode 100644 core/apps/contracts/validators/file_contents.py create mode 100644 core/apps/contracts/validators/owners.py create mode 100644 core/apps/contracts/views/__init__.py create mode 100644 core/apps/contracts/views/attached_files.py create mode 100644 core/apps/contracts/views/contracts.py create mode 100644 core/apps/contracts/views/file_contents.py create mode 100644 core/apps/contracts/views/owners.py create mode 100644 core/apps/logs/.gitignore 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/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/base_model.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.yml create mode 100644 docker/Dockerfile.nginx create mode 100644 docker/Dockerfile.web create mode 100644 index.html create mode 100644 jst.json 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/minio-setup.sh 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/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 diff --git a/.cruft.json b/.cruft.json new file mode 100644 index 0000000..25e436d --- /dev/null +++ b/.cruft.json @@ -0,0 +1,28 @@ +{ + "template": "https://github.com/JscorpTech/django", + "commit": "21828db40fdb614c96281df6ebd424a9c38edfab", + "checkout": null, + "context": { + "cookiecutter": { + "cacheops": true, + "silk": true, + "storage": true, + "rosetta": false, + "channels": false, + "ckeditor": false, + "modeltranslation": false, + "parler": false, + "project_name": "trustme", + "settings_module": "config.settings.local", + "runner": "wsgi", + "script": "entrypoint.sh", + "key": "key", + "port": "8000", + "phone": "998974903433", + "password": "2309", + "max_line_length": "120", + "project_slug": "trustme" + } + }, + "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..2ff0fa4 --- /dev/null +++ b/.env.example @@ -0,0 +1,63 @@ +# Django configs +DJANGO_SECRET_KEY=key +DEBUG=True +DJANGO_SETTINGS_MODULE=config.settings.local +COMMAND=sh ./resources/scripts/entrypoint.sh +PORT=8000 +#! debug | prod +PROJECT_ENV=debug +PROTOCOL_HTTPS=False + +# 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/Makefile b/Makefile new file mode 100644 index 0000000..cafd984 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ + +start: up makemigration migrate seed + +createsuperuser: + docker compose exec web python manage.py createsuperuser + +shell: + docker compose exec web python manage.py shell + +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 \ No newline at end of file diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..c669d6f --- /dev/null +++ b/README.MD @@ -0,0 +1,156 @@ + +endpoints from models shoule be realized: + +accounts: + users + +banks: + banks + +companies: + companies + company-accounts + company-folders + +contracts: + contracts + contract-owners + contract-attached-files + contract-file-contents + +individuals # contract-owner-part +legal-entities # contract-owner-part + + +Endpoints should be created: + + +# admin - endpoint is only for admins +# user - endpoint can be used by regular users with given +# credentials + + +================================================================== + + +users: + +POST /auth/register # user # remake +POST /auth/verify # user # ok +GET /auth/me # user # ok + +GET /me/companies # user # ok +POST /me/companies # user # ok + +GET /users//companies # user # ok +POST /users//companies # user # ok + + +================================================================== + + +companies: +GET /companies # admin # ok +POST /companies # admin # ok +GET /companies/ # admin # ok +DELETE /companies/ # admin # ok +PATCH /companies/ # admin # ok + +GET /companies//contracts # user # partial +- - folder: uuid | None +- - status: list[str] + +GET /companies//folders # user #! not working +POST /companies//folders # user #! not working + +GET /companies//accounts # user # ok +POST /companies//accounts # user #! TODO + + +================================================================== + + +GET /company-accounts # admin # ok +POST /company-accounts # admin # ok +GET /company-accounts/ # admin # ok +PATCH /company-accounts/ # admin # ok +DELETE /company-accounts/ # admin # ok + +POST /accounts/verify # user #! TODO +- - phone +- - code + + +================================================================== + + +GET /banks # admin # ok +POST /banks # admin # ok +GET /banks/ # admin # ok +DELETE /banks/ # admin # ok +PATCH /banks/ # admin # ok + + +================================================================== + + +GET /contracts # admin # ok +POST /contracts # admin # ok +GET /contracts/ # admin # ok +DELETE /contracts/ # admin # ok +PATCH /contracts/ # admin # ok + +GET /contracts//files # user # ok + +GET /contracts//owners # user # ok + + +================================================================== + + +GET /contract-owners # admin # ok +POST /contract-owners # admin # ok +GET /contract-owners/ # admin # ok +DELETE /contract-owners/ # admin # ok +PATCH /contract-owners/ # admin # ok + +GET /contract-owners//contract # user # ok | full contract data return + +POST /contract-owners//files # user # ok +DELETE /contract-owners//files/ # user # ok + +GET /contract-owners//files # user # not ok | full data return + +POST /contract-owners//files//upload # user # ok + + +================================================================== + + +GET /files # admin # ok +POST /files # admin # ok +GET /files/ # admin # ok +DELETE /files/ # admin # ok +PATCH /files/ # admin # ok + + +================================================================== + + +GET /folders # admin # ok +POST /folders # admin # ok +GET /folders/ # admin # ok +DELETE /folders/ # admin # ok +PATCH /folders/ # admin # ok + +GET /folders//contracts # admin # ok + + +================================================================== + + +GET /file-contents # admin # ok +POST /file-contents # admin # ok +GET /file-contents/ # admin # ok +DELETE /file-contents/ # admin # ok +PATCH /file-contents/ # admin # ok 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..0e14eda --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,12 @@ +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")) + + +application = asgi_application + 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..0979789 --- /dev/null +++ b/config/conf/__init__.py @@ -0,0 +1,11 @@ +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 .storage import * # noqa diff --git a/config/conf/apps.py b/config/conf/apps.py new file mode 100644 index 0000000..79ef56d --- /dev/null +++ b/config/conf/apps.py @@ -0,0 +1,27 @@ +from config.env import env + +APPS = [ + + "cacheops", + + + + "drf_spectacular", + "rest_framework", + "corsheaders", + "django_filters", + "django_redis", + "rest_framework_simplejwt", + "django_core", + 'storages', + + "core.apps.accounts.apps.AccountsConfig", + "core.apps.companies.apps.ModuleConfig", + "core.apps.contracts.apps.ModuleConfig", + "core.apps.banks.apps.ModuleConfig", +] + +if env.str("PROJECT_ENV") == "debug": + 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..71dad20 --- /dev/null +++ b/config/conf/modules.py @@ -0,0 +1,3 @@ +MODULES = [ + "core.apps.shared", +] 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..d34df1a --- /dev/null +++ b/config/conf/spectacular.py @@ -0,0 +1,32 @@ +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"], + "COMPONENT_SPLIT_REQUEST": True, +} + + +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..cfbd93a --- /dev/null +++ b/config/conf/unfold.py @@ -0,0 +1,41 @@ +from . import navigation + +UNFOLD = { + "DASHBOARD_CALLBACK": "django_core.views.dashboard_callback", + "SITE_TITLE": None, + "SHOW_LANGUAGES": True, + "SITE_HEADER": None, + "SITE_URL": "/", + "SITE_SYMBOL": "speed", # symbol from icon set + "SHOW_HISTORY": True, # show/hide "History" button, default: True + "SHOW_VIEW_ON_SITE": True, + "COLORS": { + "primary": { + "50": "220 255 230", + "100": "190 255 200", + "200": "160 255 170", + "300": "130 255 140", + "400": "100 255 110", + "500": "70 255 80", + "600": "50 225 70", + "700": "40 195 60", + "800": "30 165 50", + "900": "20 135 40", + "950": "10 105 30", + }, + }, + "EXTENSIONS": { + "modeltranslation": { + "flags": { + "en": "πŸ‡¬πŸ‡§", + "uz": "πŸ‡ΊπŸ‡Ώ", + "ru": "πŸ‡·πŸ‡Ί", + }, + }, + }, + "SIDEBAR": { + "show_search": True, # Search in applications and models names + "show_all_applications": True, + # "navigation": navigation.PAGES, # Pagelarni qo'lda qo'shish + }, +} diff --git a/config/env.py b/config/env.py new file mode 100644 index 0000000..f37c164 --- /dev/null +++ b/config/env.py @@ -0,0 +1,28 @@ +""" +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"), +) 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..7c1b661 --- /dev/null +++ b/config/settings/common.py @@ -0,0 +1,172 @@ +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 = [ + + "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.str("PROJECT_ENV") == "debug": + 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 + + + + +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/urls.py b/config/urls.py new file mode 100644 index 0000000..17cc628 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,58 @@ +""" +All urls configurations tree +""" + +from django.conf import settings +from config.env import env +from django.contrib import admin +from django.urls import include, path, re_path +from django.views.static import serve +from drf_spectacular.views import (SpectacularAPIView, SpectacularRedocView, + SpectacularSwaggerView) + +################ +# My apps url +################ +urlpatterns = [ + path("", include("core.apps.accounts.urls")), + path("", include("core.apps.banks.urls")), + path("", include("core.apps.companies.urls")), + path("", include("core.apps.contracts.urls")), +] + + +################ +# Library urls +################ +urlpatterns += [ + path("admin/", admin.site.urls), + path("accounts/", include("django.contrib.auth.urls")), + path("i18n/", include("django.conf.urls.i18n")), + + +] + +################ +# Project env debug mode +################ +if env.str("PROJECT_ENV") == "debug": + urlpatterns += [ + path('silk/', include('silk.urls', namespace='silk')) + ] + + ################ + # Swagger urls + ################ + 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"), + ] + +################ +# Media urls +################ +urlpatterns += [ + re_path(r"static/(?P.*)", serve, {"document_root": settings.STATIC_ROOT}), + re_path(r"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..6e3a821 --- /dev/null +++ b/core/apps/accounts/admin/__init__.py @@ -0,0 +1,2 @@ +from .core 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/user.py b/core/apps/accounts/admin/user.py new file mode 100644 index 0000000..4bbbcbe --- /dev/null +++ b/core/apps/accounts/admin/user.py @@ -0,0 +1,125 @@ +from django.contrib.auth import admin +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin # type: ignore +from unfold.forms import AdminPasswordChangeForm # type: ignore # UserCreationForm, +from unfold.forms import UserChangeForm # type: ignore + + +class CustomUserAdmin(admin.UserAdmin, ModelAdmin): + change_password_form = AdminPasswordChangeForm + # add_form = UserCreationForm + form = UserChangeForm + list_display = ( + "phone", + "full_name", + "role", + "validated_at", + ) + + search_fields = ( + "first_name", + "last_name", + "phone", + "validated_at", + "inn_code", + ) + + list_display_links = ( + "phone", + ) + + 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", + ), + }, + ), + ( + _("Important dates"), + { + "fields": + ( + "last_login", + "date_joined" + ) + } + ), + ) + + +class PermissionAdmin(ModelAdmin): + list_display = ( + "name", + ) + search_fields = ( + "name", + ) + list_display_links = ( + "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/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..e7df8d9 --- /dev/null +++ b/core/apps/accounts/managers/user.py @@ -0,0 +1,23 @@ +from django.contrib.auth import base_user + + +class UserManager(base_user.BaseUserManager): + def create_user(self, phone, password=None, **extra_fields): + if not phone: + raise ValueError("The phone number must be set") + + 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..61a24d6 --- /dev/null +++ b/core/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,141 @@ +# Generated by Django 5.2.4 on 2025-08-01 09:53 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ("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", + ), + ), + ( + "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")), + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "phone", + models.CharField( + max_length=255, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message="Enter a valid international phone number (E.164 format, e.g., +14155552671).", + regex="^\\+?[1-9]\\d{1,14}$", + ) + ], + ), + ), + ("username", models.CharField(blank=True, max_length=255, null=True)), + ("validated_at", models.DateTimeField(blank=True, null=True)), + ("inn_code", models.CharField(blank=True, max_length=12, null=True)), + ("first_name", models.CharField(max_length=150, verbose_name="First Name")), + ("last_name", models.CharField(max_length=150, verbose_name="Last Name")), + ("email", models.EmailField(blank=True, max_length=254, verbose_name="Email Address")), + ( + "role", + models.CharField( + choices=[("superuser", "Superuser"), ("admin", "Admin"), ("user", "User")], + default="user", + max_length=255, + verbose_name="Role", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")), + ( + "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", + "db_table": "users", + }, + ), + 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)), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + "verbose_name": "Reset Token", + "verbose_name_plural": "Reset Tokens", + }, + ), + migrations.AddIndex( + model_name="user", + index=models.Index(fields=["phone"], name="users_phone_inx"), + ), + migrations.AddIndex( + model_name="user", + index=models.Index(fields=["email"], name="users_email_inx"), + ), + migrations.AddIndex( + model_name="user", + index=models.Index(fields=["inn_code"], name="users_inn_code_inx"), + ), + ] 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..1b85129 --- /dev/null +++ b/core/apps/accounts/models/__init__.py @@ -0,0 +1,3 @@ +# isort: skip_file +from .user import * # noqa +from .reset_token import * # noqa 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..8be5125 --- /dev/null +++ b/core/apps/accounts/models/user.py @@ -0,0 +1,133 @@ +import uuid + +from django.contrib.auth import models as auth_models +from django.db import models + +from django.utils.translation import gettext_lazy as _ +from django.core.validators import RegexValidator + +from ..choices import RoleChoice +from ..managers import UserManager + + +phone_validator = RegexValidator( + regex=r'^\+?[1-9]\d{1,14}$', + message=_( + "Enter a valid international phone number " + "(E.164 format, e.g., +14155552671)." + ) +) + + +class User(auth_models.AbstractUser): + id = models.UUIDField( + _("ID"), + primary_key=True, + default=uuid.uuid4, + editable=False + ) + + phone = models.CharField( + max_length=255, + validators=[ + phone_validator + ], + unique=True + ) + username = models.CharField( + max_length=255, + null=True, + blank=True + ) + validated_at = models.DateTimeField( + null=True, + blank=True + ) + inn_code = models.CharField( + max_length=12, + null=True, + blank=True + ) + first_name = models.CharField( + _("First Name"), + max_length=150, + blank=False, + null=False, + ) + last_name = models.CharField( + _("Last Name"), + max_length=150, + blank=False, + null=False + ) + email = models.EmailField( + _("Email Address"), + blank=True, + ) + + role = models.CharField( + _("Role"), + max_length=255, + choices=RoleChoice, + default=RoleChoice.USER, + ) + + created_at = models.DateTimeField( + verbose_name=_("Created At"), + auto_now_add=True + ) + + updated_at = models.DateTimeField( + verbose_name=_("Updated At"), + auto_now=True + ) + + USERNAME_FIELD = "phone" + + REQUIRED_FIELDS = [ + "first_name", + "last_name", + *auth_models.AbstractUser.REQUIRED_FIELDS + ] + objects = UserManager() # type: ignore + + def save(self, *args: object, **kwargs: object): + """ + save method overwriten to make self.role updated + every time when user is made admin or superuser + """ + if self.is_staff: + self.role = RoleChoice.ADMIN + if self.is_superuser: + self.role = RoleChoice.SUPERUSER + else: + self.role = RoleChoice.USER + + super().save(*args, **kwargs) # type: ignore + + def __str__(self): + return self.phone + + @property + def full_name(self) -> str: + return f"{self.first_name} {self.last_name}" + + class Meta: + db_table = "users" + verbose_name = _("User") + verbose_name_plural = _("Users") + + indexes = [ + models.Index( + fields=["phone"], + name="users_phone_inx" + ), + models.Index( + fields=["email"], + name="users_email_inx", + ), + models.Index( + fields=["inn_code"], + name="users_inn_code_inx" + ) + ] diff --git a/core/apps/accounts/permissions/users.py b/core/apps/accounts/permissions/users.py new file mode 100644 index 0000000..e69de29 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..227d32c --- /dev/null +++ b/core/apps/accounts/serializers/__init__.py @@ -0,0 +1,4 @@ +from .auth import * # noqa +from .change_password 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..960e1ec --- /dev/null +++ b/core/apps/accounts/serializers/auth.py @@ -0,0 +1,59 @@ +from django.contrib.auth import get_user_model +from django.utils.translation import gettext as _ +from rest_framework import exceptions, serializers + + +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.IntegerField(min_value=1000, max_value=9999) + 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.IntegerField(min_value=1000, max_value=9999) + 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/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..6a1ab45 --- /dev/null +++ b/core/apps/accounts/signals/__init__.py @@ -0,0 +1 @@ +from .user import * # noqa \ No newline at end of file diff --git a/core/apps/accounts/signals/user.py b/core/apps/accounts/signals/user.py new file mode 100644 index 0000000..547fb21 --- /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": str(instance.id)} + instance.save() 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/urls.py b/core/apps/accounts/urls.py new file mode 100644 index 0000000..667740a --- /dev/null +++ b/core/apps/accounts/urls.py @@ -0,0 +1,35 @@ +""" +Accounts app urls +""" + +from django.urls import path, include +from rest_framework_simplejwt import views as jwt_views +from .views import ( + RegisterView, + ResetPasswordView, + MeView, + ChangePasswordView, + MeCompanyView, +) +from rest_framework.routers import DefaultRouter # type: ignore + + +router = DefaultRouter() +router.register("auth", RegisterView, basename="auth") # type: ignore +router.register("auth", ResetPasswordView, basename="reset-password") # type: ignore +router.register("auth", MeView, basename="me") # type: ignore +router.register("auth", ChangePasswordView, basename="change-password") # type: ignore + +router.register(r"me/companies", MeCompanyView, "me-company") # type: ignore + + +urlpatterns = [ # type: ignore + path("", include(router.urls)), # type: ignore + 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/views/__init__.py b/core/apps/accounts/views/__init__.py new file mode 100644 index 0000000..9ac6afb --- /dev/null +++ b/core/apps/accounts/views/__init__.py @@ -0,0 +1,3 @@ +from .auth import * # noqa +from .users import * # type: ignore +from .me import * # type: ignore 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/me.py b/core/apps/accounts/views/me.py new file mode 100644 index 0000000..77c2bac --- /dev/null +++ b/core/apps/accounts/views/me.py @@ -0,0 +1,70 @@ +from rest_framework.viewsets import GenericViewSet # type: ignore +from rest_framework.decorators import action # type: ignore +from rest_framework import status # type: ignore +from rest_framework.request import HttpRequest # type: ignore +from rest_framework.response import Response # type: ignore +from rest_framework.permissions import ( # type: ignore + IsAuthenticated +) + +from django_core.mixins import BaseViewSetMixin # type: ignore + +from core.apps.companies.serializers import ( + RetrieveCompanySerializer, + CreateCompanySerializer, +) +from core.apps.companies.models import ( + CompanyModel, + CompanyAccountModel +) + +from django.db import transaction + + +class MeCompanyView(BaseViewSetMixin, GenericViewSet): + permission_classes = [IsAuthenticated] + + action_permission_classes = {} + action_serializer_class = { + "create": CreateCompanySerializer, + "list": RetrieveCompanySerializer, + } + + def list( + self, + request: HttpRequest, + *args: object, + **kwargs: object + ) -> Response: + + companies = CompanyModel.objects.filter( + accounts__user=request.user + ) + + return Response( + RetrieveCompanySerializer(instance=companies, many=True).data, + status=status.HTTP_200_OK + ) + + def create( + self, + request: HttpRequest, + *args: object, + **kwargs: object + ) -> Response: + + with transaction.atomic(): + serializer = CreateCompanySerializer(data=request.data) # type: ignore + serializer.is_valid(raise_exception=True) + company = serializer.save() # type: ignore + + account = CompanyAccountModel( + company=company, + user=request.user + ) + account.save() + + return Response( + data=serializer.data, + status=status.HTTP_201_CREATED + ) \ No newline at end of file diff --git a/core/apps/accounts/views/users.py b/core/apps/accounts/views/users.py new file mode 100644 index 0000000..d7ed7b2 --- /dev/null +++ b/core/apps/accounts/views/users.py @@ -0,0 +1,84 @@ +import uuid + +from drf_spectacular.utils import extend_schema + +from rest_framework.viewsets import GenericViewSet # type: ignore +from rest_framework.decorators import action # type: ignore +from rest_framework import status # type: ignore +from rest_framework.request import HttpRequest # type: ignore +from rest_framework.response import Response # type: ignore +from rest_framework.permissions import ( # type: ignore + IsAdminUser, +) +from django_core.mixins import BaseViewSetMixin + +from rest_framework.generics import get_object_or_404 # type: ignore +from django.contrib.auth import get_user_model +from django.db import transaction + + +from core.apps.companies.serializers import ( + CreateCompanySerializer, + RetrieveCompanySerializer +) +from core.apps.companies.models import ( + CompanyModel, + CompanyAccountModel, +) + +UserModel = get_user_model() + + +class UserCompaniesView(BaseViewSetMixin, GenericViewSet): + permission_classes = [IsAdminUser] + + action_permission_classes = {} + action_permission_classes = { + "list_company": RetrieveCompanySerializer, + "create_company": CreateCompanySerializer, + } + + @extend_schema( + summary="Get list of companies", + description="Get list of companies", + ) + @action(url_path="companies", detail=True, methods=["GET"]) + def list_company( + self, + request: HttpRequest, + pk: uuid.UUID, + *args: object, + **kwargs: object, + ) -> Response: + + companies = CompanyModel.objects.filter(accounts__user__pk=pk) + return Response( + data=RetrieveCompanySerializer(instance=companies, many=True), + status=status.HTTP_200_OK + ) + + @extend_schema( + summary="Create Company", + description="Create Company", + ) + @action(url_path="companies", detail=True, methods=["POST"]) + def create_company( + self, + request: HttpRequest, + pk: uuid.UUID, + *args: object, + **kwargs: object, + ) -> Response: + + with transaction.atomic(): + ser = CreateCompanySerializer(data=request.data) # type: ignore + ser.is_valid(raise_exception=True) + company = ser.save() # type: ignore + + user = get_object_or_404(UserModel, pk=pk) + + account = CompanyAccountModel(company=company, user=user) + account.save() + + return Response(data=ser.data, status=status.HTTP_201_CREATED) + \ No newline at end of file diff --git a/core/apps/banks/__init__.py b/core/apps/banks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/banks/admin/__init__.py b/core/apps/banks/admin/__init__.py new file mode 100644 index 0000000..cc2f6ee --- /dev/null +++ b/core/apps/banks/admin/__init__.py @@ -0,0 +1 @@ +from .banks import * # noqa diff --git a/core/apps/banks/admin/banks.py b/core/apps/banks/admin/banks.py new file mode 100644 index 0000000..26bb66b --- /dev/null +++ b/core/apps/banks/admin/banks.py @@ -0,0 +1,23 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin # type: ignore + +from core.apps.banks.models import BankModel + + +@admin.register(BankModel) +class BankAdmin(ModelAdmin): + list_display = ( + "name", + "bic_code", + "created_at", + "updated_at", + ) + search_fields = ( + "name", + "bic_code", + "created_at", + "updated_at" + ) + list_display_links = ( + "name", + ) diff --git a/core/apps/banks/apps.py b/core/apps/banks/apps.py new file mode 100644 index 0000000..8ec932b --- /dev/null +++ b/core/apps/banks/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.banks" diff --git a/core/apps/banks/filters/__init__.py b/core/apps/banks/filters/__init__.py new file mode 100644 index 0000000..cc2f6ee --- /dev/null +++ b/core/apps/banks/filters/__init__.py @@ -0,0 +1 @@ +from .banks import * # noqa diff --git a/core/apps/banks/filters/banks.py b/core/apps/banks/filters/banks.py new file mode 100644 index 0000000..72bbd02 --- /dev/null +++ b/core/apps/banks/filters/banks.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.banks.models import BanksModel + + +class BanksFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = BanksModel + fields = [ + "name", + ] diff --git a/core/apps/banks/forms/__init__.py b/core/apps/banks/forms/__init__.py new file mode 100644 index 0000000..cc2f6ee --- /dev/null +++ b/core/apps/banks/forms/__init__.py @@ -0,0 +1 @@ +from .banks import * # noqa diff --git a/core/apps/banks/forms/banks.py b/core/apps/banks/forms/banks.py new file mode 100644 index 0000000..a4d6a11 --- /dev/null +++ b/core/apps/banks/forms/banks.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.banks.models import BanksModel + + +class BanksForm(forms.ModelForm): + + class Meta: + model = BanksModel + fields = "__all__" diff --git a/core/apps/banks/migrations/0001_initial.py b/core/apps/banks/migrations/0001_initial.py new file mode 100644 index 0000000..d2ca555 --- /dev/null +++ b/core/apps/banks/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.4 on 2025-08-01 09:53 + +import django.core.validators +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="BankModel", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")), + ( + "name", + models.CharField( + max_length=255, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message="Enter a valid bank name. Only letters, numbers, spaces, hyphens, ampersands, commas, periods, apostrophes, and parentheses are allowed. Length must be 2–100 characters.", + regex="^[A-Za-z0-9Γ€-ΓΏ&'\\-.,() ]{2,100}$", + ) + ], + verbose_name="Name", + ), + ), + ( + "bic_code", + models.CharField( + unique=True, + validators=[ + django.core.validators.RegexValidator( + code="Invalid BIC/SWIFT code", + message="Enter a valid BIC/SWIFT code (8 or 11 uppercase letters/numbers).", + regex="^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$", + ) + ], + verbose_name="BIC code", + ), + ), + ], + options={ + "verbose_name": "Bank", + "verbose_name_plural": "Banks", + "db_table": "banks", + "indexes": [ + models.Index(fields=["bic_code"], name="banks_bic_code_inx"), + models.Index(fields=["name"], name="banks_name_inx"), + ], + }, + ), + ] diff --git a/core/apps/banks/migrations/__init__.py b/core/apps/banks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/banks/models/__init__.py b/core/apps/banks/models/__init__.py new file mode 100644 index 0000000..cc2f6ee --- /dev/null +++ b/core/apps/banks/models/__init__.py @@ -0,0 +1 @@ +from .banks import * # noqa diff --git a/core/apps/banks/models/banks.py b/core/apps/banks/models/banks.py new file mode 100644 index 0000000..0e41fef --- /dev/null +++ b/core/apps/banks/models/banks.py @@ -0,0 +1,68 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.utils.base_model import UUIDPrimaryKeyBaseModel + +from core.apps.banks.validators.banks import ( + BankValidator, # used with BankModel.clean + bic_validator, # validates bic + name_validator, # validates bank name +) + + +class BankModel(UUIDPrimaryKeyBaseModel): + + name = models.CharField( + _("Name"), + max_length=255, + validators=[ + name_validator, + ], + null=False, + blank=False, + unique=True, + ) + + bic_code = models.CharField( + _("BIC code"), + validators=[ + bic_validator, + ], + null=False, + blank=False, + unique=True, + ) + + def __str__(self): + return self.name + + @classmethod + def _create_fake(cls): + return cls.objects.create( + name="Mock TBC Bank", + bic_code="MOCKUZ22" + ) + + def clean(self) -> None: + super().clean() + + validator = BankValidator(self) + validator() + + class Meta: # type: ignore + db_table = "banks" + + verbose_name = _("Bank") + verbose_name_plural = _("Banks") + + indexes = [ + models.Index( + fields=["bic_code"], + name="banks_bic_code_inx" + ), + models.Index( + fields=["name"], + name="banks_name_inx" + ), + ] + \ No newline at end of file diff --git a/core/apps/banks/permissions/__init__.py b/core/apps/banks/permissions/__init__.py new file mode 100644 index 0000000..cc2f6ee --- /dev/null +++ b/core/apps/banks/permissions/__init__.py @@ -0,0 +1 @@ +from .banks import * # noqa diff --git a/core/apps/banks/permissions/banks.py b/core/apps/banks/permissions/banks.py new file mode 100644 index 0000000..3f3c7d8 --- /dev/null +++ b/core/apps/banks/permissions/banks.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class BanksPermission(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/banks/serializers/__init__.py b/core/apps/banks/serializers/__init__.py new file mode 100644 index 0000000..cc2f6ee --- /dev/null +++ b/core/apps/banks/serializers/__init__.py @@ -0,0 +1 @@ +from .banks import * # noqa diff --git a/core/apps/banks/serializers/banks/__init__.py b/core/apps/banks/serializers/banks/__init__.py new file mode 100644 index 0000000..cc2f6ee --- /dev/null +++ b/core/apps/banks/serializers/banks/__init__.py @@ -0,0 +1 @@ +from .banks import * # noqa diff --git a/core/apps/banks/serializers/banks/banks.py b/core/apps/banks/serializers/banks/banks.py new file mode 100644 index 0000000..bd786ec --- /dev/null +++ b/core/apps/banks/serializers/banks/banks.py @@ -0,0 +1,35 @@ +from rest_framework import serializers # type: ignore + +from core.apps.banks.models import BankModel + + +class BaseBankSerializer(serializers.ModelSerializer): + class Meta: + model = BankModel + fields = "__all__" + read_only_fields = ( + "id", + "created_at", + "updated_at" + ) + + +class ListBankSerializer(BaseBankSerializer): + class Meta(BaseBankSerializer.Meta): ... + + +class RetrieveBankSerializer(BaseBankSerializer): + class Meta(BaseBankSerializer.Meta): ... + + +class CreateBankSerializer(BaseBankSerializer): + class Meta(BaseBankSerializer.Meta): ... + + +class UpdateBankSerializer(BaseBankSerializer): + class Meta(BaseBankSerializer.Meta): ... + + +class DestroyBankSerializer(BaseBankSerializer): + class Meta(BaseBankSerializer.Meta): ... + diff --git a/core/apps/banks/signals/__init__.py b/core/apps/banks/signals/__init__.py new file mode 100644 index 0000000..cc2f6ee --- /dev/null +++ b/core/apps/banks/signals/__init__.py @@ -0,0 +1 @@ +from .banks import * # noqa diff --git a/core/apps/banks/signals/banks.py b/core/apps/banks/signals/banks.py new file mode 100644 index 0000000..2b2ec70 --- /dev/null +++ b/core/apps/banks/signals/banks.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.banks.models import BanksModel + + +@receiver(post_save, sender=BanksModel) +def BanksSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/banks/tests/__init__.py b/core/apps/banks/tests/__init__.py new file mode 100644 index 0000000..6ca4b28 --- /dev/null +++ b/core/apps/banks/tests/__init__.py @@ -0,0 +1 @@ +from .test_banks import * # noqa diff --git a/core/apps/banks/tests/test_banks.py b/core/apps/banks/tests/test_banks.py new file mode 100644 index 0000000..7f00e4f --- /dev/null +++ b/core/apps/banks/tests/test_banks.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.banks.models import BanksModel + + +class BanksTest(TestCase): + + def _create_data(self): + return BanksModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("Banks-list"), + "retrieve": reverse("Banks-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("Banks-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/banks/translation/__init__.py b/core/apps/banks/translation/__init__.py new file mode 100644 index 0000000..cc2f6ee --- /dev/null +++ b/core/apps/banks/translation/__init__.py @@ -0,0 +1 @@ +from .banks import * # noqa diff --git a/core/apps/banks/translation/banks.py b/core/apps/banks/translation/banks.py new file mode 100644 index 0000000..5ae279d --- /dev/null +++ b/core/apps/banks/translation/banks.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.banks.models import BanksModel + + +@register(BanksModel) +class BanksTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/banks/urls.py b/core/apps/banks/urls.py new file mode 100644 index 0000000..11a8461 --- /dev/null +++ b/core/apps/banks/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() +router.register(r"banks", views.BankView, "banks") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/core/apps/banks/validators/__init__.py b/core/apps/banks/validators/__init__.py new file mode 100644 index 0000000..cc2f6ee --- /dev/null +++ b/core/apps/banks/validators/__init__.py @@ -0,0 +1 @@ +from .banks import * # noqa diff --git a/core/apps/banks/validators/banks.py b/core/apps/banks/validators/banks.py new file mode 100644 index 0000000..da16e30 --- /dev/null +++ b/core/apps/banks/validators/banks.py @@ -0,0 +1,33 @@ +# from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator + +from django.utils.translation import gettext_lazy as _ +from django_core.models.base import AbstractBaseModel # type: ignore + + +bic_validator = RegexValidator( + regex=r'^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$', + message=_( + "Enter a valid BIC/SWIFT code " + "(8 or 11 uppercase letters/numbers)." + ), + code='Invalid BIC/SWIFT code' +) + +name_validator = RegexValidator( + regex=r"^[A-Za-z0-9Γ€-ΓΏ&'\-.,() ]{2,100}$", + message=_( + "Enter a valid bank name. " + "Only letters, numbers, spaces, hyphens, ampersands, " + "commas, periods, apostrophes, and parentheses are " + "allowed. Length must be 2–100 characters." + ) +) + + +class BankValidator: + def __init__(self, instance: AbstractBaseModel): + self.instance = instance + + def __call__(self): + return diff --git a/core/apps/banks/views/__init__.py b/core/apps/banks/views/__init__.py new file mode 100644 index 0000000..cc2f6ee --- /dev/null +++ b/core/apps/banks/views/__init__.py @@ -0,0 +1 @@ +from .banks import * # noqa diff --git a/core/apps/banks/views/banks.py b/core/apps/banks/views/banks.py new file mode 100644 index 0000000..bca9745 --- /dev/null +++ b/core/apps/banks/views/banks.py @@ -0,0 +1,29 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAdminUser +from rest_framework.viewsets import ModelViewSet + +from core.apps.banks.models import BankModel +from core.apps.banks.serializers.banks import ( + CreateBankSerializer, + ListBankSerializer, + RetrieveBankSerializer, + UpdateBankSerializer, + DestroyBankSerializer, +) + + +@extend_schema(tags=["Banks"]) +class BankView(BaseViewSetMixin, ModelViewSet): + queryset = BankModel.objects.all() + serializer_class = ListBankSerializer + permission_classes = [IsAdminUser] + + action_permission_classes = {} + action_serializer_class = { + "list": ListBankSerializer, + "retrieve": RetrieveBankSerializer, + "create": CreateBankSerializer, + "update": UpdateBankSerializer, + "destroy": DestroyBankSerializer, + } diff --git a/core/apps/companies/__init__.py b/core/apps/companies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/companies/admin/__init__.py b/core/apps/companies/admin/__init__.py new file mode 100644 index 0000000..426f570 --- /dev/null +++ b/core/apps/companies/admin/__init__.py @@ -0,0 +1,3 @@ +from .accounts import * # noqa +from .companies import * # noqa +from .folders import * # noqa diff --git a/core/apps/companies/admin/accounts.py b/core/apps/companies/admin/accounts.py new file mode 100644 index 0000000..7544113 --- /dev/null +++ b/core/apps/companies/admin/accounts.py @@ -0,0 +1,27 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin # type: ignore + +from core.apps.companies.models import CompanyAccountModel + + +@admin.register(CompanyAccountModel) +class DirectorAdmin(ModelAdmin): + list_display = ( + "user_name", + "role", + "company", + "created_at", + ) + + list_display_links = ( + "user_name", + "role", + ) + + search_fields = ( + "user", + "role", + "company", + "created_at", + "updated_at", + ) diff --git a/core/apps/companies/admin/companies.py b/core/apps/companies/admin/companies.py new file mode 100644 index 0000000..45d5cc7 --- /dev/null +++ b/core/apps/companies/admin/companies.py @@ -0,0 +1,32 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin # type: ignore + +from core.apps.companies.models import CompanyModel + + +@admin.register(CompanyModel) +class CompanyAdmin(ModelAdmin): + list_display = ( + "name", + "company_code", + "phone", + "email", + "iik_code", + "legal_address", + "real_address", + "created_at", + ) + search_fields = ( + "name", + "company_code", + "phone", + "email", + "iik_code", + "legal_address", + "real_address", + "created_at", + "updated_at" + ) + list_display_links = ( + "name", + ) diff --git a/core/apps/companies/admin/folders.py b/core/apps/companies/admin/folders.py new file mode 100644 index 0000000..255ea93 --- /dev/null +++ b/core/apps/companies/admin/folders.py @@ -0,0 +1,24 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin # type: ignore + +from core.apps.companies.models import CompanyFolderModel + + +@admin.register(CompanyFolderModel) +class FolderAdmin(ModelAdmin): + list_display = ( + "name", + "company", + "created_at" + ) + + search_fields = ( + "name", + "company", + "created_at", + "updated_at", + ) + + list_display_links = ( + "name", + ) diff --git a/core/apps/companies/apps.py b/core/apps/companies/apps.py new file mode 100644 index 0000000..b84dd25 --- /dev/null +++ b/core/apps/companies/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.companies" diff --git a/core/apps/companies/filters/__init__.py b/core/apps/companies/filters/__init__.py new file mode 100644 index 0000000..426f570 --- /dev/null +++ b/core/apps/companies/filters/__init__.py @@ -0,0 +1,3 @@ +from .accounts import * # noqa +from .companies import * # noqa +from .folders import * # noqa diff --git a/core/apps/companies/filters/accounts.py b/core/apps/companies/filters/accounts.py new file mode 100644 index 0000000..1dbedce --- /dev/null +++ b/core/apps/companies/filters/accounts.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.companies.models import CompanyaccountModel + + +class CompanyaccountFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = CompanyaccountModel + fields = [ + "name", + ] diff --git a/core/apps/companies/filters/companies.py b/core/apps/companies/filters/companies.py new file mode 100644 index 0000000..9c65349 --- /dev/null +++ b/core/apps/companies/filters/companies.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.companies.models import CompanyModel + + +class CompanyFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = CompanyModel + fields = [ + "name", + ] diff --git a/core/apps/companies/filters/folders.py b/core/apps/companies/filters/folders.py new file mode 100644 index 0000000..d1f2882 --- /dev/null +++ b/core/apps/companies/filters/folders.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.companies.models import CompanyfolderModel + + +class CompanyfolderFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = CompanyfolderModel + fields = [ + "name", + ] diff --git a/core/apps/companies/forms/__init__.py b/core/apps/companies/forms/__init__.py new file mode 100644 index 0000000..426f570 --- /dev/null +++ b/core/apps/companies/forms/__init__.py @@ -0,0 +1,3 @@ +from .accounts import * # noqa +from .companies import * # noqa +from .folders import * # noqa diff --git a/core/apps/companies/forms/accounts.py b/core/apps/companies/forms/accounts.py new file mode 100644 index 0000000..688a174 --- /dev/null +++ b/core/apps/companies/forms/accounts.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.companies.models import CompanyaccountModel + + +class CompanyaccountForm(forms.ModelForm): + + class Meta: + model = CompanyaccountModel + fields = "__all__" diff --git a/core/apps/companies/forms/companies.py b/core/apps/companies/forms/companies.py new file mode 100644 index 0000000..c74b172 --- /dev/null +++ b/core/apps/companies/forms/companies.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.companies.models import CompanyModel + + +class CompanyForm(forms.ModelForm): + + class Meta: + model = CompanyModel + fields = "__all__" diff --git a/core/apps/companies/forms/folders.py b/core/apps/companies/forms/folders.py new file mode 100644 index 0000000..483e846 --- /dev/null +++ b/core/apps/companies/forms/folders.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.companies.models import CompanyfolderModel + + +class CompanyfolderForm(forms.ModelForm): + + class Meta: + model = CompanyfolderModel + fields = "__all__" diff --git a/core/apps/companies/migrations/0001_initial.py b/core/apps/companies/migrations/0001_initial.py new file mode 100644 index 0000000..eee0194 --- /dev/null +++ b/core/apps/companies/migrations/0001_initial.py @@ -0,0 +1,257 @@ +# Generated by Django 5.2.4 on 2025-08-01 09:53 + +import django.core.validators +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("banks", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="CompanyModel", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")), + ( + "name", + models.CharField( + max_length=512, + validators=[ + django.core.validators.RegexValidator( + message="Company name contains invalid characters.", + regex="^[A-Za-z0-9\\s\\.\\-\\,\\&]+$", + ), + django.core.validators.MinLengthValidator(3), + ], + verbose_name="name", + ), + ), + ( + "pinfl_code", + models.CharField( + blank=True, + null=True, + validators=[ + django.core.validators.RegexValidator( + message="PINFL code must be exactly 14 digits.", regex="^\\d{14}$" + ) + ], + verbose_name="PINFL code", + ), + ), + ( + "iin_code", + models.CharField( + blank=True, + null=True, + validators=[ + django.core.validators.RegexValidator( + message="IIN code must be exactly 14 digits.", regex="^\\d{14}$" + ) + ], + verbose_name="IIN code", + ), + ), + ( + "phone", + models.CharField( + max_length=25, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message="Enter a valid international phone number (E.164 format, e.g., +14155552671).", + regex="^\\+?[1-9]\\d{1,14}$", + ) + ], + verbose_name="Phone Number", + ), + ), + ( + "email", + models.CharField( + max_length=255, + unique=True, + validators=[django.core.validators.EmailValidator()], + verbose_name="Email Address", + ), + ), + ( + "iik_code", + models.CharField( + blank=True, + max_length=30, + null=True, + validators=[ + django.core.validators.RegexValidator( + message="IIK code must be alphanumeric and between 10 and 30 characters.", + regex="^[A-Z0-9]{10,30}$", + ) + ], + verbose_name="IIK code", + ), + ), + ( + "legal_address", + models.CharField( + blank=True, + max_length=512, + null=True, + validators=[django.core.validators.MinLengthValidator(10)], + verbose_name="Legal Address", + ), + ), + ( + "real_address", + models.CharField( + blank=True, + max_length=512, + null=True, + validators=[django.core.validators.MinLengthValidator(10)], + verbose_name="Real Address", + ), + ), + ("logo", models.ImageField(upload_to="", verbose_name="Logo")), + ( + "signature_authority_document", + models.FileField(upload_to="", verbose_name="Signature Authority Document"), + ), + ( + "bank", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="banks.bankmodel" + ), + ), + ], + options={ + "verbose_name": "Company", + "verbose_name_plural": "Companies", + "db_table": "companies", + }, + ), + migrations.CreateModel( + name="CompanyFolderModel", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")), + ( + "name", + models.CharField( + max_length=150, + validators=[ + django.core.validators.MaxLengthValidator(150), + django.core.validators.MinLengthValidator(3), + ], + verbose_name="name", + ), + ), + ( + "company", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="folders", to="companies.companymodel" + ), + ), + ], + options={ + "verbose_name": "Company Folder", + "verbose_name_plural": "Company Folders", + "db_table": "company_folders", + }, + ), + migrations.CreateModel( + name="CompanyAccountModel", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")), + ("role", models.CharField(blank=True, max_length=255, null=True, verbose_name="Role")), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="companies", + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ( + "company", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="accounts", + to="companies.companymodel", + verbose_name="Company", + ), + ), + ], + options={ + "verbose_name": "Company Account", + "verbose_name_plural": "Company Accounts", + "db_table": "company_accounts", + }, + ), + migrations.AddIndex( + model_name="companymodel", + index=models.Index(fields=["phone"], name="companies_phone_inx"), + ), + migrations.AddIndex( + model_name="companymodel", + index=models.Index(fields=["email"], name="companies_email_inx"), + ), + migrations.AddIndex( + model_name="companymodel", + index=models.Index(fields=["iin_code"], name="companies_iin_code_inx"), + ), + migrations.AddIndex( + model_name="companymodel", + index=models.Index(fields=["pinfl_code"], name="companies_pinfl_code_inx"), + ), + migrations.AddIndex( + model_name="companymodel", + index=models.Index(fields=["name"], name="companies_name_inx"), + ), + migrations.AddConstraint( + model_name="companyfoldermodel", + constraint=models.UniqueConstraint( + fields=("name", "company"), name="company_folders_name_company_unique_constraint" + ), + ), + migrations.AddIndex( + model_name="companyaccountmodel", + index=models.Index(fields=["user"], name="company_accounts_user_inx"), + ), + migrations.AddIndex( + model_name="companyaccountmodel", + index=models.Index(fields=["company"], name="company_accounts_company_inx"), + ), + migrations.AlterUniqueTogether( + name="companyaccountmodel", + unique_together={("user", "company")}, + ), + ] diff --git a/core/apps/companies/migrations/0002_alter_companymodel_logo_and_more.py b/core/apps/companies/migrations/0002_alter_companymodel_logo_and_more.py new file mode 100644 index 0000000..212d1f8 --- /dev/null +++ b/core/apps/companies/migrations/0002_alter_companymodel_logo_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.4 on 2025-08-01 10:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("companies", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="companymodel", + name="logo", + field=models.ImageField(blank=True, null=True, upload_to="", verbose_name="Logo"), + ), + migrations.AlterField( + model_name="companymodel", + name="signature_authority_document", + field=models.FileField(blank=True, null=True, upload_to="", verbose_name="Signature Authority Document"), + ), + ] diff --git a/core/apps/companies/migrations/0003_companyfoldermodel_contracts.py b/core/apps/companies/migrations/0003_companyfoldermodel_contracts.py new file mode 100644 index 0000000..9cbf380 --- /dev/null +++ b/core/apps/companies/migrations/0003_companyfoldermodel_contracts.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.4 on 2025-08-01 12:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("companies", "0002_alter_companymodel_logo_and_more"), + ("contracts", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="companyfoldermodel", + name="contracts", + field=models.ManyToManyField( + related_name="folders", to="contracts.contractmodel", verbose_name="Contracts" + ), + ), + ] diff --git a/core/apps/companies/migrations/0004_alter_companymodel_logo_and_more.py b/core/apps/companies/migrations/0004_alter_companymodel_logo_and_more.py new file mode 100644 index 0000000..cb7f5fa --- /dev/null +++ b/core/apps/companies/migrations/0004_alter_companymodel_logo_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.4 on 2025-08-04 09:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("companies", "0003_companyfoldermodel_contracts"), + ] + + operations = [ + migrations.AlterField( + model_name="companymodel", + name="logo", + field=models.ImageField(blank=True, max_length=1024, null=True, upload_to="", verbose_name="Logo"), + ), + migrations.AlterField( + model_name="companymodel", + name="signature_authority_document", + field=models.FileField( + blank=True, max_length=1024, null=True, upload_to="", verbose_name="Signature Authority Document" + ), + ), + ] diff --git a/core/apps/companies/migrations/__init__.py b/core/apps/companies/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/companies/models/__init__.py b/core/apps/companies/models/__init__.py new file mode 100644 index 0000000..426f570 --- /dev/null +++ b/core/apps/companies/models/__init__.py @@ -0,0 +1,3 @@ +from .accounts import * # noqa +from .companies import * # noqa +from .folders import * # noqa diff --git a/core/apps/companies/models/accounts.py b/core/apps/companies/models/accounts.py new file mode 100644 index 0000000..016db15 --- /dev/null +++ b/core/apps/companies/models/accounts.py @@ -0,0 +1,77 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth import get_user_model + +from core.utils.base_model import UUIDPrimaryKeyBaseModel +from core.apps.companies.models.companies import CompanyModel +from core.apps.companies.validators.accounts import ( + CompanyAccountValidator +) + + +UserModel = get_user_model() + + +class CompanyAccountModel(UUIDPrimaryKeyBaseModel): + role = models.CharField( + _("Role"), + max_length=255, + null=True, + blank=True, + ) + + user = models.ForeignKey( # type: ignore + UserModel, + on_delete=models.CASCADE, + verbose_name=_("User"), + null=False, + blank=False, + related_name="companies" + ) + + company = models.ForeignKey( # type: ignore + CompanyModel, + on_delete=models.CASCADE, + verbose_name=_("Company"), + null=False, + blank=False, + related_name="accounts", + ) + + def __str__(self): + return ( + f"{self.user!s} " + f"{self.company!s} " + f"{self.role}" + ) + + def clean(self) -> None: + super().clean() + + validator = CompanyAccountValidator(self) + validator() + + @property + def user_name(self) -> str: # type: ignore + return self.user.full_name # type: ignore + + @classmethod + def _create_fake(cls): + return cls.objects.create( + name="Mock CompanyAccount", + user=UserModel._create_fake(), # type: ignore + company=CompanyModel._create_fake() # type: ignore + ) + + class Meta: # type: ignore + db_table = "company_accounts" + + verbose_name = _("Company Account") + verbose_name_plural = _("Company Accounts") + + indexes = [ + models.Index(fields=["user"], name="company_accounts_user_inx"), + models.Index(fields=["company"], name="company_accounts_company_inx") + ] + + unique_together = ["user", "company"] diff --git a/core/apps/companies/models/companies.py b/core/apps/companies/models/companies.py new file mode 100644 index 0000000..cd184ac --- /dev/null +++ b/core/apps/companies/models/companies.py @@ -0,0 +1,184 @@ +from typing import Self + +from django.db import models + +from django.core.validators import ( + EmailValidator, + MinLengthValidator, +) + +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth import get_user_model + +from core.utils.base_model import UUIDPrimaryKeyBaseModel +from core.apps.banks.models import BankModel +from core.apps.companies.validators.companies import ( + iik_validator, + iin_validator, + name_validator, + phone_validator, + pinfl_validator, + CompanyValidator, +) + + +UserModel = get_user_model() + + +class CompanyModel(UUIDPrimaryKeyBaseModel): + name = models.CharField( + _("name"), + max_length=512, + validators=[ + name_validator, + MinLengthValidator(3) + ], + null=False, + blank=False + ) + + pinfl_code = models.CharField( + _("PINFL code"), + validators=[ + pinfl_validator, + ], + null=True, + blank=True, + ) + + iin_code = models.CharField( + _("IIN code"), + validators=[iin_validator], + null=True, + blank=True + ) + + phone = models.CharField( + _("Phone Number"), + max_length=25, + unique=True, + validators=[ + phone_validator, + ], + null=False, + blank=False, + ) + + email = models.CharField( + _("Email Address"), + max_length=255, + validators=[ + EmailValidator() + ], + unique=True, + null=False, + blank=False, + ) + + iik_code = models.CharField( + _("IIK code"), + max_length=30, + validators=[ + iik_validator + ], + null=True, blank=True + ) + + bank = models.ForeignKey( + BankModel, + on_delete=models.SET_NULL, + null=True, + blank=True + ) + + legal_address = models.CharField( + _("Legal Address"), + max_length=512, + validators=[ + MinLengthValidator(10) + ], + null=True, + blank=True + ) + + real_address = models.CharField( + _("Real Address"), + max_length=512, + validators=[ + MinLengthValidator(10) + ], + null=True, + blank=True + ) + + logo = models.ImageField( + _("Logo"), + max_length=1024, + null=True, + blank=True + ) + + signature_authority_document = models.FileField( + _("Signature Authority Document"), + max_length=1024, + null=True, + blank=True + ) + + def __str__(self): + return self.name + + @property + def company_code(self) -> str: + if self.pinfl_code is not None: + return f"PINFL code: {self.pinfl_code}" + else: + return f"IIN code: {self.iin_code}" + + def clean(self): + super().clean() + validator = CompanyValidator(self) + validator() + + @classmethod + def _create_fake(cls) -> Self: + return cls.objects.create( + name="mock LLC", + pinfl_code="12345678901234", + iin_code="12345678901234", + phone="+998901234567", + email="mock@example.com", + iik_code="UZS1234567890", + legal_address="Some legal address, City, Country", + real_address="Same as above", + bank=BankModel._create_fake() # type: ignore + ) + + class Meta: # type: ignore + db_table = "companies" + + verbose_name = _("Company") + verbose_name_plural = _("Companies") + + indexes = [ + models.Index( + fields=["phone"], + name="companies_phone_inx" + ), + models.Index( + fields=["email"], + name="companies_email_inx" + ), + models.Index( + fields=["iin_code"], + name="companies_iin_code_inx" + ), + models.Index( + fields=["pinfl_code"], + name="companies_pinfl_code_inx" + ), + models.Index( + fields=["name"], + name="companies_name_inx" + ), + ] diff --git a/core/apps/companies/models/folders.py b/core/apps/companies/models/folders.py new file mode 100644 index 0000000..3adf3b9 --- /dev/null +++ b/core/apps/companies/models/folders.py @@ -0,0 +1,68 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from django.core.validators import ( + MaxLengthValidator, + MinLengthValidator, +) + +from core.utils.base_model import UUIDPrimaryKeyBaseModel +from core.apps.companies.models.companies import CompanyModel +from core.apps.companies.validators.folders import ( + CompanyFolderValidator +) + +from core.apps.contracts.models import ContractModel + + +class CompanyFolderModel(UUIDPrimaryKeyBaseModel): + name = models.CharField( + _("name"), + max_length=150, + validators=[ + MaxLengthValidator(150), + MinLengthValidator(3), + ] + ) + + company = models.ForeignKey( + CompanyModel, + on_delete=models.PROTECT, + related_name="folders", + null=False, + blank=False + ) + + contracts = models.ManyToManyField( # type: ignore + ContractModel, + verbose_name=_("Contracts"), + related_name="folders", + ) + + def __str__(self): + return f"{self.name} {self.company!s}" + + @classmethod + def _create_fake(cls): + return cls.objects.create( + name="mock", + company=CompanyModel._create_fake() # type: ignore + ) + + def clean(self) -> None: + super().clean() + validator = CompanyFolderValidator(self) + validator() + + class Meta: # type: ignore + db_table = "company_folders" + + verbose_name = _("Company Folder") + verbose_name_plural = _("Company Folders") + + constraints = [ + models.UniqueConstraint( + fields=["name", "company"], + name="company_folders_name_company_unique_constraint" + ) + ] diff --git a/core/apps/companies/permissions/__init__.py b/core/apps/companies/permissions/__init__.py new file mode 100644 index 0000000..426f570 --- /dev/null +++ b/core/apps/companies/permissions/__init__.py @@ -0,0 +1,3 @@ +from .accounts import * # noqa +from .companies import * # noqa +from .folders import * # noqa diff --git a/core/apps/companies/permissions/accounts.py b/core/apps/companies/permissions/accounts.py new file mode 100644 index 0000000..b8b10df --- /dev/null +++ b/core/apps/companies/permissions/accounts.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class CompanyaccountPermission(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/companies/permissions/companies.py b/core/apps/companies/permissions/companies.py new file mode 100644 index 0000000..53a9cc4 --- /dev/null +++ b/core/apps/companies/permissions/companies.py @@ -0,0 +1,23 @@ +from rest_framework import permissions # type: ignore +from rest_framework.views import APIView # type: ignore +from rest_framework.request import HttpRequest # type: ignore + +from core.apps.companies.models import ( + CompanyAccountModel, + CompanyModel +) + + +class IsCompanyAccount(permissions.IsAuthenticated): + def has_object_permission( # type: ignore + self, + request: HttpRequest, + view: APIView, + obj: CompanyModel + ) -> bool: + if request.user.is_staff: + return True + + return CompanyAccountModel.objects.filter( + company=obj, user=request.user, + ).exists() diff --git a/core/apps/companies/permissions/folders.py b/core/apps/companies/permissions/folders.py new file mode 100644 index 0000000..baa6e97 --- /dev/null +++ b/core/apps/companies/permissions/folders.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class CompanyfolderPermission(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/companies/serializers/__init__.py b/core/apps/companies/serializers/__init__.py new file mode 100644 index 0000000..426f570 --- /dev/null +++ b/core/apps/companies/serializers/__init__.py @@ -0,0 +1,3 @@ +from .accounts import * # noqa +from .companies import * # noqa +from .folders import * # noqa diff --git a/core/apps/companies/serializers/accounts/__init__.py b/core/apps/companies/serializers/accounts/__init__.py new file mode 100644 index 0000000..e215084 --- /dev/null +++ b/core/apps/companies/serializers/accounts/__init__.py @@ -0,0 +1 @@ +from .company_accounts import * # noqa diff --git a/core/apps/companies/serializers/accounts/company_accounts.py b/core/apps/companies/serializers/accounts/company_accounts.py new file mode 100644 index 0000000..f6f1051 --- /dev/null +++ b/core/apps/companies/serializers/accounts/company_accounts.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from core.apps.companies.models import CompanyAccountModel + + +class BaseCompanyAccountSerializer(serializers.ModelSerializer): + class Meta: + model = CompanyAccountModel + fields = "__all__" + read_only_fields = ( + "id", + "created_at", + "updated_at" + ) + + +class ListCompanyAccountSerializer(BaseCompanyAccountSerializer): + class Meta(BaseCompanyAccountSerializer.Meta): ... + + +class RetrieveCompanyAccountSerializer(BaseCompanyAccountSerializer): + class Meta(BaseCompanyAccountSerializer.Meta): ... + + +class CreateCompanyAccountSerializer(BaseCompanyAccountSerializer): + class Meta(BaseCompanyAccountSerializer.Meta): ... + + +class UpdateCompanyAccountSerializer(BaseCompanyAccountSerializer): + class Meta(BaseCompanyAccountSerializer.Meta): ... + + +class DestroyCompanyAccountSerializer(BaseCompanyAccountSerializer): + class Meta(BaseCompanyAccountSerializer.Meta): + fields = ["id"] + \ No newline at end of file diff --git a/core/apps/companies/serializers/companies/__init__.py b/core/apps/companies/serializers/companies/__init__.py new file mode 100644 index 0000000..777f8b7 --- /dev/null +++ b/core/apps/companies/serializers/companies/__init__.py @@ -0,0 +1 @@ +from .companies import * # noqa diff --git a/core/apps/companies/serializers/companies/companies.py b/core/apps/companies/serializers/companies/companies.py new file mode 100644 index 0000000..b59c3f4 --- /dev/null +++ b/core/apps/companies/serializers/companies/companies.py @@ -0,0 +1,51 @@ +from rest_framework import serializers + +from core.apps.companies.models import CompanyModel + +from core.apps.companies.serializers.accounts import ( + CreateCompanyAccountSerializer, +) + +from core.apps.companies.serializers.folders import ( + CreateCompanyFolderSerializer +) + + +class BaseCompanySerializer(serializers.ModelSerializer): + class Meta: + model = CompanyModel + fields = "__all__" + read_only_fields = ( + "id", + "created_at", + "updated_at" + ) + + +class ListCompanySerializer(BaseCompanySerializer): + class Meta(BaseCompanySerializer.Meta): + fields = ( + "id", + "name", + "phone", + "email", + "created_at", + "updated_at", + ) + + +class RetrieveCompanySerializer(BaseCompanySerializer): + class Meta(BaseCompanySerializer.Meta): ... + + +class CreateCompanySerializer(BaseCompanySerializer): + class Meta(BaseCompanySerializer.Meta): ... + + +class UpdateCompanySerializer(BaseCompanySerializer): + class Meta(BaseCompanySerializer.Meta): ... + + +class DestroyCompanySerializer(BaseCompanySerializer): + class Meta(BaseCompanySerializer.Meta): + fields = ["id"] diff --git a/core/apps/companies/serializers/folders/__init__.py b/core/apps/companies/serializers/folders/__init__.py new file mode 100644 index 0000000..8501dd9 --- /dev/null +++ b/core/apps/companies/serializers/folders/__init__.py @@ -0,0 +1 @@ +from .company_folders import * # noqa diff --git a/core/apps/companies/serializers/folders/company_folders.py b/core/apps/companies/serializers/folders/company_folders.py new file mode 100644 index 0000000..9a290f0 --- /dev/null +++ b/core/apps/companies/serializers/folders/company_folders.py @@ -0,0 +1,48 @@ +from rest_framework import serializers # type: ignore + +from core.apps.companies.models import CompanyFolderModel + + +class BaseCompanyFolderSerializer(serializers.ModelSerializer): + class Meta: + model = CompanyFolderModel + fields = "__all__" + read_only_fields = ( + "id", + "created_at", + "updated_at" + ) + + +class ListCompanyFolderSerializer(BaseCompanyFolderSerializer): + class Meta(BaseCompanyFolderSerializer.Meta): ... + + +class RetrieveCompanyFolderSerializer(BaseCompanyFolderSerializer): + class Meta(BaseCompanyFolderSerializer.Meta): ... + + +class CreateCompanyFolderSerializer(BaseCompanyFolderSerializer): + class Meta(BaseCompanyFolderSerializer.Meta): ... + + +class UpdateCompanyFolderSerializer(BaseCompanyFolderSerializer): + class Meta(BaseCompanyFolderSerializer.Meta): ... + + +class DestroyCompanyFolderSerializer(BaseCompanyFolderSerializer): + class Meta(BaseCompanyFolderSerializer.Meta): + fields = ["id"] + + +class CreateCompanyFolderFromCompanySerializer(CreateCompanyFolderSerializer): + class Meta(CreateCompanyFolderSerializer.Meta): + read_only_fields = ( + *CreateCompanyFolderSerializer.Meta.read_only_fields, + "company", + ) + + def create(self, validated_data: dict[str, object]) -> Meta.model: + validated_data["company_id"] = self.context["company_id"] + return super().create(validated_data) # type: ignore + \ No newline at end of file diff --git a/core/apps/companies/signals/__init__.py b/core/apps/companies/signals/__init__.py new file mode 100644 index 0000000..426f570 --- /dev/null +++ b/core/apps/companies/signals/__init__.py @@ -0,0 +1,3 @@ +from .accounts import * # noqa +from .companies import * # noqa +from .folders import * # noqa diff --git a/core/apps/companies/signals/accounts.py b/core/apps/companies/signals/accounts.py new file mode 100644 index 0000000..47ce0c5 --- /dev/null +++ b/core/apps/companies/signals/accounts.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.companies.models import CompanyaccountModel + + +@receiver(post_save, sender=CompanyaccountModel) +def CompanyaccountSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/companies/signals/companies.py b/core/apps/companies/signals/companies.py new file mode 100644 index 0000000..f93d1a1 --- /dev/null +++ b/core/apps/companies/signals/companies.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.companies.models import CompanyModel + + +@receiver(post_save, sender=CompanyModel) +def CompanySignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/companies/signals/folders.py b/core/apps/companies/signals/folders.py new file mode 100644 index 0000000..808c1b8 --- /dev/null +++ b/core/apps/companies/signals/folders.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.companies.models import CompanyfolderModel + + +@receiver(post_save, sender=CompanyfolderModel) +def CompanyfolderSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/companies/tests/__init__.py b/core/apps/companies/tests/__init__.py new file mode 100644 index 0000000..c5e3684 --- /dev/null +++ b/core/apps/companies/tests/__init__.py @@ -0,0 +1,3 @@ +from .test_accounts import * # noqa +from .test_companies import * # noqa +from .test_folders import * # noqa diff --git a/core/apps/companies/tests/test_accounts.py b/core/apps/companies/tests/test_accounts.py new file mode 100644 index 0000000..909f33f --- /dev/null +++ b/core/apps/companies/tests/test_accounts.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.companies.models import CompanyaccountModel + + +class CompanyaccountTest(TestCase): + + def _create_data(self): + return CompanyaccountModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("CompanyAccount-list"), + "retrieve": reverse("CompanyAccount-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("CompanyAccount-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/companies/tests/test_companies.py b/core/apps/companies/tests/test_companies.py new file mode 100644 index 0000000..84b1cbc --- /dev/null +++ b/core/apps/companies/tests/test_companies.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.companies.models import CompanyModel + + +class CompanyTest(TestCase): + + def _create_data(self): + return CompanyModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("Company-list"), + "retrieve": reverse("Company-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("Company-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/companies/tests/test_folders.py b/core/apps/companies/tests/test_folders.py new file mode 100644 index 0000000..bbdac2c --- /dev/null +++ b/core/apps/companies/tests/test_folders.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.companies.models import CompanyfolderModel + + +class CompanyfolderTest(TestCase): + + def _create_data(self): + return CompanyfolderModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("CompanyFolder-list"), + "retrieve": reverse("CompanyFolder-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("CompanyFolder-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/companies/translation/__init__.py b/core/apps/companies/translation/__init__.py new file mode 100644 index 0000000..426f570 --- /dev/null +++ b/core/apps/companies/translation/__init__.py @@ -0,0 +1,3 @@ +from .accounts import * # noqa +from .companies import * # noqa +from .folders import * # noqa diff --git a/core/apps/companies/translation/accounts.py b/core/apps/companies/translation/accounts.py new file mode 100644 index 0000000..e60bc87 --- /dev/null +++ b/core/apps/companies/translation/accounts.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.companies.models import CompanyaccountModel + + +@register(CompanyaccountModel) +class CompanyaccountTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/companies/translation/companies.py b/core/apps/companies/translation/companies.py new file mode 100644 index 0000000..a649fa7 --- /dev/null +++ b/core/apps/companies/translation/companies.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.companies.models import CompanyModel + + +@register(CompanyModel) +class CompanyTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/companies/translation/folders.py b/core/apps/companies/translation/folders.py new file mode 100644 index 0000000..545946f --- /dev/null +++ b/core/apps/companies/translation/folders.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.companies.models import CompanyfolderModel + + +@register(CompanyfolderModel) +class CompanyfolderTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/companies/urls.py b/core/apps/companies/urls.py new file mode 100644 index 0000000..e156fdf --- /dev/null +++ b/core/apps/companies/urls.py @@ -0,0 +1,18 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() + +router.register(r"company-accounts", views.CompanyAccountView, "company-account") +router.register(r"company-folders", views.CompanyFolderView, "company-folders") +router.register(r"companies", views.CompanyView, "companies") +router.register(r"companies", views.CompanyFolderViewSet, "companies-folders") +router.register(r"companies", views.CompanyAccountViewSet, "companies-accounts") +router.register(r"companies", views.CompanyContractViewSet, "companies-contracts") + + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/core/apps/companies/validators/__init__.py b/core/apps/companies/validators/__init__.py new file mode 100644 index 0000000..426f570 --- /dev/null +++ b/core/apps/companies/validators/__init__.py @@ -0,0 +1,3 @@ +from .accounts import * # noqa +from .companies import * # noqa +from .folders import * # noqa diff --git a/core/apps/companies/validators/accounts.py b/core/apps/companies/validators/accounts.py new file mode 100644 index 0000000..090e666 --- /dev/null +++ b/core/apps/companies/validators/accounts.py @@ -0,0 +1,10 @@ +# from django.core.exceptions import ValidationError +from django_core.models.base import AbstractBaseModel # type: ignore + + +class CompanyAccountValidator: + def __init__(self, instance: AbstractBaseModel): + self.instance = instance + + def __call__(self): + return True diff --git a/core/apps/companies/validators/companies.py b/core/apps/companies/validators/companies.py new file mode 100644 index 0000000..aa2afa8 --- /dev/null +++ b/core/apps/companies/validators/companies.py @@ -0,0 +1,62 @@ +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator +from django_core.models.base import AbstractBaseModel # type: ignore + +from django.utils.translation import gettext_lazy as _ + + +phone_validator = RegexValidator( + regex=r'^\+?[1-9]\d{1,14}$', + message=_( + "Enter a valid international phone number " + "(E.164 format, e.g., +14155552671)." + ) +) + +iin_validator = RegexValidator( + regex=r'^\d{14}$', + message=_("IIN code must be exactly 14 digits.") +) + +pinfl_validator = RegexValidator( + regex=r'^\d{14}$', + message=_("PINFL code must be exactly 14 digits.") +) + +iik_validator = RegexValidator( + regex=r'^[A-Z0-9]{10,30}$', + message=_( + "IIK code must be alphanumeric and " \ + "between 10 and 30 characters." + ), +) + +name_validator = RegexValidator( + regex=r'^[A-Za-z0-9\s\.\-\,\&]+$', + message=_("Company name contains invalid characters.") +) + + +class CompanyValidator: + def __init__(self, instance: AbstractBaseModel): + self.instance = instance + + def __call__(self): + if ( + self.instance.iin_code is None # type: ignore + and self.instance.pinfl_code is None # type: ignore + ): + + raise ValidationError(_( + "Either IIN code or PINFL " \ + "code must be provided." + )) + + if ( + self.instance.iin_code is not None # type: ignore + and self.instance.pinfl_code is not None # type: ignore + ): + raise ValidationError(_( + "One of IIN code and PINFL " + "code must be None" + )) \ No newline at end of file diff --git a/core/apps/companies/validators/folders.py b/core/apps/companies/validators/folders.py new file mode 100644 index 0000000..6b0860b --- /dev/null +++ b/core/apps/companies/validators/folders.py @@ -0,0 +1,10 @@ +# from django.core.exceptions import ValidationError +from django_core.models.base import AbstractBaseModel # type: ignore + + +class CompanyFolderValidator: + def __init__(self, instance: AbstractBaseModel): + self.instance = instance + + def __call__(self): + return diff --git a/core/apps/companies/views/__init__.py b/core/apps/companies/views/__init__.py new file mode 100644 index 0000000..426f570 --- /dev/null +++ b/core/apps/companies/views/__init__.py @@ -0,0 +1,3 @@ +from .accounts import * # noqa +from .companies import * # noqa +from .folders import * # noqa diff --git a/core/apps/companies/views/accounts.py b/core/apps/companies/views/accounts.py new file mode 100644 index 0000000..76287c8 --- /dev/null +++ b/core/apps/companies/views/accounts.py @@ -0,0 +1,35 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import IsAdminUser, AllowAny +from rest_framework.viewsets import ModelViewSet + +from core.apps.companies.models import CompanyAccountModel +from core.apps.companies.serializers.accounts import ( + CreateCompanyAccountSerializer, + ListCompanyAccountSerializer, + RetrieveCompanyAccountSerializer, + UpdateCompanyAccountSerializer, + DestroyCompanyAccountSerializer, +) + + +@extend_schema(tags=["CompanyAccount"]) +class CompanyAccountView(BaseViewSetMixin, ModelViewSet): + queryset = CompanyAccountModel.objects.all() + serializer_class = ListCompanyAccountSerializer + permission_classes = [AllowAny] + + action_permission_classes = { + "list": [IsAdminUser], + "retrieve": [IsAdminUser], + "create": [IsAdminUser], + "update": [IsAdminUser], + "destroy": [IsAdminUser], + } + action_serializer_class = { + "list": ListCompanyAccountSerializer, + "retrieve": RetrieveCompanyAccountSerializer, + "create": CreateCompanyAccountSerializer, + "update": UpdateCompanyAccountSerializer, + "destroy": DestroyCompanyAccountSerializer, + } diff --git a/core/apps/companies/views/companies.py b/core/apps/companies/views/companies.py new file mode 100644 index 0000000..ed25389 --- /dev/null +++ b/core/apps/companies/views/companies.py @@ -0,0 +1,189 @@ +import uuid + +from django_core.mixins import BaseViewSetMixin # type: ignore +from drf_spectacular.utils import extend_schema +from rest_framework.decorators import action # type: ignore +from rest_framework.permissions import AllowAny, IsAdminUser # type: ignore +from rest_framework.viewsets import ( # type: ignore + ModelViewSet, + GenericViewSet +) +from rest_framework.request import HttpRequest # type: ignore +from rest_framework.response import Response # type: ignore +from rest_framework import status # type: ignore +from rest_framework.generics import get_object_or_404 # type: ignore + + +from django.contrib.auth import get_user_model +from core.apps.companies.permissions import IsCompanyAccount + +from core.apps.companies.models import ( + CompanyModel, + CompanyFolderModel, + CompanyAccountModel +) +from core.apps.companies.serializers import ( + CreateCompanySerializer, + ListCompanySerializer, + RetrieveCompanySerializer, + UpdateCompanySerializer, + DestroyCompanySerializer, + + RetrieveCompanyFolderSerializer, + RetrieveCompanyAccountSerializer, + CreateCompanyFolderSerializer, + BaseCompanyAccountSerializer, + + CreateCompanyFolderFromCompanySerializer +) + +from core.apps.contracts.serializers import ( + RetrieveContractSerializer, + BaseContractSerializer +) + +from core.apps.contracts.models import ( + ContractModel, +) + + +UserModel = get_user_model() + + +@extend_schema(tags=["Company"]) +class CompanyView(BaseViewSetMixin, ModelViewSet): + queryset = CompanyModel.objects.all() + serializer_class = ListCompanySerializer + permission_classes = [AllowAny] + + action_permission_classes = { + "list": [IsAdminUser], + "retrieve": [IsAdminUser], + "create": [IsAdminUser], + "update": [IsAdminUser], + "destroy": [IsAdminUser], + } + action_serializer_class = { # type: ignore + "list": ListCompanySerializer, + "retrieve": RetrieveCompanySerializer, + "create": CreateCompanySerializer, + "update": UpdateCompanySerializer, + "destroy": DestroyCompanySerializer, + } + + +class CompanyContractViewSet(BaseViewSetMixin, GenericViewSet): + queryset = CompanyModel.objects.all() + permission_classes = [AllowAny] + serializer_class = BaseContractSerializer + + action_permission_classes = { + "list_contract": [IsCompanyAccount] + } + action_serializer_class = { + "list_contract": RetrieveContractSerializer + } + + #! TODO: status should be added. + @extend_schema( + summary="Company Contracts", + description="Get List Company Contracts" + ) + @action(methods=["GET"], detail=True, url_path="contracts") + def list_contract( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> Response: + company = self.get_object() + contracts = ContractModel.objects.filter( + owners__legal_entity__phone=company.phone, + ).distinct() + + folders_param = request.data.get("folders") + if folders_param: + folder_ids = folders_param.split(",") + contracts = contracts.filter(folders__id__in=folder_ids) + + serializer = self.get_serializer(instance=contracts, many=True) # type: ignore + return Response(serializer.data, status=status.HTTP_200_OK) + + +class CompanyAccountViewSet(BaseViewSetMixin, GenericViewSet): + queryset = CompanyModel.objects.all() + serializer_class = BaseCompanyAccountSerializer + permission_classes = [AllowAny] + + action_permission_classes = { + "list_account": [IsCompanyAccount] + } + action_serializer_class = { + "list_account": RetrieveCompanyAccountSerializer + } + + @extend_schema( + summary="List company accounts", + description="List Company Accounts" + ) + @action(url_path="accounts", detail=True, methods=["GET"]) + def list_account( + self, + request: HttpRequest, + pk: uuid.UUID, + *args: object, + **kwargs: object, + ) -> Response: + company = self.get_object() + accounts = CompanyAccountModel.objects.filter(company=company) + ser = self.get_serializer(instance=accounts, many=True) # type: ignore + return Response(data=ser.data, status=status.HTTP_200_OK) + + +class CompanyFolderViewSet(BaseViewSetMixin, GenericViewSet): + queryset = CompanyModel.objects.all() + permission_classes = [AllowAny] + # serializer_class = BaseCompanyFolderSerializer + + action_permission_classes = { + "list_folder": [IsCompanyAccount], + "create_folder": [IsCompanyAccount], + } + action_serializer_class = { # type: ignore + "list_folder": RetrieveCompanyFolderSerializer, + "create_folder": CreateCompanyFolderFromCompanySerializer, + } + + @extend_schema( + summary="List Company Folders", + description="List Company Folders" + ) + @action(methods=["GET"], detail=True, url_path="folders") + def list_folder( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> Response: + company = self.get_object() + folders = CompanyFolderModel.objects.filter(company=company) + ser = self.get_serializer(instance=folders, many=True, context={"company_id": company.pk}) # type: ignore + return Response(data=ser.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Create Folder for company", + description="Create Folder for company", + ) + @action(url_path="folders", detail=True, methods=["POST"]) + def create_folder( + self, + request: HttpRequest, + pk: uuid.UUID, + *args: object, + **kwargs: object, + ) -> Response: + company = self.get_object() + data = request.data.copy() | dict(company=company.id) # type: ignore + ser = CreateCompanyFolderSerializer(data=data) # type: ignore + ser.is_valid(raise_exception=True) + return Response(data=ser.data, status=status.HTTP_201_CREATED) diff --git a/core/apps/companies/views/folders.py b/core/apps/companies/views/folders.py new file mode 100644 index 0000000..69abd84 --- /dev/null +++ b/core/apps/companies/views/folders.py @@ -0,0 +1,35 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.viewsets import ModelViewSet + +from core.apps.companies.models import CompanyFolderModel +from core.apps.companies.serializers.folders import ( + CreateCompanyFolderSerializer, + ListCompanyFolderSerializer, + RetrieveCompanyFolderSerializer, + UpdateCompanyFolderSerializer, + DestroyCompanyFolderSerializer +) + + +@extend_schema(tags=["CompanyFolder"]) +class CompanyFolderView(BaseViewSetMixin, ModelViewSet): + queryset = CompanyFolderModel.objects.all() + serializer_class = ListCompanyFolderSerializer + permission_classes = [AllowAny] + + action_permission_classes = { + "list": [IsAdminUser], + "retrieve": [IsAdminUser], + "create": [IsAdminUser], + "update": [IsAdminUser], + "destroy": [IsAdminUser], + } + action_serializer_class = { + "list": ListCompanyFolderSerializer, + "retrieve": RetrieveCompanyFolderSerializer, + "create": CreateCompanyFolderSerializer, + "update": UpdateCompanyFolderSerializer, + "destroy": DestroyCompanyFolderSerializer, + } diff --git a/core/apps/contracts/__init__.py b/core/apps/contracts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/contracts/admin/__init__.py b/core/apps/contracts/admin/__init__.py new file mode 100644 index 0000000..a75110b --- /dev/null +++ b/core/apps/contracts/admin/__init__.py @@ -0,0 +1,4 @@ +from .attached_files import * # noqa +from .contracts import * # noqa +from .file_contents import * # noqa +from .owners import * # noqa diff --git a/core/apps/contracts/admin/attached_files.py b/core/apps/contracts/admin/attached_files.py new file mode 100644 index 0000000..35d426a --- /dev/null +++ b/core/apps/contracts/admin/attached_files.py @@ -0,0 +1,24 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin # type: ignore + +from core.apps.contracts.models import ContractAttachedFileModel + + +@admin.register(ContractAttachedFileModel) +class FileAdmin(ModelAdmin): + list_display = ( + "name", + "contract", + "allowed_types", + "created_at", + ) + search_fields = ( + "name", + "contract", + "created_at", + "updated_at", + ) + list_display_links = ( + "name", + ) + diff --git a/core/apps/contracts/admin/contracts.py b/core/apps/contracts/admin/contracts.py new file mode 100644 index 0000000..6c6e4d1 --- /dev/null +++ b/core/apps/contracts/admin/contracts.py @@ -0,0 +1,26 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin # type: ignore + +from core.apps.contracts.models import ContractModel + + +@admin.register(ContractModel) +class ContractAdmin(ModelAdmin): + list_display = ( + "name", + "identifier", + "file_permissions", + "created_at", + ) + + search_fields = ( + "name", + "identifier", + "owners", + "created_at", + "updated_at", + ) + + list_display_links = ( + "name", + ) diff --git a/core/apps/contracts/admin/file_contents.py b/core/apps/contracts/admin/file_contents.py new file mode 100644 index 0000000..32f1e32 --- /dev/null +++ b/core/apps/contracts/admin/file_contents.py @@ -0,0 +1,26 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin # type: ignore + +from core.apps.contracts.models import ContractFileContentModel + + +@admin.register(ContractFileContentModel) +class FilecontentAdmin(ModelAdmin): + list_display = ( + "owner_name", + "contract_owner", + "file", + "created_at", + "updated_at", + ) + search_fields = ( + "contract_owner" + "file", + "content", + "created_at", + "updated_at", + "document_url", + ) + list_display_links = ( + "owner_name", + ) diff --git a/core/apps/contracts/admin/owners.py b/core/apps/contracts/admin/owners.py new file mode 100644 index 0000000..645679e --- /dev/null +++ b/core/apps/contracts/admin/owners.py @@ -0,0 +1,78 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin # type: ignore + +from core.apps.contracts.models import ( + ContractOwnerModel, + LegalEntityModel, + IndividualModel, +) + + +@admin.register(IndividualModel) +class IndividualAdmin(ModelAdmin): + list_display = ( + "full_name", + "individual_code", + "phone", + "use_face_id", + "created_at", + ) + + search_fields = ( + "full_name", + "iin_code", + "person_code", + "phone", + "use_face_id", + "created_at", + "updated_at", + ) + + list_display_links = ( + "full_name", + ) + + +@admin.register(LegalEntityModel) +class LegalentityAdmin(ModelAdmin): + list_display = ( + "name", + "role", + "entity_code", + "phone", + "created_at", + ) + search_fields = ( + "name", + "role", + "bin_code", + "identifier", + "phone", + "created_at", + "updated_at", + ) + + list_display_links = ( + "name", + ) + + +@admin.register(ContractOwnerModel) +class OwnerAdmin(ModelAdmin): + list_display = ( + "owner_identity", + "owner_name", + "status", + "created_at" + ) + + search_fields = ( + "owner_name", + "status", + "created_at", + "updated_at" + ) + + list_display_links = ( + "owner_identity", + ) diff --git a/core/apps/contracts/apps.py b/core/apps/contracts/apps.py new file mode 100644 index 0000000..13d6f7e --- /dev/null +++ b/core/apps/contracts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.contracts" diff --git a/core/apps/contracts/choices/__init__.py b/core/apps/contracts/choices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/contracts/choices/contracts.py b/core/apps/contracts/choices/contracts.py new file mode 100644 index 0000000..f11f43b --- /dev/null +++ b/core/apps/contracts/choices/contracts.py @@ -0,0 +1,12 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ContractOwnerStatus(models.TextChoices): + """ + Owner status choices. + """ + + ACCEPTED = "accepted", _("Accepted") + REJECTED = "rejected", _("Rejected") + PENDING = "pending", _("Pending") diff --git a/core/apps/contracts/filters/__init__.py b/core/apps/contracts/filters/__init__.py new file mode 100644 index 0000000..a75110b --- /dev/null +++ b/core/apps/contracts/filters/__init__.py @@ -0,0 +1,4 @@ +from .attached_files import * # noqa +from .contracts import * # noqa +from .file_contents import * # noqa +from .owners import * # noqa diff --git a/core/apps/contracts/filters/attached_files.py b/core/apps/contracts/filters/attached_files.py new file mode 100644 index 0000000..6450698 --- /dev/null +++ b/core/apps/contracts/filters/attached_files.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.contracts.models import ContractattachedfileModel + + +class ContractattachedfileFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = ContractattachedfileModel + fields = [ + "name", + ] diff --git a/core/apps/contracts/filters/contracts.py b/core/apps/contracts/filters/contracts.py new file mode 100644 index 0000000..48ec9ed --- /dev/null +++ b/core/apps/contracts/filters/contracts.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.contracts.models import ContractModel + + +class ContractFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = ContractModel + fields = [ + "name", + ] diff --git a/core/apps/contracts/filters/file_contents.py b/core/apps/contracts/filters/file_contents.py new file mode 100644 index 0000000..b8be9e1 --- /dev/null +++ b/core/apps/contracts/filters/file_contents.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.contracts.models import ContractfilecontentModel + + +class ContractfilecontentFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = ContractfilecontentModel + fields = [ + "name", + ] diff --git a/core/apps/contracts/filters/owners.py b/core/apps/contracts/filters/owners.py new file mode 100644 index 0000000..a300c7b --- /dev/null +++ b/core/apps/contracts/filters/owners.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.contracts.models import ContractownerModel + + +class ContractownerFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = ContractownerModel + fields = [ + "name", + ] diff --git a/core/apps/contracts/forms/__init__.py b/core/apps/contracts/forms/__init__.py new file mode 100644 index 0000000..a75110b --- /dev/null +++ b/core/apps/contracts/forms/__init__.py @@ -0,0 +1,4 @@ +from .attached_files import * # noqa +from .contracts import * # noqa +from .file_contents import * # noqa +from .owners import * # noqa diff --git a/core/apps/contracts/forms/attached_files.py b/core/apps/contracts/forms/attached_files.py new file mode 100644 index 0000000..4ba2ec5 --- /dev/null +++ b/core/apps/contracts/forms/attached_files.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.contracts.models import ContractattachedfileModel + + +class ContractattachedfileForm(forms.ModelForm): + + class Meta: + model = ContractattachedfileModel + fields = "__all__" diff --git a/core/apps/contracts/forms/contracts.py b/core/apps/contracts/forms/contracts.py new file mode 100644 index 0000000..9b29d2a --- /dev/null +++ b/core/apps/contracts/forms/contracts.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.contracts.models import ContractModel + + +class ContractForm(forms.ModelForm): + + class Meta: + model = ContractModel + fields = "__all__" diff --git a/core/apps/contracts/forms/file_contents.py b/core/apps/contracts/forms/file_contents.py new file mode 100644 index 0000000..14bbbdb --- /dev/null +++ b/core/apps/contracts/forms/file_contents.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.contracts.models import ContractfilecontentModel + + +class ContractfilecontentForm(forms.ModelForm): + + class Meta: + model = ContractfilecontentModel + fields = "__all__" diff --git a/core/apps/contracts/forms/owners.py b/core/apps/contracts/forms/owners.py new file mode 100644 index 0000000..6aef327 --- /dev/null +++ b/core/apps/contracts/forms/owners.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.contracts.models import ContractownerModel + + +class ContractownerForm(forms.ModelForm): + + class Meta: + model = ContractownerModel + fields = "__all__" diff --git a/core/apps/contracts/migrations/0001_initial.py b/core/apps/contracts/migrations/0001_initial.py new file mode 100644 index 0000000..b0c857d --- /dev/null +++ b/core/apps/contracts/migrations/0001_initial.py @@ -0,0 +1,347 @@ +# Generated by Django 5.2.4 on 2025-08-01 09:53 + +import core.apps.contracts.validators.attached_files +import core.apps.contracts.validators.owners +import django.core.validators +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="LegalEntityModel", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")), + ( + "name", + models.CharField( + max_length=255, + validators=[core.apps.contracts.validators.owners.name_validator], + verbose_name="Name", + ), + ), + ("role", models.CharField(verbose_name="Role")), + ( + "bin_code", + models.CharField( + blank=True, + max_length=14, + null=True, + validators=[core.apps.contracts.validators.owners.bin_code_validator], + verbose_name="BIN code", + ), + ), + ("identifier", models.CharField(blank=True, max_length=255, null=True, verbose_name="Identifier")), + ( + "phone", + models.CharField( + max_length=25, + validators=[ + django.core.validators.RegexValidator( + message="Enter a valid international phone number (E.164 format, e.g., +14155552671).", + regex="^\\+?[1-9]\\d{1,14}$", + ) + ], + verbose_name="Phone", + ), + ), + ], + options={ + "verbose_name": "Legal Entity", + "verbose_name_plural": "Legal Entities", + "db_table": "legal_entities", + }, + ), + migrations.CreateModel( + name="ContractModel", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")), + ( + "name", + models.CharField( + max_length=255, + validators=[ + django.core.validators.RegexValidator( + message="Enter a valid contract name. Use letters, numbers, spaces, and limited punctuation (e.g., &, -, (), ., ', \"). Length must be 3 to 100 characters.", + regex="^[A-Za-z0-9Γ€-ΓΏ&()\\-.,'\\\" ]{3,100}$", + ) + ], + verbose_name="name", + ), + ), + ("identifier", models.CharField(verbose_name="Identifier")), + ("allow_add_files", models.BooleanField(default=False)), + ("allow_delete_files", models.BooleanField(default=False)), + ], + options={ + "verbose_name": "Contract", + "verbose_name_plural": "Contracts", + "db_table": "contracts", + "indexes": [models.Index(fields=["name"], name="contracts_name_inx")], + }, + ), + migrations.CreateModel( + name="ContractAttachedFileModel", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")), + ( + "name", + models.CharField( + max_length=150, + validators=[ + django.core.validators.MinLengthValidator( + 3, message="File name must be at least 3 characters long." + ), + django.core.validators.MaxLengthValidator( + 150, message="File name must be at most 150 characters long." + ), + core.apps.contracts.validators.attached_files.starts_with_letter_validator, + django.core.validators.RegexValidator( + code="invalid_characters", + message="File name can only contain letters, digits, spaces, dots, underscores, or hyphens.", + regex="^[A-Za-z0-9\\s._-]+$", + ), + ], + verbose_name="name", + ), + ), + ("allow_pdf", models.BooleanField(default=True)), + ("allow_word", models.BooleanField(default=True)), + ("allow_image", models.BooleanField(default=True)), + ( + "contract", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attached_files", + to="contracts.contractmodel", + verbose_name="Contract", + ), + ), + ], + options={ + "verbose_name": "Contract Attached File", + "verbose_name_plural": "Contract Attached Files", + "db_table": "contract_attached_files", + "unique_together": {("name", "contract")}, + }, + ), + migrations.CreateModel( + name="ContractOwnerModel", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")), + ( + "status", + models.CharField( + blank=True, + choices=[("accepted", "Accepted"), ("rejected", "Rejected"), ("pending", "Pending")], + default="pending", + max_length=255, + verbose_name="Owner Status", + ), + ), + ( + "contract", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="owners", + to="contracts.contractmodel", + verbose_name="Contract", + ), + ), + ], + options={ + "verbose_name": "Contract Owner", + "verbose_name_plural": "Contract Owners", + "db_table": "contract_owners", + }, + ), + migrations.CreateModel( + name="ContractFileContentModel", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")), + ("document", models.FileField(upload_to="", verbose_name="Document")), + ( + "file", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="contents", + to="contracts.contractattachedfilemodel", + verbose_name="File", + ), + ), + ( + "contract_owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contracts.contractownermodel", + verbose_name="Contract Owner", + ), + ), + ], + options={ + "verbose_name": "Contract File Content", + "verbose_name_plural": "Contract File Contents", + "db_table": "contract_file_contents", + }, + ), + migrations.CreateModel( + name="IndividualModel", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated At")), + ( + "full_name", + models.CharField( + max_length=512, + validators=[ + django.core.validators.RegexValidator( + message="Invalid Full Name, Please enter Full Name in the format: ", + regex="^[A-Z][a-z]+ [A-Z][a-z]+ [A-Z][a-z]+$", + ) + ], + verbose_name="name", + ), + ), + ( + "iin_code", + models.CharField( + blank=True, + max_length=14, + null=True, + unique=True, + validators=[ + django.core.validators.RegexValidator( + code="invalid_iin", + message="IIN code must consist of exactly 14 digits.", + regex="^\\d{14}$", + ) + ], + verbose_name="IIN code", + ), + ), + ( + "person_code", + models.CharField( + blank=True, + max_length=64, + null=True, + unique=True, + validators=[ + django.core.validators.RegexValidator( + code="invalid_person_code", + message="Person Code must be 3 to 64 characters long and contain only letters, digits, dashes, or underscores.", + regex="^[A-Za-z0-9_-]{3,64}$", + ) + ], + verbose_name="Person Code (if no IIN code)", + ), + ), + ( + "phone", + models.CharField( + validators=[ + django.core.validators.RegexValidator( + message="Enter a valid international phone number (E.164 format, e.g., +14155552671).", + regex="^\\+?[1-9]\\d{1,14}$", + ) + ], + verbose_name="Phone", + ), + ), + ("use_face_id", models.BooleanField(default=False, verbose_name="Use FaceID")), + ], + options={ + "verbose_name": "Individual", + "verbose_name_plural": "Individuals", + "db_table": "individuals", + "indexes": [ + models.Index(fields=["full_name"], name="individuals_fullname_inx"), + models.Index(fields=["iin_code"], name="individuals_iin_inx"), + models.Index(fields=["person_code"], name="individuals_code_inx"), + models.Index(fields=["phone"], name="individuals_phone_inx"), + ], + }, + ), + migrations.AddField( + model_name="contractownermodel", + name="individual", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="owner", + to="contracts.individualmodel", + verbose_name="Individual", + ), + ), + migrations.AddField( + model_name="contractownermodel", + name="legal_entity", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="owner", + to="contracts.legalentitymodel", + verbose_name="Legal Entity", + ), + ), + migrations.AddConstraint( + model_name="contractownermodel", + constraint=models.UniqueConstraint(fields=("individual", "contract"), name="unique_individual_contract"), + ), + migrations.AddConstraint( + model_name="contractownermodel", + constraint=models.UniqueConstraint( + fields=("legal_entity", "contract"), name="unique_legal_entity_contract" + ), + ), + ] diff --git a/core/apps/contracts/migrations/__init__.py b/core/apps/contracts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/contracts/models/__init__.py b/core/apps/contracts/models/__init__.py new file mode 100644 index 0000000..a75110b --- /dev/null +++ b/core/apps/contracts/models/__init__.py @@ -0,0 +1,4 @@ +from .attached_files import * # noqa +from .contracts import * # noqa +from .file_contents import * # noqa +from .owners import * # noqa diff --git a/core/apps/contracts/models/attached_files.py b/core/apps/contracts/models/attached_files.py new file mode 100644 index 0000000..64f8366 --- /dev/null +++ b/core/apps/contracts/models/attached_files.py @@ -0,0 +1,110 @@ +from django.db import models + +from django.utils.translation import gettext_lazy as _ +from functools import cache + +from django.core.validators import ( + MinLengthValidator, + MaxLengthValidator, +) + +from core.utils.base_model import UUIDPrimaryKeyBaseModel +from .contracts import ContractModel +from core.apps.contracts.validators.attached_files import ( + ContractAttachedFileValidator, + allowed_chars_validator, + starts_with_letter_validator, +) + + +ALLOWED_FILE_EXTENTIONS: dict[str, list[str]] = { + 'pdf': ['.pdf'], + 'word': ['.doc', '.docx'], + 'image': ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'], +} + +class ContractAttachedFileModel(UUIDPrimaryKeyBaseModel): + name = models.CharField( + _("name"), + max_length=150, + validators=[ + MinLengthValidator( + 3, + message=_( + "File name must be at " \ + "least 3 characters long." + ) + ), + MaxLengthValidator( + 150, + message=_( + "File name must be at " \ + "most 150 characters long." + ) + ), + starts_with_letter_validator, + allowed_chars_validator, + ], + ) + + contract = models.ForeignKey( + ContractModel, + on_delete=models.CASCADE, + verbose_name=_("Contract"), + related_name="attached_files", + ) + + allow_pdf = models.BooleanField(default=True) + allow_word = models.BooleanField(default=True) + allow_image = models.BooleanField(default=True) + + @property + def allowed_types(self) -> str: + allowed_types: list[str] = [] + + if self.allow_pdf: + allowed_types.append("pdf") + if self.allow_word: + allowed_types.append("word") + if self.allow_image: + allowed_types.append("image") + + if len(allowed_types) == 3: + return "All" + return ", ".join(allowed_type.upper() for allowed_type in allowed_types) + + @property + @cache + def allowed_extensions(self) -> list[str]: + extensions: list[str] = [] + + if self.allow_pdf: + extensions += ALLOWED_FILE_EXTENTIONS['pdf'] + if self.allow_word: + extensions += ALLOWED_FILE_EXTENTIONS['word'] + if self.allow_image: + extensions += ALLOWED_FILE_EXTENTIONS['image'] + + return extensions + + def __str__(self): + return self.name + + @classmethod + def _create_fake(cls): + return cls.objects.create( + name="mock", + ) + + def clean(self) -> None: + super().clean() + validator = ContractAttachedFileValidator() + validator() + + class Meta: # type: ignore + db_table = "contract_attached_files" + + verbose_name = _("Contract Attached File") + verbose_name_plural = _("Contract Attached Files") + + unique_together = ("name", "contract") diff --git a/core/apps/contracts/models/contracts.py b/core/apps/contracts/models/contracts.py new file mode 100644 index 0000000..d1f887f --- /dev/null +++ b/core/apps/contracts/models/contracts.py @@ -0,0 +1,70 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.utils.base_model import UUIDPrimaryKeyBaseModel +from core.apps.contracts.validators.contracts import ( + ContractValidator, + name_validator +) + + +class ContractModel(UUIDPrimaryKeyBaseModel): + + name = models.CharField( + _("name"), + validators=[ + name_validator, + ], + max_length=255 + ) + + identifier = models.CharField( + _("Identifier"), + null=False, + blank=False + ) + + allow_add_files = models.BooleanField(default=False) + allow_delete_files = models.BooleanField(default=False) + + @property + def file_permissions(self) -> str: + permissions: list[str] = [] + + if self.allow_add_files: + permissions.append("add") + if self.allow_delete_files: + permissions.append("delete") + + if len(permissions) == 2: + return "All" + + return ", ".join(permission.capitalize() for permission in permissions) + + def __str__(self): + return self.name + + @classmethod + def _create_fake(cls): + return cls.objects.create( + name="mock", + ) + + def clean(self) -> None: + super().clean() + + validator = ContractValidator(self) + validator() + + class Meta: # type: ignore + db_table = "contracts" + + verbose_name = _("Contract") + verbose_name_plural = _("Contracts") + + indexes = [ + models.Index( + fields=["name"], + name="contracts_name_inx" + ) + ] diff --git a/core/apps/contracts/models/file_contents.py b/core/apps/contracts/models/file_contents.py new file mode 100644 index 0000000..2230c80 --- /dev/null +++ b/core/apps/contracts/models/file_contents.py @@ -0,0 +1,70 @@ +import os + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.db.models.fields.files import FieldFile + +from django.core.exceptions import ValidationError + +from core.utils.base_model import UUIDPrimaryKeyBaseModel +from core.apps.contracts.models.attached_files import ContractAttachedFileModel +from core.apps.contracts.models.owners import ContractOwnerModel + + +class ContractFileContentModel(UUIDPrimaryKeyBaseModel): + file = models.ForeignKey( + ContractAttachedFileModel, + on_delete=models.CASCADE, + verbose_name=_("File"), + related_name="contents", + null=False, + blank=False, + ) + + contract_owner = models.ForeignKey( + ContractOwnerModel, + on_delete=models.CASCADE, + verbose_name=_("Contract Owner"), + null=False, + blank=False, + ) + + document = models.FileField( + _("Document"), + null=False, + blank=False, + ) + + @property + def owner_name(self) -> str: + return self.contract_owner.owner_name + + def __str__(self): + return self.file.name + + def validate_file(self, document: FieldFile): + try: + ext = os.path.splitext(document.name)[1].lower() + except IndexError: + raise ValidationError(f"Unsupported document name: {document.name}") + + if ext.lower() not in self.file.allowed_extensions: + raise ValidationError(f"Unsupported document type: {ext.upper()}") + + return document + + @classmethod + def _create_fake(cls): + return cls.objects.create( + file=ContractAttachedFileModel._create_fake(), # type: ignore + owner=ContractOwnerModel._create_fake(), # type: ignore + document_url=( + "https://img.freepik.com/free-photo/closeup-scarlet-macaw-from-side" + "-view-scarlet-macaw-closeup-head_488145-3540.jpg?semt=ais_hybrid&w=740" + ) + ) + + class Meta: # type: ignore + db_table = "contract_file_contents" + verbose_name = _("Contract File Content") + verbose_name_plural = _("Contract File Contents") diff --git a/core/apps/contracts/models/owners.py b/core/apps/contracts/models/owners.py new file mode 100644 index 0000000..3d18d47 --- /dev/null +++ b/core/apps/contracts/models/owners.py @@ -0,0 +1,279 @@ +from typing import Self + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.apps.contracts.models.contracts import ContractModel +from core.apps.contracts.choices.contracts import ContractOwnerStatus +from core.utils.base_model import UUIDPrimaryKeyBaseModel +from core.apps.contracts.validators.owners import ( + ContractOwnerValidator, + LegalEntityValidator, + IndividualValidator, + name_validator, + bin_code_validator, + phone_validator, + person_code_validator, + full_name_validator, + iin_code_validator, +) + + +class LegalEntityModel(UUIDPrimaryKeyBaseModel): + name = models.CharField( + _("Name"), + validators=[ + name_validator, + ], + max_length=255, + null=False, + blank=False, + ) + + role = models.CharField( + _("Role"), + null=False, + blank=False + ) + + bin_code = models.CharField( + _("BIN code"), + validators=[ + bin_code_validator, + ], + max_length=14, + null=True, + blank=True, + ) + + identifier = models.CharField( + _("Identifier"), + max_length=255, + null=True, + blank=True + ) + + phone = models.CharField( + _("Phone"), + validators=[ + phone_validator + ], + max_length=25, + null=False, + blank=False + ) + + def __str__(self): + return self.name + + @classmethod + def _create_fake(cls): + return cls.objects.create( + name="mock", + ) + + @property + def entity_code(self) -> str: + if self.bin_code is not None: + return f"BIN code: {self.bin_code}" + else: + return f"Identifier: {self.identifier}" + + def clean(self) -> None: + super().clean() + validator = LegalEntityValidator(self) + validator() + + class Meta: # type: ignore + db_table = "legal_entities" + + verbose_name = _("Legal Entity") + verbose_name_plural = _("Legal Entities") + + +class IndividualModel(UUIDPrimaryKeyBaseModel): + + full_name = models.CharField( + _("name"), + validators=[ + full_name_validator, + ], + max_length=512, + null=False, + blank=False + ) + + iin_code = models.CharField( + _("IIN code"), + max_length=14, + validators=[ + iin_code_validator, + ], + null=True, + blank=True, + unique=True + ) + + person_code = models.CharField( + _("Person Code (if no IIN code)"), + validators=[ + person_code_validator, + ], + max_length=64, + null=True, + blank=True, + unique=True + ) + + phone = models.CharField( + _("Phone"), + validators=[ + phone_validator, + ], + null=False, + blank=False + ) + + use_face_id = models.BooleanField( + _("Use FaceID"), + null=False, + blank=False, + default=False + ) + + def __str__(self): + return self.full_name + + @classmethod + def _create_fake(cls): + return cls.objects.create( + name="mock", + ) + + @property + def individual_code(self) -> str: + if self.iin_code is not None: + return f"IIN Code: {self.iin_code}" + else: + return f"Person Code: {self.person_code}" + + def clean(self): + super().clean() + validator = IndividualValidator(self) + validator() + + class Meta: # type: ignore + db_table = "individuals" + + verbose_name = _("Individual") + verbose_name_plural = _("Individuals") + + indexes = [ + models.Index( + fields=["full_name"], + name="individuals_fullname_inx" + ), + models.Index( + fields=["iin_code"], + name="individuals_iin_inx" + ), + models.Index( + fields=["person_code"], + name="individuals_code_inx" + ), + models.Index( + fields=["phone"], + name="individuals_phone_inx" + ), + ] + + +class ContractOwnerModel(UUIDPrimaryKeyBaseModel): + + legal_entity = models.OneToOneField( + LegalEntityModel, + related_name="owner", + verbose_name=_("Legal Entity"), + on_delete=models.PROTECT, + null=True, + blank=True + ) + + individual = models.OneToOneField( + IndividualModel, + related_name="owner", + verbose_name=_("Individual"), + on_delete=models.PROTECT, + null=True, + blank=True, + ) + + status = models.CharField( + _("Owner Status"), + max_length=255, + choices=ContractOwnerStatus, + default=ContractOwnerStatus.PENDING, + null=False, + blank=True, + ) + + contract = models.ForeignKey( + ContractModel, + verbose_name=_("Contract"), + related_name="owners", + on_delete=models.PROTECT, + null=False, + blank=False + ) + + def clean(self) -> None: + super().clean() + validator = ContractOwnerValidator(self) + validator() + + def __str__(self) -> str: + return str(self.legal_entity or self.individual) + + @property + def owner_name(self) -> str: + if self.legal_entity is not None: + return str(self.legal_entity) + else: + return str(self.individual) + + @property + def owner_identity(self) -> str: + if self.legal_entity is not None: + return _("Legal Entity") + else: + return _("Individual") + + @classmethod + def _create_fake(cls, mock_individual: bool = True) -> Self: + kwargs: dict[str, IndividualModel | LegalEntityModel] = dict() + + if mock_individual: + kwargs["individual"] = IndividualModel._create_fake() # type: ignore + else: + kwargs["legal_entity"] = LegalEntityModel._create_fake() # type: ignore + + return cls.objects.create( + **kwargs + ) + + class Meta: # type: ignore + db_table = "contract_owners" + + verbose_name = _("Contract Owner") + verbose_name_plural = _("Contract Owners") + + constraints = [ + models.UniqueConstraint( + fields=["individual", "contract"], + name="unique_individual_contract" + ), + models.UniqueConstraint( + fields=["legal_entity", "contract"], + name="unique_legal_entity_contract" + ), + ] diff --git a/core/apps/contracts/permissions/__init__.py b/core/apps/contracts/permissions/__init__.py new file mode 100644 index 0000000..a75110b --- /dev/null +++ b/core/apps/contracts/permissions/__init__.py @@ -0,0 +1,4 @@ +from .attached_files import * # noqa +from .contracts import * # noqa +from .file_contents import * # noqa +from .owners import * # noqa diff --git a/core/apps/contracts/permissions/attached_files.py b/core/apps/contracts/permissions/attached_files.py new file mode 100644 index 0000000..64a5572 --- /dev/null +++ b/core/apps/contracts/permissions/attached_files.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class ContractattachedfilePermission(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/contracts/permissions/contracts.py b/core/apps/contracts/permissions/contracts.py new file mode 100644 index 0000000..07dd082 --- /dev/null +++ b/core/apps/contracts/permissions/contracts.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class ContractPermission(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/contracts/permissions/file_contents.py b/core/apps/contracts/permissions/file_contents.py new file mode 100644 index 0000000..01ea61b --- /dev/null +++ b/core/apps/contracts/permissions/file_contents.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class ContractfilecontentPermission(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/contracts/permissions/owners.py b/core/apps/contracts/permissions/owners.py new file mode 100644 index 0000000..311fcf2 --- /dev/null +++ b/core/apps/contracts/permissions/owners.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class ContractownerPermission(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/contracts/serializers/__init__.py b/core/apps/contracts/serializers/__init__.py new file mode 100644 index 0000000..a75110b --- /dev/null +++ b/core/apps/contracts/serializers/__init__.py @@ -0,0 +1,4 @@ +from .attached_files import * # noqa +from .contracts import * # noqa +from .file_contents import * # noqa +from .owners import * # noqa diff --git a/core/apps/contracts/serializers/attached_files/__init__.py b/core/apps/contracts/serializers/attached_files/__init__.py new file mode 100644 index 0000000..009ad74 --- /dev/null +++ b/core/apps/contracts/serializers/attached_files/__init__.py @@ -0,0 +1 @@ +from .attached_files import * # noqa diff --git a/core/apps/contracts/serializers/attached_files/attached_files.py b/core/apps/contracts/serializers/attached_files/attached_files.py new file mode 100644 index 0000000..59d00fc --- /dev/null +++ b/core/apps/contracts/serializers/attached_files/attached_files.py @@ -0,0 +1,47 @@ +from rest_framework import serializers # type: ignore + +from core.apps.contracts.models import ContractAttachedFileModel +from core.apps.contracts.serializers.file_contents import ( + RetrieveContractFileContentSerializer +) + + +class BaseContractAttachedFileSerializer(serializers.ModelSerializer): + class Meta: + model = ContractAttachedFileModel + fields = "__all__" + read_only_fields = ( + "id", + "created_at", + "updated_at", + ) + + +class ListContractAttachedFileSerializer(BaseContractAttachedFileSerializer): + class Meta(BaseContractAttachedFileSerializer.Meta): + fields = ( + "id", + "name", + "created_at", + "updated_at", + ) + + +class RetrieveContractAttachedFileSerializer(BaseContractAttachedFileSerializer): + contents = RetrieveContractFileContentSerializer(many=True, read_only=True) + + class Meta(BaseContractAttachedFileSerializer.Meta): + fields = "__all__" + + +class CreateContractAttachedFileSerializer(BaseContractAttachedFileSerializer): + class Meta(BaseContractAttachedFileSerializer.Meta): ... + + +class UpdateContractAttachedFileSerializer(BaseContractAttachedFileSerializer): + class Meta(BaseContractAttachedFileSerializer.Meta): ... + + +class DestroyContractAttachedFileSerializer(BaseContractAttachedFileSerializer): + class Meta(BaseContractAttachedFileSerializer.Meta): + fields = ["id"] diff --git a/core/apps/contracts/serializers/contracts/__init__.py b/core/apps/contracts/serializers/contracts/__init__.py new file mode 100644 index 0000000..4c1cd4e --- /dev/null +++ b/core/apps/contracts/serializers/contracts/__init__.py @@ -0,0 +1 @@ +from .contracts import * # noqa diff --git a/core/apps/contracts/serializers/contracts/contracts.py b/core/apps/contracts/serializers/contracts/contracts.py new file mode 100644 index 0000000..6703d41 --- /dev/null +++ b/core/apps/contracts/serializers/contracts/contracts.py @@ -0,0 +1,42 @@ +from rest_framework import serializers + +from core.apps.contracts.models import ContractModel + + +class BaseContractSerializer(serializers.ModelSerializer): + class Meta: + model = ContractModel + fields = "__all__" + read_only_fields = ( + "id", + "created_at", + "updated_at" + ) + + +class ListContractSerializer(BaseContractSerializer): + class Meta(BaseContractSerializer.Meta): + fields = ( + "id", + "name", + "identifier", + "created_at", + "updated_at", + ) + + +class RetrieveContractSerializer(BaseContractSerializer): + class Meta(BaseContractSerializer.Meta): ... + + +class CreateContractSerializer(BaseContractSerializer): + class Meta(BaseContractSerializer.Meta): ... + + +class UpdateContractSerializer(BaseContractSerializer): + class Meta(BaseContractSerializer.Meta): ... + + +class DestroyContractSerializer(BaseContractSerializer): + class Meta(BaseContractSerializer.Meta): + fields = ["id"] diff --git a/core/apps/contracts/serializers/file_contents/__init__.py b/core/apps/contracts/serializers/file_contents/__init__.py new file mode 100644 index 0000000..c5333f0 --- /dev/null +++ b/core/apps/contracts/serializers/file_contents/__init__.py @@ -0,0 +1 @@ +from .file_contents import * # noqa diff --git a/core/apps/contracts/serializers/file_contents/file_contents.py b/core/apps/contracts/serializers/file_contents/file_contents.py new file mode 100644 index 0000000..6ed1ef3 --- /dev/null +++ b/core/apps/contracts/serializers/file_contents/file_contents.py @@ -0,0 +1,51 @@ +from rest_framework import serializers # type: ignore +from core.apps.contracts.models import ContractFileContentModel + + +class BaseContractFileContentSerializer(serializers.ModelSerializer): + document = serializers.FileField(required=True) + + class Meta: + model = ContractFileContentModel + fields = "__all__" + read_only_fields = ( + "id", + "created_at", + "updated_at", + ) + + +class ListContractFileContentSerializer(BaseContractFileContentSerializer): + class Meta(BaseContractFileContentSerializer.Meta): ... + + +class RetrieveContractFileContentSerializer(BaseContractFileContentSerializer): + class Meta(BaseContractFileContentSerializer.Meta): ... + + +class CreateContractFileContentSerializer(BaseContractFileContentSerializer): + class Meta(BaseContractFileContentSerializer.Meta): ... + + +class UpdateContractFileContentSerializer(BaseContractFileContentSerializer): + class Meta(BaseContractFileContentSerializer.Meta): ... + + +class DestroyContractFileContentSerializer(BaseContractFileContentSerializer): + class Meta(BaseContractFileContentSerializer.Meta): + fields = ["id"] + + +class CreateContractFileContentFromOwnerSerializer(BaseContractFileContentSerializer): + class Meta(BaseContractFileContentSerializer.Meta): + fields = "__all__" + read_only_fields = ( + *BaseContractFileContentSerializer.Meta.read_only_fields, + "file", + "contract_owner", + ) + def create(self, validated_data): # type: ignore + validated_data["contract_owner_id"] = self.context["owner_id"] + validated_data["file_id"] = self.context["file_id"] + return super().create(validated_data) # type: ignore + \ No newline at end of file diff --git a/core/apps/contracts/serializers/owners/__init__.py b/core/apps/contracts/serializers/owners/__init__.py new file mode 100644 index 0000000..f404028 --- /dev/null +++ b/core/apps/contracts/serializers/owners/__init__.py @@ -0,0 +1 @@ +from .owner import * # noqa diff --git a/core/apps/contracts/serializers/owners/owner.py b/core/apps/contracts/serializers/owners/owner.py new file mode 100644 index 0000000..3ef5341 --- /dev/null +++ b/core/apps/contracts/serializers/owners/owner.py @@ -0,0 +1,174 @@ +from rest_framework import serializers + +from core.apps.contracts.models import ( + ContractOwnerModel, + LegalEntityModel, + IndividualModel, +) + +from django.db import transaction + +# from core.apps.contracts.serializers.attached_files import ( +# BaseContractAttachedFileSerializer, +# ) +# from core.apps.contracts.serializers.file_contents import ( +# BaseContractFileContentSerializer +# ) + + +#! TODO fix: BaseContractOwnerSerializer (.create/.update) fix +class BaseIndividualSerializer(serializers.ModelSerializer): + class Meta: + model = IndividualModel + fields = "__all__" + read_only_fields = ( + "id", + "created_at", + "updated_at", + ) + extra_kwargs = { + "iin_code": { + "required": False + }, + "person_code": { + "required": False + } + } + + + +class ListIndividualSerializer(BaseIndividualSerializer): + class Meta(BaseIndividualSerializer.Meta): + fields = ( + "id", + "full_name", + "phone", + "created_at", + "updated_at", + ) + + +class BaseLegalEntitySerializer(serializers.ModelSerializer): + class Meta: + model = LegalEntityModel + fields = "__all__" + read_only_fields = ( + "id", + "created_at", + "updated_at", + ) + extra_kwargs = { + "bin_code": { + "required": False + }, + "identifier": { + "required": False + } + } + + + +class ListLegalEntitySerializer(BaseLegalEntitySerializer): + class Meta(BaseLegalEntitySerializer.Meta): + fields = ( + "id", + "name", + "phone", + "created_at", + "updated_at", + ) + + +class BaseContractOwnerSerializer(serializers.ModelSerializer): + legal_entity = BaseLegalEntitySerializer(required=False) + individual = BaseIndividualSerializer(required=False) + + class Meta: + model = ContractOwnerModel + fields = "__all__" + read_only_fields = ( + "id", + "created_at", + "updated_at", + ) + extra_kwargs = { + "legal_entity": { + "required": False + }, + "individual": { + "required": False + } + } + + def create(self, validated_data): + legal_entity_data = validated_data.pop("legal_entity", None) + individual_data = validated_data.pop("individual", None) + + with transaction.atomic(): + if legal_entity_data is not None: + legal_entity_serializer = BaseLegalEntitySerializer(data=legal_entity_data) + legal_entity_serializer.is_valid(raise_exception=True) + validated_data["legal_entity"] = legal_entity_serializer.save() + + if individual_data is not None: + individual_serializer = BaseIndividualSerializer(data=individual_data) + individual_serializer.is_valid(raise_exception=True) + validated_data["individual"] = individual_serializer.save() + + contract_owner = ContractOwnerModel.objects.create(**validated_data) + return contract_owner + + def update(self, instance, validated_data): + legal_entity_data = validated_data.pop("legal_entity", None) + individual_data = validated_data.pop("individual", None) + + with transaction.atomic(): + if legal_entity_data is not None: + if instance.legal_entity: + for attr, value in legal_entity_data.items(): + setattr(instance.legal_entity, attr, value) + instance.legal_entity.save() + else: + legal_entity = LegalEntityModel.objects.create(**legal_entity_data) + instance.legal_entity = legal_entity + + if individual_data is not None: + if instance.individual: + for attr, value in individual_data.items(): + setattr(instance.individual, attr, value) + instance.individual.save() + else: + individual = IndividualModel.objects.create(**individual_data) + instance.individual = individual + + # Update ContractOwnerModel fields + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + return instance + + +class ListContractOwnerSerializer(BaseContractOwnerSerializer): + legal_entity = ListLegalEntitySerializer(read_only=True) + individual = ListIndividualSerializer(read_only=True) + + class Meta(BaseContractOwnerSerializer.Meta): ... + + +class RetrieveContractOwnerSerializer(BaseContractOwnerSerializer): + class Meta(BaseContractOwnerSerializer.Meta): ... + + +class CreateContractOwnerSerializer(BaseContractOwnerSerializer): + class Meta(BaseContractOwnerSerializer.Meta): ... + + +class UpdateContractOwnerSerializer(BaseContractOwnerSerializer): + class Meta(BaseContractOwnerSerializer.Meta): ... + + +class DestroyContractOwnerSerializer(BaseContractOwnerSerializer): + class Meta(BaseContractOwnerSerializer.Meta): + fields = ["id"] + \ No newline at end of file diff --git a/core/apps/contracts/signals/__init__.py b/core/apps/contracts/signals/__init__.py new file mode 100644 index 0000000..a75110b --- /dev/null +++ b/core/apps/contracts/signals/__init__.py @@ -0,0 +1,4 @@ +from .attached_files import * # noqa +from .contracts import * # noqa +from .file_contents import * # noqa +from .owners import * # noqa diff --git a/core/apps/contracts/signals/attached_files.py b/core/apps/contracts/signals/attached_files.py new file mode 100644 index 0000000..108c8ba --- /dev/null +++ b/core/apps/contracts/signals/attached_files.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.contracts.models import ContractattachedfileModel + + +@receiver(post_save, sender=ContractattachedfileModel) +def ContractattachedfileSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/contracts/signals/contracts.py b/core/apps/contracts/signals/contracts.py new file mode 100644 index 0000000..1582423 --- /dev/null +++ b/core/apps/contracts/signals/contracts.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.contracts.models import ContractModel + + +@receiver(post_save, sender=ContractModel) +def ContractSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/contracts/signals/file_contents.py b/core/apps/contracts/signals/file_contents.py new file mode 100644 index 0000000..49c4b8d --- /dev/null +++ b/core/apps/contracts/signals/file_contents.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.contracts.models import ContractfilecontentModel + + +@receiver(post_save, sender=ContractfilecontentModel) +def ContractfilecontentSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/contracts/signals/owners.py b/core/apps/contracts/signals/owners.py new file mode 100644 index 0000000..f9c2c65 --- /dev/null +++ b/core/apps/contracts/signals/owners.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.contracts.models import ContractownerModel + + +@receiver(post_save, sender=ContractownerModel) +def ContractownerSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/contracts/tests/__init__.py b/core/apps/contracts/tests/__init__.py new file mode 100644 index 0000000..f9028c3 --- /dev/null +++ b/core/apps/contracts/tests/__init__.py @@ -0,0 +1,4 @@ +from .test_attached_files import * # noqa +from .test_contracts import * # noqa +from .test_file_contents import * # noqa +from .test_owners import * # noqa diff --git a/core/apps/contracts/tests/test_attached_files.py b/core/apps/contracts/tests/test_attached_files.py new file mode 100644 index 0000000..33c8e60 --- /dev/null +++ b/core/apps/contracts/tests/test_attached_files.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.contracts.models import ContractattachedfileModel + + +class ContractattachedfileTest(TestCase): + + def _create_data(self): + return ContractattachedfileModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("ContractAttachedFile-list"), + "retrieve": reverse("ContractAttachedFile-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("ContractAttachedFile-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/contracts/tests/test_contracts.py b/core/apps/contracts/tests/test_contracts.py new file mode 100644 index 0000000..8db9a8c --- /dev/null +++ b/core/apps/contracts/tests/test_contracts.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.contracts.models import ContractModel + + +class ContractTest(TestCase): + + def _create_data(self): + return ContractModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("Contract-list"), + "retrieve": reverse("Contract-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("Contract-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/contracts/tests/test_file_contents.py b/core/apps/contracts/tests/test_file_contents.py new file mode 100644 index 0000000..f91b44b --- /dev/null +++ b/core/apps/contracts/tests/test_file_contents.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.contracts.models import ContractfilecontentModel + + +class ContractfilecontentTest(TestCase): + + def _create_data(self): + return ContractfilecontentModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("ContractFileContent-list"), + "retrieve": reverse("ContractFileContent-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("ContractFileContent-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/contracts/tests/test_owners.py b/core/apps/contracts/tests/test_owners.py new file mode 100644 index 0000000..0823311 --- /dev/null +++ b/core/apps/contracts/tests/test_owners.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.contracts.models import ContractownerModel + + +class ContractownerTest(TestCase): + + def _create_data(self): + return ContractownerModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("ContractOwner-list"), + "retrieve": reverse("ContractOwner-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("ContractOwner-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/contracts/translation/__init__.py b/core/apps/contracts/translation/__init__.py new file mode 100644 index 0000000..a75110b --- /dev/null +++ b/core/apps/contracts/translation/__init__.py @@ -0,0 +1,4 @@ +from .attached_files import * # noqa +from .contracts import * # noqa +from .file_contents import * # noqa +from .owners import * # noqa diff --git a/core/apps/contracts/translation/attached_files.py b/core/apps/contracts/translation/attached_files.py new file mode 100644 index 0000000..deb1d4d --- /dev/null +++ b/core/apps/contracts/translation/attached_files.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.contracts.models import ContractattachedfileModel + + +@register(ContractattachedfileModel) +class ContractattachedfileTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/contracts/translation/contracts.py b/core/apps/contracts/translation/contracts.py new file mode 100644 index 0000000..31ad29e --- /dev/null +++ b/core/apps/contracts/translation/contracts.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.contracts.models import ContractModel + + +@register(ContractModel) +class ContractTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/contracts/translation/file_contents.py b/core/apps/contracts/translation/file_contents.py new file mode 100644 index 0000000..180a137 --- /dev/null +++ b/core/apps/contracts/translation/file_contents.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.contracts.models import ContractfilecontentModel + + +@register(ContractfilecontentModel) +class ContractfilecontentTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/contracts/translation/owners.py b/core/apps/contracts/translation/owners.py new file mode 100644 index 0000000..752a1ba --- /dev/null +++ b/core/apps/contracts/translation/owners.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.contracts.models import ContractownerModel + + +@register(ContractownerModel) +class ContractownerTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/contracts/urls.py b/core/apps/contracts/urls.py new file mode 100644 index 0000000..372d7e7 --- /dev/null +++ b/core/apps/contracts/urls.py @@ -0,0 +1,37 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter # type: ignore + +from . import views + +router = DefaultRouter() + +router.register(r"contract-attached-files", views.ContractAttachedFileView, "contract-attached-files") # type: ignore +router.register(r"contracts", views.ContractView, "contracts") # type: ignore +router.register(r"contract-file-contents", views.ContractFileContentView, "contract-file-contents") # type: ignore +router.register(r"contract-owners", views.ContractOwnerView, "contract-owners") # type: ignore +router.register(r"contract-owners", views.ContractOwnerFileViewSet, "contract-owner-files") # type: ignore + + +urlpatterns = [ # type: ignore + path("", include(router.urls)), # type: ignore + path( + r"contract-owners//contract", + views.ContractDetailView.as_view(), + name="contract-detail" + ), + path( + "contract-owners//files/", + views.ContractAttachedFileDeleteView.as_view(), + name="contract-file-delete" + ), + path( + r"contract-owners//files//upload", + views.UploadFileContentView.as_view(), + name="upload-file-content" + ), + path( + r"folders//contracts", + views.ListFolderContractsView.as_view(), + name="list-folder-contracts" + ) +] diff --git a/core/apps/contracts/validators/__init__.py b/core/apps/contracts/validators/__init__.py new file mode 100644 index 0000000..a75110b --- /dev/null +++ b/core/apps/contracts/validators/__init__.py @@ -0,0 +1,4 @@ +from .attached_files import * # noqa +from .contracts import * # noqa +from .file_contents import * # noqa +from .owners import * # noqa diff --git a/core/apps/contracts/validators/attached_files.py b/core/apps/contracts/validators/attached_files.py new file mode 100644 index 0000000..9eb7e8b --- /dev/null +++ b/core/apps/contracts/validators/attached_files.py @@ -0,0 +1,34 @@ +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_core.models.base import AbstractBaseModel # type: ignore + + +def starts_with_letter_validator(value: str) -> None: + """ + Checks if the value starts with a letter. + """ + if value[0].isalpha(): + return + + raise ValidationError( + _("File name must start with a letter."), + code="invalid_start", + ) + + +allowed_chars_validator = RegexValidator( + regex=r"^[A-Za-z0-9\s._-]+$", + message=_( + "File name can only contain letters, " \ + "digits, spaces, dots, underscores, or hyphens." + ), + code="invalid_characters", +) + + +class ContractAttachedFileValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/contracts/validators/contracts.py b/core/apps/contracts/validators/contracts.py new file mode 100644 index 0000000..40c8d3f --- /dev/null +++ b/core/apps/contracts/validators/contracts.py @@ -0,0 +1,23 @@ +# from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from django_core.models.base import AbstractBaseModel # type: ignore + + +name_validator = RegexValidator( + regex=r"^[A-Za-z0-9Γ€-ΓΏ&()\-.,'\" ]{3,100}$", + message=_( + "Enter a valid contract name. " + "Use letters, numbers, spaces, and " + "limited punctuation (e.g., &, -, (), ., ', \"). " + "Length must be 3 to 100 characters." + ) +) + + +class ContractValidator: + def __init__(self, instance: AbstractBaseModel): + self.instance = instance + + def __call__(self): + return True diff --git a/core/apps/contracts/validators/file_contents.py b/core/apps/contracts/validators/file_contents.py new file mode 100644 index 0000000..22b0cc7 --- /dev/null +++ b/core/apps/contracts/validators/file_contents.py @@ -0,0 +1,8 @@ +# from django.core.exceptions import ValidationError + + +class ContractfilecontentValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/contracts/validators/owners.py b/core/apps/contracts/validators/owners.py new file mode 100644 index 0000000..d058877 --- /dev/null +++ b/core/apps/contracts/validators/owners.py @@ -0,0 +1,146 @@ +import re + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from django_core.models.base import AbstractBaseModel # type: ignore +from django.core.validators import RegexValidator + + +full_name_validator = RegexValidator( + regex=r"^[A-Z][a-z]+ [A-Z][a-z]+ [A-Z][a-z]+$", + message=_( + "Invalid Full Name, Please enter Full Name in the " + "format: " + ) +) + +iin_code_validator = RegexValidator( + regex=r'^\d{14}$', + message=_("IIN code must consist of exactly 14 digits."), + code='invalid_iin' +) + + +person_code_validator = RegexValidator( + regex=r'^[A-Za-z0-9_-]{3,64}$', + message=_( + "Person Code must be 3 to 64 characters long and contain" + " only letters, digits, dashes, or underscores." + ), + code='invalid_person_code' +) + + +phone_validator = RegexValidator( + regex=r'^\+?[1-9]\d{1,14}$', + message=_( + "Enter a valid international phone number " + "(E.164 format, e.g., +14155552671)." + ) +) + + +def bin_code_validator(value: str): + if not value.isdigit(): + raise ValidationError(_("BIN code must contain only digits.")) + + if len(value) != 14: + raise ValidationError(_("BIN code must be exactly 14 digits long.")) + + if not re.match(r"^\d{14}$", value): + raise ValidationError(_("Invalid BIN code format.")) + + # Optional: check if first 6 digits represent a valid date (YYMMDD) + try: + from datetime import datetime + datetime.strptime(value[:6], "%y%m%d") + except ValueError: + raise ValidationError(_("BIN code starts with an invalid date.")) + + +def name_validator(value: str): + if len(value.strip()) < 3: + raise ValidationError(_("Name is too short.")) + + if re.fullmatch(r"(.)\1{2,}", value): # e.g., "aaa", "!!!" + raise ValidationError(_("Name cannot contain excessive repeated characters.")) + + if not re.match(r"^[A-Za-z0-9&\-\.\,\(\)\s]+$", value): + raise ValidationError(_("Name contains invalid characters. Only letters, numbers, spaces, and symbols like . , & - ( ) are allowed.")) + + if value.lower() in {"test", "company", "name", "example", "sample"}: + raise ValidationError(_("This name is too generic. Please choose a more specific name.")) + + if value.isupper() or value.islower(): + raise ValidationError(_("Name must have both uppercase and lowercase letters.")) + + if not value[0].isupper(): + raise ValidationError(_("Name should start with a capital letter.")) + + if len(set(value.lower())) < 4: + raise ValidationError(_("Name is too repetitive or lacks meaningful structure.")) + + +class IndividualValidator: + def __init__(self, instance: AbstractBaseModel): + self.instance = instance + + def __call__(self): + if ( + self.instance.iin_code is None # type: ignore + and self.instance.person_code is None # type: ignore + ): + raise ValidationError(_( + "Either IIN code or Person Code must be provided." + )) + + if ( + self.instance.iin_code is not None # type: ignore + and self.instance.person_code is not None # type: ignore + ): + raise ValidationError(_( + "One of IIN code or Person code must be empty" + )) + + +class LegalEntityValidator: + def __init__(self, instance: AbstractBaseModel): + self.instance = instance + + def __call__(self): + + if ( + self.instance.identifier is None # type: ignore + and self.instance.bin_code is None # type: ignore + ): + raise ValidationError(_( + "Either Identifier or BIN code " + "must contain a value" + )) + + if ( + self.instance.identifier is not None # type: ignore + and self.instance.bin_code is not None # type: ignore + ): + raise ValidationError(_( + "One of Indentifier or BIN code " + "must be empty" + )) + + +class ContractOwnerValidator: + def __init__(self, instance: AbstractBaseModel): + self.instance = instance + + def __call__(self): + if ( + self.instance.legal_entity is None # type: ignore + and self.instance.individual is None # type: ignore + ): + raise ValidationError("Either entity or individual should be not None") + + if ( + self.instance.legal_entity is not None # type: ignore + and self.instance.individual is not None # type: ignore + ): + raise ValidationError("One of Individual or Legal entity should be given") diff --git a/core/apps/contracts/views/__init__.py b/core/apps/contracts/views/__init__.py new file mode 100644 index 0000000..a75110b --- /dev/null +++ b/core/apps/contracts/views/__init__.py @@ -0,0 +1,4 @@ +from .attached_files import * # noqa +from .contracts import * # noqa +from .file_contents import * # noqa +from .owners import * # noqa diff --git a/core/apps/contracts/views/attached_files.py b/core/apps/contracts/views/attached_files.py new file mode 100644 index 0000000..b52c8be --- /dev/null +++ b/core/apps/contracts/views/attached_files.py @@ -0,0 +1,35 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.viewsets import ModelViewSet + +from core.apps.contracts.models import ContractAttachedFileModel +from core.apps.contracts.serializers.attached_files import ( + CreateContractAttachedFileSerializer, + ListContractAttachedFileSerializer, + RetrieveContractAttachedFileSerializer, + UpdateContractAttachedFileSerializer, + DestroyContractAttachedFileSerializer, +) + + +@extend_schema(tags=["ContractAttachedFile"]) +class ContractAttachedFileView(BaseViewSetMixin, ModelViewSet): + queryset = ContractAttachedFileModel.objects.all() + serializer_class = ListContractAttachedFileSerializer + permission_classes = [AllowAny] + + action_permission_classes = { + "list": [IsAdminUser], + "retrieve": [IsAdminUser], + "create": [IsAdminUser], + "update": [IsAdminUser], + "destroy": [IsAdminUser], + } + action_serializer_class = { + "list": ListContractAttachedFileSerializer, + "retrieve": RetrieveContractAttachedFileSerializer, + "create": CreateContractAttachedFileSerializer, + "update": UpdateContractAttachedFileSerializer, + "destroy": DestroyContractAttachedFileSerializer, + } diff --git a/core/apps/contracts/views/contracts.py b/core/apps/contracts/views/contracts.py new file mode 100644 index 0000000..3b22351 --- /dev/null +++ b/core/apps/contracts/views/contracts.py @@ -0,0 +1,123 @@ +import uuid + +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny, IsAdminUser # type: ignore +from rest_framework.viewsets import ModelViewSet # type: ignore +from rest_framework.views import APIView # type: ignore + +from rest_framework.request import HttpRequest # type: ignore +from rest_framework.response import Response # type: ignore +from rest_framework.decorators import action # type: ignore +from rest_framework.generics import get_object_or_404 # type: ignore +from rest_framework import status # type: ignore + +from django_core.mixins import BaseViewSetMixin # type: ignore +from core.apps.contracts.models import ( + ContractModel, + ContractAttachedFileModel, + ContractOwnerModel +) +from core.apps.contracts.serializers import ( + CreateContractSerializer, + ListContractSerializer, + RetrieveContractSerializer, + UpdateContractSerializer, + DestroyContractSerializer, + + ListContractAttachedFileSerializer, + RetrieveContractOwnerSerializer +) + + +@extend_schema(tags=["Contract"]) +class ContractView(BaseViewSetMixin, ModelViewSet): + queryset = ContractModel.objects.all() + serializer_class = ListContractSerializer + permission_classes = [AllowAny] + + action_permission_classes = { # type: ignore + "list": [IsAdminUser], + "retrieve": [IsAdminUser], + "create": [IsAdminUser], + "update": [IsAdminUser], + "destroy": [IsAdminUser], + "list_file": [AllowAny], + "list_owner": [AllowAny], + } + action_serializer_class = { # type: ignore + "list": ListContractSerializer, + "retrieve": RetrieveContractSerializer, + "create": CreateContractSerializer, + "update": UpdateContractSerializer, + "destroy": DestroyContractSerializer, + "list_file": ListContractAttachedFileSerializer, + "list_owner": RetrieveContractOwnerSerializer, + } + + @extend_schema( + summary="Get List Of Files", + description="Get List Of Files" + ) + @action(url_path="files", detail=True, methods=["GET"]) + def list_file( + self, + request: HttpRequest, + pk: uuid.UUID, + *args: object, + **kwargs: object + ) -> Response: + contract = get_object_or_404(ContractModel, pk=pk) + files = ContractAttachedFileModel.objects.filter(contract=contract) + ser = self.get_serializer(instance=files, many=True) # type: ignore + return Response(data=ser.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Get List Of Owners", + description="Get list of owners" + ) + @action(url_path="owners", detail=True, methods=["GET"]) + def list_owner( + self, + request: HttpRequest, + pk: uuid.UUID, + *args: object, + **kwargs: object + ) -> Response: + contract = get_object_or_404(ContractModel, pk=pk) + owners = ContractOwnerModel.objects.filter(contract=contract) + ser = self.get_serializer(instance=owners, many=True) # type: ignore + return Response(ser.data, status.HTTP_200_OK) + + +class ContractDetailView(APIView): + permission_classes = [AllowAny] + + @extend_schema( + summary="Uploads a file for contract attached files", + description="Creates a file for contract attached files.", + ) + def get( + self, + request: HttpRequest, + owner_id: uuid.UUID, + *args: object, + **kwargs: object + ) -> Response: + contract = ContractModel.objects.filter(owners__id=owner_id)[0] + ser = RetrieveContractSerializer(instance=contract) + return Response(ser.data, status=status.HTTP_200_OK) + + +class ListFolderContractsView(APIView): + permission_classes = [IsAdminUser] + + def get( + self, + request: HttpRequest, + pk: uuid.UUID, + *args: object, + **kwargs: object + ) -> Response: + contracts = ContractModel.objects.filter(folders__id=pk) + ser = ListContractSerializer(instance=contracts, many=True) + return Response(ser.data, status.HTTP_200_OK) diff --git a/core/apps/contracts/views/file_contents.py b/core/apps/contracts/views/file_contents.py new file mode 100644 index 0000000..f049f8c --- /dev/null +++ b/core/apps/contracts/views/file_contents.py @@ -0,0 +1,35 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.viewsets import ModelViewSet + +from core.apps.contracts.models import ContractFileContentModel +from core.apps.contracts.serializers.file_contents import ( + CreateContractFileContentSerializer, + ListContractFileContentSerializer, + RetrieveContractFileContentSerializer, + UpdateContractFileContentSerializer, + DestroyContractFileContentSerializer +) + + +@extend_schema(tags=["ContractFileContent"]) +class ContractFileContentView(BaseViewSetMixin, ModelViewSet): + queryset = ContractFileContentModel.objects.all() + serializer_class = ListContractFileContentSerializer + permission_classes = [AllowAny] + + action_permission_classes = { + "list": [IsAdminUser], + "retrieve": [IsAdminUser], + "create": [IsAdminUser], + "update": [IsAdminUser], + "destroy": [IsAdminUser], + } + action_serializer_class = { + "list": ListContractFileContentSerializer, + "retrieve": RetrieveContractFileContentSerializer, + "create": CreateContractFileContentSerializer, + "update": UpdateContractFileContentSerializer, + "destroy": DestroyContractFileContentSerializer, + } diff --git a/core/apps/contracts/views/owners.py b/core/apps/contracts/views/owners.py new file mode 100644 index 0000000..a5ba640 --- /dev/null +++ b/core/apps/contracts/views/owners.py @@ -0,0 +1,171 @@ +import uuid +from typing import cast + +from django_core.mixins import BaseViewSetMixin # type: ignore +from django.utils.translation import gettext as _ +from drf_spectacular.utils import extend_schema, OpenApiResponse +from rest_framework.exceptions import PermissionDenied # type: ignore +from rest_framework.decorators import action # type: ignore +from rest_framework.permissions import AllowAny, IsAdminUser # type: ignore +from rest_framework.viewsets import ModelViewSet # type: ignore +from rest_framework.request import HttpRequest # type: ignore +from rest_framework.response import Response # type: ignore +from rest_framework import status # type: ignore +from rest_framework.views import APIView # type: ignore +from rest_framework.parsers import MultiPartParser, FormParser # type: ignore +from rest_framework.generics import get_object_or_404 # type: ignore + +from core.apps.contracts.models import ( + ContractOwnerModel, + ContractAttachedFileModel +) +from core.apps.contracts.serializers import ( + CreateContractOwnerSerializer, + ListContractOwnerSerializer, + RetrieveContractOwnerSerializer, + UpdateContractOwnerSerializer, + DestroyContractOwnerSerializer, + + RetrieveContractAttachedFileSerializer, + CreateContractAttachedFileSerializer, + CreateContractFileContentFromOwnerSerializer, +) + + +@extend_schema(tags=["ContractOwner"]) +class ContractOwnerView(BaseViewSetMixin, ModelViewSet): + queryset = ContractOwnerModel.objects.all() + serializer_class = ListContractOwnerSerializer + permission_classes = [AllowAny] + + action_permission_classes = { + "list": [IsAdminUser], + "retrieve": [IsAdminUser], + "create": [IsAdminUser], + "update": [IsAdminUser], + "destroy": [IsAdminUser], + } + action_serializer_class = { # type: ignore + "list": ListContractOwnerSerializer, + "retrieve": RetrieveContractOwnerSerializer, + "create": CreateContractOwnerSerializer, + "update": UpdateContractOwnerSerializer, + "destroy": DestroyContractOwnerSerializer, + } + + +class ContractOwnerFileViewSet(BaseViewSetMixin, ModelViewSet): + queryset = ContractOwnerModel.objects.all() + serializer_class = RetrieveContractAttachedFileSerializer + permission_classes = [AllowAny] + + # action_permission_classes = { + # "create_file": [AllowAny], + # "list_file": [AllowAny] + # } + action_serializer_class = { # type: ignore + "list_file": RetrieveContractAttachedFileSerializer, + "create_file": CreateContractAttachedFileSerializer, + } + + @extend_schema( + summary="Contract Files Related to Owner", + description="Contract Files Related to Owner", + ) + @action(url_path="files", methods=["GET"], detail=True) + def list_file( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> Response: + owner = cast(ContractOwnerModel, self.get_object()) + files = ContractAttachedFileModel.objects.filter( + contract__owners=owner, contents__owner=owner + ).select_related("contents") + serializer = self.get_serializer(instance=files, many=True) + return Response(serializer.data, status.HTTP_200_OK) + + @extend_schema( + summary="Create Contract Files Related to Owner", + description="Create Contract Files Related to Owner" + ) + @action(url_path="files", methods=["GET"], detail=True) + def create_file( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> Response: + owner = cast( + ContractOwnerModel, + self.get_queryset().select_related("contract") + ) + if not owner.contract.allow_add_files: + raise PermissionDenied(_("Attaching new files was restricted for this contract.")) + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + ser.save() # type: ignore + return Response(ser.data, status.HTTP_201_CREATED) + + +class ContractAttachedFileDeleteView(APIView): + permission_classes = [AllowAny] + + @extend_schema( + # request=ContractFileDeleteRequestSerializer, + responses={204: OpenApiResponse(description="File successfully deleted.")}, + summary="Delete a file from contract", + description="Deletes a contract-attached file if contract allows file deletion.", + ) + def delete( + self, + request: HttpRequest, + owner_id: uuid.UUID, + file_id: uuid.UUID, + *args: object, + **kwargs: object + ) -> Response: + owner = get_object_or_404( + ContractOwnerModel.objects.all().select_related("contract"), pk=owner_id + ) + if not owner.contract.allow_delete_files: + raise PermissionDenied(_("Deleting attached files was restricted for this contract")) + file = get_object_or_404( + ContractAttachedFileModel.objects.all().select_related("contract"), + pk=file_id + ) + if owner.contract.pk != file.contract.pk: + raise PermissionDenied(_("Contract have no such file attached")) + file.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class UploadFileContentView(APIView): + permission_classes = [AllowAny] + parser_classes = [MultiPartParser, FormParser] # type: ignore + + @extend_schema( + summary="Uploads a file for contract attached files", + description="Creates a file for contract attached files.", + request=CreateContractFileContentFromOwnerSerializer, + responses={201: CreateContractFileContentFromOwnerSerializer}, + ) + def post( + self, + request: HttpRequest, + owner_id: uuid.UUID, + file_id: uuid.UUID, + *args: object, + **kwargs: object + ) -> Response: + serializer = CreateContractFileContentFromOwnerSerializer( + data=request.data, # type: ignore + context={ + "file_id": file_id, + "contract_owner_id": owner_id, + } + ) + serializer.is_valid(raise_exception=True) + serializer.save() # type: ignore + return Response(serializer.data, status=status.HTTP_201_CREATED) 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/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..acc8d5e --- /dev/null +++ b/core/apps/shared/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.2.4 on 2025-08-01 09:53 + +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..fecd1ff --- /dev/null +++ b/core/apps/shared/models/settings.py @@ -0,0 +1,31 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel +from django.contrib.postgres.fields import ArrayField + + +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/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..7eade1d --- /dev/null +++ b/core/services/sms.py @@ -0,0 +1,57 @@ +from datetime import datetime, timedelta + +from django_core import exceptions, models, tasks + + +class SmsService: + @staticmethod + def send_confirm(phone): + # TODO: Deploy this change when deploying -> code = random.randint(1000, 9999) # noqa + code = 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() + + tasks.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 sms_confirm.code == 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/base_model.py b/core/utils/base_model.py new file mode 100644 index 0000000..a499c81 --- /dev/null +++ b/core/utils/base_model.py @@ -0,0 +1,28 @@ +import uuid + +from django.utils.translation import gettext_lazy as _ +from django.db import models + +from django_core.models.base import AbstractBaseModel # type: ignore + + +class UUIDPrimaryKeyBaseModel(AbstractBaseModel): + id = models.UUIDField( + verbose_name=_("ID"), + primary_key=True, + default=uuid.uuid4, + editable=False, + ) + + created_at = models.DateTimeField( + verbose_name=_("Created At"), + auto_now_add=True + ) + + updated_at = models.DateTimeField( + verbose_name=_("Updated At"), + auto_now=True + ) + + class Meta: # type: ignore # noqa + abstract = True 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..453dd09 --- /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[dict[str, object]]: + 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.yml b/docker-compose.yml new file mode 100644 index 0000000..10b8744 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,81 @@ +networks: + trustmeuz: + driver: bridge + +volumes: + pg_data: null + rabbitmq: null + pycache: null + # minio_certs: null + minio_data: null + +services: + nginx: + networks: + - trustmeuz + 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: + - trustmeuz + build: + context: . + dockerfile: ./docker/Dockerfile.web + restart: no + command: ${COMMAND:-sh ./entrypoint.sh} + environment: + - PYTHONPYCACHEPREFIX=/var/cache/pycache + volumes: + - .:/code + - pycache:/var/cache/pycache + depends_on: + - db + - redis + # - minio + + db: + networks: + - trustmeuz + image: postgres:16 + restart: no + environment: + POSTGRES_DB: django + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-2309} + volumes: + - pg_data:/var/lib/postgresql/data + redis: + networks: + - trustmeuz + restart: no + image: redis + + minio: + image: minio/minio + container_name: minio + networks: + - trustmeuz + expose: + - 9000 + - 9001 + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + environment: + MINIO_ROOT_USER: ${STORAGE_ID} + MINIO_ROOT_PASSWORD: ${STORAGE_KEY} + STORAGE_BUCKET_MEDIA: ${STORAGE_BUCKET_MEDIA} + STORAGE_BUCKET_STATIC: ${STORAGE_BUCKET_STATIC} + command: server --console-address ":9001" /data + # entrypoint: > + # sh -c "chmod +x ./resources/layout/minio-setup.sh && fg" 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..1fcf699 --- /dev/null +++ b/docker/Dockerfile.web @@ -0,0 +1,9 @@ +FROM jscorptech/django:v0.5 + +WORKDIR /code + +COPY ./ /code + +RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt + +CMD ["sh", "./resources/scripts/entrypoint.sh"] diff --git a/index.html b/index.html new file mode 100644 index 0000000..ceb1c74 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + Document + + + + fds + + + \ No newline at end of file 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/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..e7de07c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[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"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dd0e88f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,86 @@ +amqp==5.3.1 +annotated-types==0.7.0 +asgiref==3.9.1 +attrs==25.3.0 +autocommand==2.2.2 +backports.tarfile==1.2.0 +bcrypt==4.3.0 +billiard==4.2.1 +boto3==1.39.17 +botocore==1.39.17 +celery==5.4.0 +certifi==2025.7.14 +charset-normalizer==3.4.2 +click==8.2.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +Django==5.2.4 +django-cacheops==7.2 +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-silk==5.4.0 +django-storages==1.14.6 +django-unfold==0.42.0 +djangorestframework==3.16.0 +djangorestframework-simplejwt==5.3.1 +drf-spectacular==0.28.0 +drf-writable-nested==0.7.2 +funcy==2.0 +gprof2dot==2025.4.14 +h11==0.16.0 +idna==3.10 +importlib_metadata==8.5.0 +importlib_resources==6.4.5 +inflect==7.3.1 +inflection==0.5.1 +iniconfig==2.1.0 +jaraco.collections==5.1.0 +jaraco.context==6.0.1 +jaraco.functools==4.2.1 +jaraco.text==4.0.0 +jmespath==1.0.1 +jsonschema==4.25.0 +jsonschema-specifications==2025.4.1 +jst-django-core==1.1.9 +kombu==5.5.4 +markdown-it-py==3.0.0 +mdurl==0.1.2 +more-itertools==10.7.0 +packaging==24.2 +pillow==11.3.0 +pip-chill==1.0.3 +platformdirs==4.3.6 +pluggy==1.6.0 +prompt_toolkit==3.0.51 +psycopg2-binary==2.9.10 +pydantic==2.11.7 +pydantic_core==2.33.2 +Pygments==2.19.2 +PyJWT==2.10.1 +pytest==8.4.1 +pytest-django==4.11.1 +python-dateutil==2.9.0.post0 +PyYAML==6.0.2 +redis==6.2.0 +referencing==0.36.2 +requests==2.32.4 +rich==14.0.0 +rpds-py==0.26.0 +s3transfer==0.13.1 +six==1.17.0 +sqlparse==0.5.3 +tomli==2.2.1 +typeguard==4.4.4 +typing-inspection==0.4.1 +typing_extensions==4.14.1 +tzdata==2025.2 +uritemplate==4.2.0 +urllib3==2.5.0 +uvicorn==0.32.1 +vine==5.1.0 +wcwidth==0.2.13 +zipp==3.23.0 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/minio-setup.sh b/resources/layout/minio-setup.sh new file mode 100644 index 0000000..4f3c805 --- /dev/null +++ b/resources/layout/minio-setup.sh @@ -0,0 +1,32 @@ +#!/bin/sh + +echo "Setting up MinIO bucket and public policy..." + +# Wait for MinIO to become available +until curl -s http://localhost:9000/minio/health/ready > /dev/null; do + echo "Waiting for MinIO..." + sleep 2 +done + +# Set default values from environment or fallback +STORAGE_ID=${STORAGE_ID:-minioadmin} +STORAGE_KEY=${STORAGE_KEY:-minioadmin} +STORAGE_BUCKET_MEDIA=${STORAGE_BUCKET_MEDIA:-media} +STORAGE_BUCKET_STATIC=${STORAGE_BUCKET_STATIC:-static} + +# Setup mc alias +mc alias set local http://0.0.0.0:9000 "$STORAGE_ID" "$STORAGE_KEY" + +# Create buckets (no error if already exists) +mc mb --ignore-existing local/"$STORAGE_BUCKET_MEDIA" +mc mb --ignore-existing local/"$STORAGE_BUCKET_STATIC" + +# Set upload/download access for Django (authenticated) +mc policy set write local/"$STORAGE_BUCKET_MEDIA" +mc policy set write local/"$STORAGE_BUCKET_STATIC" + +# Set anonymous read policy +mc anonymous set download local/"$STORAGE_BUCKET_MEDIA" +mc anonymous set download local/"$STORAGE_BUCKET_STATIC" + +echo "βœ… MinIO public policy applied" 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..d0233fc --- /dev/null +++ b/resources/scripts/entrypoint-server.sh @@ -0,0 +1,11 @@ +#!/bin/bash +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..b5e6e01 --- /dev/null +++ b/resources/scripts/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/bash +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/logo.png b/resources/static/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..57ac67eec80f23a0ea8c2fc7c946588a4d8f5d35 GIT binary patch literal 39362 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]%23n2&SNM2Gj_-SWIm&t|Q0ArH zEW}EpMf;!OUvkO(W^>^0BI2b4+}co12rXUg?tE@@3^C9b(H9qo5GQvr&PGCVDj6%N z#&-J;N%Ddv=()=CJ;u8E7JfH7nt){E1UyF0C*Y^b^Kj2&=bzwcKn;m+7v0=&N>9I$~ins{jl};QX&T#X27B!vXt|M&D~uV zhvcig(GR!eJvdvN@Y$_I!D(bF*#-fDG-XZcYH`cwVOTgy=|A~+S&5I7YCr_(Q!uZ2 zIhd&2+g@}+7}>igvo|QW1??R^)-8p3<;VZpq4y&q%9R4MPv~-HKT|)~FoOcJzb1g3 zSuL9toQ+zh$!639_)Wl`kjjDLqb*f;eEGq!a3d!Oq7VUMJ$v|&(d6gX?>Mo{xjXki zg}bN-b_$2nSAKC2xVCd3En->XRk(WA!EU`?kiL&#IF1s}4~BNO!w&=*tVU;=idFGf ziBl;1`u=*in`bAcvz}cdPYt>s0pq*5G`V_%f!?gx5itp@fV>HW8!9O-b7}evM!aJx59f<5JL7G+PE%z)T=w zUHhojF%1N58Di-Pgvcl#VQb;~ZO1ztMg1qoNIh#jGu^0ip?=KIJ{bt#Y*jS+k=iYO z`LFios61^Sk5fF9V_pPmN6poDqhHVMU1+1!>*sB!3HF)ItSbiXO|3y_Cu8{t9N)^Q z{wm1yKxR7!R-_TFO0$LUi)N>#0n-^zUFq@v^mv7K^$6voa{YV75pU9dPOR`nXYP=1XR z`$rOH2iy3xK-0LTPR#2eueIM^z4hOW6V_0?w`@L)zpyGo&S z-}ZHUf-Lj>D3v|r^eKpBmW?oxzV>_ElK(A~qw9rTtlW~AJ~BP&5t+7WgX9t$TT#VS|5@!U(UelzCkqRG>n#Ttis4|njQo{nOBAA8O-fgqWpnt zu1*BqUqrJTGBK?&7wdHalTLk|QDnKH^wF+2An1|EzcTBo#Q!FlcSoKA=T&X{n~&u9 zemInvvXIBp%Z1k!K9$qZ{0j4$3Dzm%D{Ws)8Xb$y>-$`Nu6mc>Yc5E;fW7^)ujc&9 zdsh<>l5vOXN&#wJrUD6T_KZwzJg=zwT#)jQGY3tun27)E`$+(G_4yH3b2B>vdw`Kz zrie#02SI<>x$-=>sXJ zO{8CGraAifXh&f#?@MbW4=&|VH2aqPVD}_(sRkoZY-P22?ynrazYST-W}JDfF8=U6 zOD^1OdIEMvs7=oD3IA@gQ(4(HfZZG4t_cD691bym+Q0~eZn7LThUR#-211v2a&Z;4dn9wuEByOwk74jH?#s}lBb4ulo<>IR$G2|y zEqP1L=HW|JsWTJsZo#-}v_K2G!AKU=W_>zL$12?_(<)m|iRmwbY7cg^?AZ4E=aaNh zo=had6=hQwiD`Cqh*CYfU5aavWCZ6cD&F5E8VU!(;sL~d&$Ey(;^pVD1e!>P#Rx3* zfn1c7NC_<$nJZ! zi8OF*8oP=Ke!+FYryFuXQO7akVb)al=>1wpbU&H(dq2SA@oQsZNO%q~(0RYaZBj?% zni#NwE4VzQv-#6miJQA` zL1`&_iQ0^`lYE{!pK6lm6OO{q&||JkT(`Ku2QrbB3bKBK8c6DYZb&=Z2JdJF{i`w6 z)#`ph(Ie)FIElNLy%pL-`24n&v({ERW9X;?9|D+I{}?GiYBL9ng#nfV`(S>xihZjQ z6{n-_9$ZPyGp&{jJ!=FvhWP%4nIZP!{(CFXsR{T_f7oWF>7Ocj7HkzL)$*77buEoP)w)2q#7p-A95+9p0ME}b z^n&>xeB;eAsP-Hc2nZ?A%eeGU?l_vrU1o3F%O;o&PP7|*aOKrj$ETC|0JlH57!+t| zkj-6f+v0oZP9kp44Rk4UrcQO|9I0x{h0mEE#K!3K&yuts{WT%V z0&y;N_Wd}qfTC)8+pX=y0(or2~hq|Q?_KROJz zp&+*%E_r20Jz|`g>l|<3`m5+2+S_)ZOcNR^h;42Bh{ryAI^I^T&@_Q zY4>gwqkqyN(=Sw1J45XSC`RdSWQL|oQ^VHi#gNU4){9alrx1d|AI>*Ffslq2mHg$0NxSf)UF^_wd|redCZoBLvSP-kfZjN`%mw z6PXa7&kqGzb}nlY8+)9bOc-TUW&>852q}EYWSe-fGRJ(^#AC3R0n7`}9lN1*h#tSE z`-vUDKFIz9nOjdX8<8C|B~8f=D2DG9e(S=;BjlU2Dj9MjBKKNtva7g(cG=wmyZ%?{Zv$rzfFOmd>HuKxV1)x$;= zg{bH0BfAdt+4Yag>J%F}HkKuIZhjHEcelVx;%giH8 z{%0c*es%p9pjF&Y{|y%^8RYyjm}%6dB>J12wKqp?geYemLZ>uJtbRw=qrPgd`%pbf zZ5fNCu6*fok^%?JPzkxJn&noM<`f0wI)jIV6S^vmpe6Z<2=vV1riR&J<|k zrBbqxQtYY2w^CSHsXuQ*or=@{@rx;pGN-7E(VamckrgY6Xh3Qx3%d%69c*Z)A(FqrOgGqKU-W7z>B^0P zOr+pLp6tJA?P8kF>0`AqVE*f`qrWRVPwIys2^LD4RgP-^9jE6sD z3AW9zBYrfyu6h!!@U86+_|q9x_u{P~mOw;uk=ErP#S{T(`@TPM;j#)%Jr+I@)x%|6 zJW;xl52SRf@Rwij3q2c!VB}FV-@KD1DEUtdV9}34yH-yxV%LK#Lcu}WE#STJ8w~^a zijGKFel6ac%!;5xQ^zStm>=MwB}|k9aAO_|k3aM#EjuxtXvWMlyU@z}I*!EjA>iXa zq$ejLc0)e$Q!xh~4EpaEhbS+Yb7i=f(>$ATZv5&PKb-*@o-$f>6#nUQ6JKf)xb zIFeVxyRxgvEh>oU-hug|>d{5kV-&T%B!C7a6WPB2t}Ys8#K@lIugkC;iwQy9=^n~`cumsz2KO?BCsKKIn(>(9&?+R$m65dWPj(g0sk zf&wOaW-)?VXtfGi_WASA6_@~49}0G1A8Aj<$)MKk=)!V7qIbMk0#W*qKSgYnc1dJ9 z5-VSZEvUy*fpPcUljwC5dQmVxa_#C|=c2FYcwJ4A$T4D!HGnKYL&rXKY5qqpxT570 z9XHP>1(?)?sf4QcFOs2uQ>lY}b?Ck5lbrBzb6cFEmu4yUxIRWDe$2p6XriI!!PCuj z(PH=kHqWEzz0i!9V;f*5Vc#0FgLN!4OAi|$nvlqUtq`XF}P_F zHlB-_hwq)#YnH{A@`ZVbIBoA{-vxxp49YkHqD%e$Cy(>&y7&4>R*W9cxwxDU-M=)k zz$RUa<7+W0M#fpdk4^V7pp}fGycki1;o7aglW9R)A6MLkM6(0P6c!Vu7{n)kYGJD?SU2TT>=( zV=3IM%##xZgC|Z`WPDAJ#xAD3U@iyfOno-bE1=b|*X-|n0W+QN1|M&hi8)Dd`}8p; zwNN#sTpX>Ua&k0@BdsIz$M(btBhb_|G$I-r^asc0g8`TLKluDgP~iC4)f~6ft@7N< z|Lf$qSO`VY4oasz%;xP;lSVsg$pX`f!<{oes7&8aJ6T2vmp?wcP+A>6=&_ZN0SCop zM;G_cR!)d)KW5uN0G=gVsfdS119_{286S7;)COLgC zL6sDpnOMFZqYAB>TK|QR$UOL4;go7muaG7_-3E%CHF%n+wZW9O>nUorxEL=;_R}bF z^}gvo_TBDVk|%X{c0w>2u;59?MNe%V4dfT!4&h+uPj@o^1)ByI^j}<&IPu=J6C&yJ z*$MC=`*yRKtNq)ItEaDLZ+URVO?&&l!9$APiS~5}2bIjP7poUN4z=Z=XuS+Uvjpfx zJ%SMncGoB2%iAkuzxTh4`7O?AC+4M9XI~uH{RKGe-n~rn-=z}XDhIAAfzp(~+oK@5 z6Yu^AJh#wd*5drCx5jjE1~-KW9M8K0NB<{66tb@ZVkAb~O|-eI@mb1j)_FZiu|(J1 zn&pnixvP+@n3w=Vj|G=}mR8yC3*W~)P`cp@P~1Cf`8jweg$TAo?#L=0mE8;&PM(;O zl@)heKxO=Qn2-4%=Rf08an7CXGI5cczaq2i!oi_UM|p1Ak@M|x*qYYi!uHEiX(Knm z(8)Vv&dxy8nn&I2f2+aJrj zO*IkyLf=lm=eU3!Csqrog>01pA1vHD()V#ym$bUF=wt)NPy&Fe>h?cac#zrYtnH(_dKF2mcpB zE=0sUJnyQt2=Tn^3HY|vYq@U1jTQJRkNDgfP@&w`75zwdz2Q&z?{O}o-tt=J!Hw(I z=lVB-P1c+HNkylQjJq?wqzF3@i1v6L_@bFGK2lPxXLt{E>DFeynutVKjs)$vivLUd zL>zP9jT^?q#%M8fc2=yQWe&cZ^}dUeJ#fzn4icCd5qm^&aJ&w;*jShIHIMsZZu{Dd zP(i3hd5xzirv!8NIDE=_a(0$lzH&-gyibF_yK?JuQ`FZA_)p(pYDeW8XKo7Ss}6ZV z^STSGOW7hHfyeB1mIvJTOlH-gXqk+qLRd2WBRdFq)s^OdO*@4ogK|G{ihE~`VS6Aq zN=Xm0GNR_gC~)q33hK%&4&q8jhl6bV?WS)4HQchZAN1vxQ<9V(Zs#$f?Dux)65X;? zDG$?NH{8Pu$$Lgzoj(brxYd@Ut2q>djQ`RKT2cdA>$if^Gis}xPa=WdAe0hI6JYbV z-gFJPznbr`Fsm6AjHXA!a1|2&*-l|rPSN6jPE3#CLF%KO5ca%E3wmmW>@ni zpHWkWZqa-Pbe@(z0zoZYn1GM}%!**Xo}8k%zVM{Pum(o(Y3!`*xyW3|9T;uTYBba$ zUSN8$=;Slj0~qoGQyrMZL9FUD{-%t$fL1Wnu&)q|&&%q>it06D0|J66#g49o#g4iy zk;zaIjhTRyngSe?|LA@BvVX2IFf-!sU`%hTsVYYQp)h(S52ExnpY^1c?1h7y(fE;O z&AoqeD0)Ls-^e8paBf2k^g*x`yX5Q% zx@vqmLeHX#!vo}+)`HebKT!qnicP4Ya3d;DC9|1f%}Vn167;FjE*|1OI(KPC}aE- zv(l5+&uO3aKW?2ryiGZxt?0MP|7Ld`e>!1df6BXi^Pk$h&-J~cv#^9UkED|uliH@q z>kch21bl=YtG;!f4;JBvy}EzNJ52$pH~Bd4j?D(qT!U71qYgGrNU?!h<=^WpfBD5D z-6Gu_F9_~;q;y|n36Lxqn|H94E01U|&x=gy5&6KT$BKYBJ?ittfm+no32ji_eojC? z7)VC+ol2T_zZDP3zs_W}tCUREvDeG0L@2)&Q&}_Hr=B7IV&w|IV|Ist%HRl>SJbFc z(T$R$JlCtkZho$yz;@Rn~U7Xn{Me)q)&6-Ah>?pGK(-5KWKYe?jYE+;lC+q2BcW;pQ@YjRtu%hs{vA zNtc%~sn%>}1_Pj{G;;Br7v|oQlS<%Fc6BXBU|jemLp@8foc=Tfy15&*ShZXHHE=UE z0}Nfc(rIZssqRAy_Yl3ulX*}XN89O3ywT1FdM%dlTBZOEy1Jm;nu~(y6F9-ZgI_{K z8bO*#GDYw2@>v2@39{73bAng8-;J>3A%bwxjO>Z96)}t&zRwXiFz(h{JtF{KsS|T@ zL|l&*;5)TqzkbcGC~M5b&*qbU+`lGETI1H8P}j=%MwRSR*+!X7_$aE>#=^w(rCItX z8KAmmU{X*4B$E~@Til0-g0LM2rh|Q+V5fmLP)yH0tJ1A3D+@cb(H%Ry!-bUq=9QID zs;b-q>GLuaprN(R*zz~L7=!hGU;;VhNegPkFNGCA_DSC57+>L$OG6#Qsd_za)yi>z z2Jaosze|(PY;-DXYg2V}+MPV#Us8>K=|bs`Lx01lQ0`P!{et8>+3Spk8wFu|ezRW= ze={EAlHJ`?(kdpA!zaK7o>hhLkt&$Hyr)6StA<&U#J|$<3HU1t3v=<$*DJefvfp96 z{r08nRU8ap4iBR>MXAj#O7GIj;7B0=6c`oYZVNc$fINaTX?u{IjTZq$%=deu;Y#${ zGy&VSerKnKjO>@D9o6T+R_2vs(mcXBfwe zk5fV=mXvB~ZN<5XlXV<_(6)ylzO-Z__=MxP_!pyOG^@W_Xkx=O=fpbMUW8A z-=~g>x@{T7*|q(+kP4SR)>kAdzPHlE^-3iv~IfF*|q`Ffs=M;6sfz zqg+^8{K8{1-egk9K`Pg5+8oM*0`MVCWPqVDFQ-xSaUs_p^3x1G`C>FYk(J5r3q+e? zhg?!#nw#|_bVgf>8WqdsevO(Eyd}COEsz2TjBtt~f%eknV%dn%&V^A2on#Yp>m+Wy0%f!j%HQH9y{q^o1r}9 z-K(FE3#&!<4T4S-3IP>dUgr}t01h_}L@7rSdw8D#5C}RzW|Uz10r?s|TomcrPSG3u zgK{v$(4k?lL~R3|-y7xX*wxYfxLyq9wn;w%O)sKtza{E>3z|r8FHo<#(DX(4XnL+R z2J!XV(}t9(Imnf?z!ShgSt$b_beyJLDFdm(6&1#e7LQ?s{VpE1defwD0ew5PXzT{4 z&IK2D#(koxVvw^P{MU<v!E$gvIqeWmQ!@E^QFLF{}QErg!)N|>-LX_QRygWBw(y8*uzAh=6ZkwYk)mB zB*xJGbJHOIDv#j)UN8G5HPCM*>}D^sR;AAGnTP>$R*KreU<73!;pS%y!efkk;ZHMd z;%>Lg72bUYINV4GMAhJ2!zIu%!zja`pXUWUT%%TpHnuAQ2U?q4=C#es2=w-0?=y?$0WX9;44xN( z%zE%M6bon=zy%qR_a*lx3m8*BpJ|9?C@cDFubMc|GtWl zN&^aQ%%lPDHrBIbtgB9ziLIpg|5qGt2xw*)BpJSAFAbP@$ufiAoFRjdRR2E>|GHdfxCWtvl;O3gNX(^)QV#5? zntsMi{<6%NRfwW{VZpRabW_ZH8a)bh89y_oCli-mq^HTC8x21xP!)o!ZXOsC#6 z4r_Q`D*7jfgV>i=qoenHs4R$VL-%JlGn#WFJtj_{w0EFy$9EJ+cu)r4Z6oWll*}uG(nGb&@_;DxC_nR?C1t1{;TNk|h)+->{1}#;zFI;1$Vn6bhK^MB zV=MA4%a}vi10{bcHI=HY4gG3zV`Nfe<+h4E;}JRjP5v5#skE3MWDpu$J#{$RhuK0s zP%Dj6CF6Z(Oz#E~2!4+P**bnfcI-oHY(q^PNQzT6gIDL41JL~TU+Pz zn9@yZ)6I4tHCZ$G#wsY;n@P+S>+)@$VGF#_7o(ORd*fBZPPzU}{UP_&f%ImxP;K(Z z<~84-J3nAk*+9Nkvq!Ww;lYcb>eWl6%rr-)W=kdIq|QbooboV zEJk)DScSpMSfLdUY5aUCqYB^5$#u*86s6h4GhD~E=WsFRjhB+CPYd+sT3kJj zthKd6@&p5>h3m1yV@NN)a*bVm+9o6uqd~6^FcO|NE07x27B5U)F3h`y#xJx;es_vU zmctA)QB-qNLNuY38{A%t@vFFTnp+LdWo0z*$|2O~rf1Bz*3(Mun| z-odG5w9&0^c4&DXdJb_yl;X2TuaEpO^i9z@eZ4W}jKId)6Y*LVFW(3!dz}K*YXpc= z`LfezesjDZyg?4s?zEy%7?UXr?vFaibi)3=K1ErFn;U0@1TbN~US!tO<#9VOgsY8U z?9~fr$V~rn5e5+fp=Yb2U_$Z7L^m-?{)68;a7o^>fr;V#EP*IOQ~ zj2|M&vSo?0@{fZa=Jo*cCoMGT z#V7i_P30dP($4-c7%b?}^F?eUOR=Q||8HwkWcghNHXkgUcY zQ9+lgtO+n2^$W|7(^Kg|@Y0k9@j@j}-sNmxzBta35jh*=kbo73oA-?`%S=hOT=QF| zoNj|PwUTxm_>6)Me-!(qPiCKSZ+Q$cY>=h0eI>a6mLJqRlrqcHQLEi*R?Z3G+{Z2I z81Ju9d27E7$8jl!Mt*Z0`pX+2lmE8QEaOl;uLGhm-~RtJ_>I9{tu_MUFt1Y z=vjKdL!;kEU^>F4t0+hp370!Zk<|`Ro*iWFVjNB^CAc5-e`JRerckn|#9BORRu|?< zWk+eg%*yd>tSEi#dAk=W{Mb(P=`+z1e%1l^$9Pcs@PomN`-C-dGoAd z^K>`sj+20Za84!oV@z}-u0e~^o;HEGT-?}Fi57`G*zMb__%3mN`m?dR(b6x!6=aWg zFodZ+mjBMZB(oN&%hc*j#?(P~Q$L`sBoJZk-I@#`x-BgTu#VmHt6OH4!7jV)gdCXH zsQp17av(AIU05$U!E0EUVmwMC0PxN;Rrhd5-i9!I(L3 zsaJ!$&2tYdOrlUyE$o@wX{iQ0g$+YDE0U>Uc0gJ9Qfj|;X%(K>gxQI6k)dVmIF_aS z`TmfJt6!PUA9TC1t+C43Ug)2JDnLw|n}2L;wBA6x=`$8MZ60AWrrHqN&CwLvP92cH zCjYzrujXWY!!Vt7zKLhA-9pt6^!m5YXBaAE2?J9Mr^FZwjvm=X0^;%-SbAiNZuz0h zRO`*4#|FsV>MQsg&K(p}CF`1?+gGzSRRfvt=OpEL$jlg@mlT8X5Vi!iIZ0IX^t~g< z?84P0L8G>2fIX7}>&^4Ardy#ktyYS9k@r#7zy1$3oA&K~!!_4BYE%5g+r7)6AN`;V zxlZSi+TQJd4_C-q_Iwtpb)Buw{Iyna)u>(70r|Kt{JOKTKUQS`LwQgOzpmZ+v=S@} zkPeSxqEGf#GihFSLY(2M*S8gw94a`$50iTFmD45@Hq{m2pI4(#?In@}DXl-NzCl|` zv_Bj;>0C<%1lQ+5&wC}p0>1!2dUPDS@x0ivP+v2j3FnNb=| zV{|lxuyf`?l61hG1eq6VzDU-F;*beKe;VqiX6ia1{5rFdPV>sDtX&aFRwb><$92E` zh))VvwxDnx&+02R(FaE9SgyFzo-R1LY+P%;Usgb|r~-~9J=3ndd~lHRAtF`{(lj@3 zwIfL9^B=7}jv5-;F!c_47a*agj&*_drXlL&i9u<6_kU6|dt0Fvi!Qw~EFSz=T!4hg zNuY&5z472!Vw2_y_#4D!?3Sp2`(v?Y*jeZxv&wYp7{mH}zveW)x7i`=i*L>qmHA*a zWk(ShX39yt%(~3kzXEK#TzS6rH9=1hR%JW&%zIeDBu0l;&7^!KT8 z*1;OOANkNF_*t(cdmg*BYsON_M)|D{y243JU z0C~|3Wc&hqM@&L|GTy zW28r|FNUX1^FAK^o6>nNy4Gk*l4nd0k(W;0`8FwQBlXk;;8^?t4dyeaTV@pV0(zA^ zl;!%;I=_XaT-G5nW9*l>_}A*I{N*_VbndOeyYCtNn^yTNHjt@wx-IsXMDSb0c?ZWu z*sHwbrTe_TBO%3>Etcpyunpxm({Dv|avGa(Z_oxEG zpjya$D_?{1%(%M9AySqsKU(d|&o&EWLHsyJR`i{8oWr|{C}GI~NXDZ~O3&F)|Ae{W z%Z`~t!b5~=8`rtqp9w~N;k2&tl8>FEFv~>CWWjepFTQ1(bx&2r7t<65)079&{=j#= zCK#gVsgVpgmh3CiQ_-lwcl?kDv1=8Vz*pSMpZ#u`TKWi!L`?1a90zSN(O9>m;8}>U zkXgYGzH6_jtx8NOkrV=^cTccOf;O~^wSJnISMh2PEvw)e5ca2V7X zZEF6?wm70uiD;pnyJ*hdGV`(eX5FXT;6iHQ*?XZZX0kV6;*d%vMKF2cmHbEOpP@iP z>|&~*7%vnt$>zNxRaaw;%sa}UYaKe;&|kD=_+{i}^kpotg7DDp_pmCp_q*QeXDqH( zwpOvpsqe%UXth%C=auKw@4aQN*#~`0IhuWKY*eQVy$=R^>}j1r1_&S)+KTSBlrO>S z$wd@hic#EYJ_w;xvm9Pv@+6Nl^`Q^at^scve~+uGy*2!ZdO=n5OkmarUYkvtMP|-_ zylz^XK+e-t@hKwEUXJL*lNi0nqsjEtO5l%6WEPQ$9axjd zl#?fof+bPf@b?(3M1JcRRI zyMla=?yCxszYjy1y+KarqD2Ec;@p_)QcvU(!}n|ypmhsaMd@@=>6;@h{))}W1n~RC zknO=-v(Dthe?)f_70o7_ygu6F4^Lz-Hc(V#gEyZ7{25g%#GTO1&K)yhf`cAHL_##n48<^ z+Tw6ll5?bJf>M_6SIU~gZxu!w;>>J?qZk9-luu)UKk?ZRO-+btOWo7~NQQW*BgHC5 zz=V~M`+3WXQdmS-i4aGI`<&c#X~gNVQs(F&9k?GDD+Zh5$Y+yqe z&Kffqnlu^y@MAMu?$P&NG`AskYi>Y)G8sUm&~i^}${>sR>H3m(%igG)5@WDmJou9h zezGve*&Ssl=4(>Kv)a47y+4foVAAR8%Jjlw1NwvCAh1C14#HOLxyW3$m^`n#>U8L_`~6*L@uuT+-_NXDI#Yy)dKKk{fWR}Bkq}k){q6gjw3_*V zsx}{Wj7h+Hv$3f}&v)AOaj8BV>tI2l59Mu*A}jc5xnMjspR-4iG;(A_K#gqm=*C_LplUPYNODz!-m)C!*ME#FU%jx1G z$Z&GgVU2vr_6U9Q(BwO7o<^vVJm3WnKE-RRNBNfw|2cgWGR_~3H0+$O*xQkBVLuTd zkK{c28i()ASAWSc%>BgAmV8T=Ep)iE_Ybbp@s*4T-iM)Pk+FO3ozFPJDxYz4POtuJ zIs7jFRNvOI`F$P(ZBy0`)oSPGo7*yt1y%B4r!Gf>Rs@=%aOMO~wNA>@>1p2JzsRb= zt3OokO0P2iyf0XRhUCo5nT^NJ)lOIfY~mx$8B|$m$X`|mb((9nKTd!`aEui5ujo73y84(hM?}V!-Yza3QrQ__HxpF4U+Zp9poT5j55_A zTWUZeCaa3mXR11Pf#vF|vm+oSmR=-{Cj*I80H^YFMEy&>J# zzdRocf;)w^kRg%$tL!ZiaIbpQXfbk%W9 zeQ$UhqXiLEq(%);S~>(#5D7u)W;6;iT2dGwT>?s%pmf*hl5o-><>(%k03WUh+E{ z;mcQH#Cc1pZ6lW(iJmv3h4A%4Ia=tUj;NOMHUhUqZcl8lWK4Fw>V*!u;F;OrUEgOj zgpQP>%ujOEcBYlAeY)JNpnpq+0bwMsXIo-9-TkRGV4t`W#h52!hP8Txf;R{~yB}k0 z@2B`H#otT_C7r(XmU>agndpwH!q+V2CZUwCjpU~<8>f!%)&z*Z-n7Zh^e9SK=vLS$G%<4QPrd_H z=N7Awn=WI1s7S~d!n#ETMix)YM$~Z72=;L6OHON5L4}!vS#+4nipFS@01gFzWmo%kW`MuP*<7HtAyq z2>mDB)u$DzT9E$3^2W*q_DZAC3-)8`!%tCPhqRjXvti)BC0&+AsMOi)2ft&&VDh-# zhh-FGwhX_vM7Y}fEq9_io4QUjBX|cN&>k zl|&V{-)S^+$G_A?rOa(S2_A|-$oZ3oT4}6?)~vpimr#x)`m2~67}69wG1gVq7Wy&a zj^}}hy_|lyPQ20>r2iUuB$*#SH`{0DZBiEjo$XUO4jqs%K3Ll8q1Rp>=~#+(0sK2&^$sur zCx#&h8T1LRd%V-jghVjNZ@VYwz=AGXx;KzNJYm*HH(^M?g6&`Fz90~U#?1QY(V#~x z36Z#lQf8i*#KCn%6y52@XeV(OwrGUg%mPaUxm=WntdK%`Sq|u~Q*N7iOlpY7MQA1eSQHwkts?cP^6|FUN?joYMv! zn&$M^)KOU{uinIt!};#C`a7AN_N_mm^sx;JZQKxj)!E9XzGn2Z<^-;7Q-z!pJNQ(Atg`TZNIM5w$3VPHy2tb{ z&VXA3OIO6wut;}ISsfH! zejpJ-Wmfjt1J>Ng`{}*g^2ok* zo!^R7y~y18Sx!#eYjp4|>Dt20zXTA2N=AOr_PsQ*XTF-&Dyrs-AfnTJ$KP;L*>*7b z!Gz{X**E&X;sTwT@b1;-KXVCf=jQK6+-U~hL;7ovO0#BQ!s;mhGaVQOL}AmIkb#LFRdy|DnV8(IoP|8!!dsaPHSS?jmnmUYYJaRywl z2Gm!@`_^^DpV(X%BvZtRNLd@u}Q`*bN%QY1AST~%==lW^n!WqcUmR8k*(@}%=Dmn{(rarnOy-gljxF0 zwkN~HxPy9bOD*Bj-6kVb+Vtj1eTFm^LK2FPPcue{?`q%DSznzHk%h8UT_-+=1g{A^t zK(Gn*f=Too!ew<@5rLKb@x7a+gerWG@UWvwdUX$$$jbQyPYzVWOkgCrm+GT1$2A>t zFlB}T3!x)RhQ9Bhs?|biO{YSr6(F293(HSgBtZk;k09y2|2i0BA0KGibWeEb4YV$E zwxQAMoA9&YjPj(2U}j*nCH4D$G0DQ?^QJkUC0cY0-3_i@IB4V!&2uL%ffk4L&VWWH zffEy%<3}`%LS6Vp(p_+ZOn1@?&qGmrW44o4p&Izu z0B9fsBEPr=2AD$5Vx)n#_4mTKb&=4s+#Wg9t&(~|kLKig4YWTxzuG%6aGH$=KRiqt zY=7D{5!E^EsyBOv3#n(wr6FKxC)0xqOfAk`0H<{Ibxf1lMsNMiIQ-D9Y2hR6ir@Nc z-ilAWek2uD51y|amil{nMpq+yzbt|7WPJ|24yZ}b<}VWO{a)?}X|kzZL_N93cLs@EzhmVMU+vRY@RYiz%iw++C4 z<-C`{d-m?*_VnamF3Wz4-)m!MkL%dz*b;7#%AuZi)x{L?c&Fd7)XQolpA{~zO-KUx19eCJ zw%&vPyj6A=XR$MU#I_*`uvdSLtO+ny3Ndg~$;Cbc+P|D@wE4ZP8vu^slzXX5OKHsh zJqB%?Qq!x7CU;eCrvS)|QbdVsIo<5{LCz+i=L8KI8DnL^BBvv^50Ioumz%%mT|{?G zx3^!Zz{G{v@UWAELV52sv>Y}!%p=au?B`=y7~nc65L6!=refth?$AdgMZ`TJ$2*}R^6bzI=}7@dpV_}B1^mR%1S|k4A{^^ z53WpfeSF#cW%JUrZwUWoyT^eFk!d~e<+~W2e{d$MXL*91+X?yL%+3zM#}Nr6@y9vL zXD+>OQr7LF7nEZPp)xy|7*OtO)i$&l5cIGAK3>1;GQImKBLg0UkL4gzCC=j4L(;fy z%K1rJ-?~4HLkq6QC9;s0{o{IExrT0gj+rM<9e*!=%p6QD(O_SM0?fursy`Nq<&_*O zH$EDs@E3=JD5NOB6J}pb&Lex1BpHt8Sel3{646AP*Exew@ld!?;C!6NituJ;y?b2z z30wIZ4GQ!$%UN3``D)X4rCzFdqTYyyQ}GMP3Q#-S8&XHARlL2<@q6-tuYAKsbZ|ia z4HPIVM8Sxdzx4ofMqJGp|u<$70r0h1q808e!neQeVAvRWZZ~fTojBeP2FMyi%fJl zk1CX?`XBsY)6?qComzQ8Gfv3+Cv(pCwwa z#~WQ%{Ftq|^3E!rWHjYhA_Ua94YBd58cLKtn?Aa9QZ0l}xavllRIs}M;r@-TNGQ~C zZcmfHRnJBv$3Rt(464Y*lEPcX=P_aPJ-y!SqGeJf8b)JVqXMF+*0twX zYVzXX^Dz3Qnpqc-!;*mC6C#&Rs$ZQq@w1B4B*bhp`)3zeTp& zdUkidvcYnlI?buVM8OGs7{`H(3(b{a!pTzo`eEA5eGOAzJVd_Alzs7A9sCtcu&pCd zpLX3C?o&CYY0N_q%#ez*s_d&vH(;O^QuW)PkRji^M)4IjD37AhU*|HuJe(DHzAvWT zwq_^k8L;JwGn4=m?Xpl=L*l17vDLQ7n&s!`drck)6S`kZ?^{$zvqyAZb$5-H^!Xi~ zU(EVe0HW~Vsrof|x7zmR`C7(VnT(al)#C08z}lZ4T)Wt=!e(6r zHnl7g>TQ1VN^Z1TQw0z5JVWK*oaXAEvhM4@!bo2?ZhN}86_=q1m_yXic|_!_YQEJs zraE^?(xY!t5`N|stw1BTKOqjr@M5d-lca_6OpgYG^pe)G2A;IsV3D#<)WR>$Puje8 zARc<#T04mm!Xu&%*%6_|H)u%QL~WnX+N<(3z8a99bcm9Wx5`!p&}pJuT%~TZ=lJ~j z=I(~)_gU{{w)<~M`;53FX&ctVP|51c)oRIgtoQkFdEz^5dtougK|C5PAaD#syT!(< zo1i@Znzo+oBJ$xW0m47cq};DlfQsZJO7ec6bzl0vN@tgHn z9sA9G?!Uz)qom7F^zXIOxw6KnFd=yhs$oH}s-bx($J_6?O_;!i0B~E|VnkHJI@N+i zz@%zr(z(m}^_JO#E8MGxhHQO)#?QSA^^fm!gO!Ca6aDve5`P5x`|nncwnfw5;KHxG zUJCYt-;Dux7XPVR9Xg;8ky%?WXl!5Is)8nwRm#ap1E>`#7tOWGm1I3a+cUSkDSpEn z`o)d|1lF}5c=?0h)UojlHz6(45*;bS^zo#gXX53P&MiOu6wR+-B z5nShscz{TiQ@`1h1hjX;W*x3KNEf#*?(SAlN`?!tWLrS;x~|3YwJZ}D*-bcBIE6&; zsNnJ*p+rps3Vl=R;9 zGB3A1pHvSL%C!h!KE3h6hqn9|zs8czW-`Q2PJgzt?ZgNRqE!9eOA4M^uzalV+Xmit z5W|jU8J-BlU7 z+#wH*<_Ndy7pDyUvN0N_w#|o`ew+BYB*<#WSC|8{x4dyy=Af04AxHxJ7Y_nc-nf%v zAJqQq>QFbtHhZPl&*SP>Yd}nObBh{{)euUVVS)jxf=OLK!&mE4X@uUSz7BUZqKQEG zo?Y&`-AN;Bo@57;RKV%XPFstkDu6c`l+hL zo7xZW8>j754Jua_8RojUwVeyeZqi!M}d{U?YtT~G~0r0pVwAbs? zBwvMkY_%4hp7_Q7W~eG&Wo#>UEb`B0r!nFU(WPm2&$`E&J8Sj4=>66`YtCXEfva*y zswmocg^Z7@iwJxHWVcN;axo&pH7u+<^4`)|Lq^~nmKT-c ({ + 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 `