From 493cb58222e23563c5a0da50b870fdad9fe8f25c Mon Sep 17 00:00:00 2001 From: NORBOYEVSAMARIDDIN Date: Sat, 7 Feb 2026 11:18:38 +0500 Subject: [PATCH] first commit --- .cruft.json | 28 + .dockerignore | 2 + .env.example | 75 ++ .flake8 | 3 + .gitignore | 158 ++++ Makefile | 46 ++ README.MD | 246 ++++++ SECURITY.md | 79 ++ config/__init__.py | 3 + config/asgi.py | 12 + config/celery.py | 16 + config/conf/__init__.py | 11 + config/conf/apps.py | 11 + config/conf/cache.py | 12 + config/conf/celery.py | 7 + config/conf/channels.py | 12 + config/conf/ckeditor.py | 147 ++++ config/conf/cron.py | 0 config/conf/jwt.py | 36 + config/conf/logs.py | 60 ++ config/conf/modules.py | 1 + config/conf/navigation.py | 31 + config/conf/rest_framework.py | 9 + config/conf/spectacular.py | 31 + config/conf/storage.py | 23 + config/conf/unfold.py | 95 +++ config/env.py | 29 + config/settings/__init__.py | 0 config/settings/common.py | 167 ++++ config/settings/local.py | 11 + config/settings/production.py | 6 + config/settings/test.py | 15 + config/urls.py | 36 + config/wsgi.py | 8 + core/__init__.py | 0 core/apps/__init__.py | 0 core/apps/accounts/__init__.py | 0 core/apps/accounts/admin.py | 42 + core/apps/accounts/apps.py | 6 + core/apps/accounts/choices.py | 9 + core/apps/accounts/forms.py | 37 + core/apps/accounts/managers.py | 22 + core/apps/accounts/migrations/0001_initial.py | 43 + core/apps/accounts/migrations/0002_initial.py | 28 + .../migrations/0003_user_warehouse.py | 20 + core/apps/accounts/migrations/__init__.py | 0 core/apps/accounts/models.py | 43 + core/apps/accounts/templates/auth/login.html | 147 ++++ core/apps/accounts/tests.py | 3 + core/apps/accounts/urls.py | 8 + core/apps/accounts/views.py | 43 + core/apps/logs/.gitignore | 2 + core/apps/management/__init__.py | 0 core/apps/management/admin.py | 85 ++ core/apps/management/apps.py | 6 + .../apps/management/choice/ToyMovementType.py | 5 + core/apps/management/choice/__init__.py | 1 + core/apps/management/decorators.py | 16 + core/apps/management/forms/DeviceForm.py | 19 + core/apps/management/forms/ExpenseForm.py | 62 ++ core/apps/management/forms/IncomeForm.py | 32 + core/apps/management/forms/RentForm.py | 11 + .../forms/ToyMovementEmployeeForm.py | 21 + core/apps/management/forms/ToyMovementForm.py | 30 + core/apps/management/forms/UserCreateForm.py | 78 ++ core/apps/management/forms/WarehouseForm.py | 7 + core/apps/management/forms/__init__.py | 8 + .../management/forms/user/BaseUserForm.py | 50 ++ .../forms/user/UserCreateFormBusinessman.py | 40 + .../user/UserCreateFormManagerToEmployee.py | 48 ++ .../forms/user/UserEditFormBusinessman.py | 31 + .../user/UserEditFormManagerToEmployee.py | 38 + core/apps/management/forms/user/__init__.py | 5 + .../management/migrations/0001_initial.py | 79 ++ .../0002_expensetype_expense_income.py | 46 ++ .../0003_load_uzbekistan_regions_districts.py | 125 +++ .../migrations/0004_alter_device_name.py | 18 + ...ter_toymovement_from_warehouse_and_more.py | 24 + .../0006_remove_device_warehouse.py | 17 + ...expense_expense_type_delete_expensetype.py | 21 + .../0008_alter_expense_expense_type.py | 18 + .../0009_alter_expense_expense_type.py | 18 + ...xpense_device_expense_employee_and_more.py | 31 + .../0011_alter_expense_confirmed_by.py | 21 + ..._reported_by_income_created_by_and_more.py | 41 + ...ress_remove_device_monthly_fee_and_more.py | 44 + .../migrations/0014_alter_income_amount.py | 18 + .../0015_remove_income_confirmed_by.py | 17 + core/apps/management/migrations/__init__.py | 0 core/apps/management/models/__init__.py | 8 + core/apps/management/models/device.py | 10 + core/apps/management/models/district.py | 16 + core/apps/management/models/expense.py | 47 ++ core/apps/management/models/income.py | 10 + core/apps/management/models/region.py | 7 + core/apps/management/models/rent.py | 15 + core/apps/management/models/toyMovement.py | 23 + core/apps/management/models/warehouse.py | 15 + core/apps/management/templates/base.html | 239 ++++++ .../businessman/businessman_dashboard.html | 117 +++ .../common/create/device_create.html | 147 ++++ .../common/create/expense_create.html | 165 ++++ .../common/create/income_create.html | 150 ++++ .../common/create/toy_movement_create.html | 196 +++++ .../templates/common/create/user_create.html | 184 +++++ .../common/create/warehouse_create.html | 10 + .../templates/common/edit/device_edit.html | 147 ++++ .../templates/common/edit/expense_edit.html | 139 ++++ .../templates/common/edit/income_edit.html | 98 +++ .../common/edit/toy_movement_edit.html | 142 ++++ .../templates/common/edit/user_edit.html | 183 +++++ .../templates/common/edit/warehouse_edit.html | 10 + .../management/templates/common/list.html | 33 + .../templates/common/lists/device_list.html | 24 + .../templates/common/lists/expense_list.html | 36 + .../templates/common/lists/income_list.html | 92 +++ .../templates/common/lists/rent_list.html | 10 + .../common/lists/toy_movement_list.html | 34 + .../templates/common/lists/user_list.html | 28 + .../common/lists/warehouse_list.html | 25 + .../employee/employee_dashboard.html | 105 +++ .../templates/manager/manager_dashboard.html | 112 +++ core/apps/management/templatetags/__init__.py | 1 + .../management/templatetags/custom_filters.py | 16 + core/apps/management/tests.py | 3 + core/apps/management/translations/__init__.py | 1 + .../translations/field_translations.py | 14 + core/apps/management/urls.py | 39 + core/apps/management/views/__init__.py | 4 + .../management/views/businessman_dashboard.py | 11 + core/apps/management/views/common/__init__.py | 5 + .../views/common/confirm_expense.py | 17 + core/apps/management/views/common/create.py | 220 +++++ .../views/common/decline_expense.py | 14 + core/apps/management/views/common/edit.py | 188 +++++ core/apps/management/views/common/list.py | 90 ++ .../management/views/employee_dashboard.py | 10 + .../management/views/manager_dashboard.py | 11 + 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 | 41 + ...d_at_settingsmodel_description_and_more.py | 46 ++ core/apps/shared/migrations/__init__.py | 0 core/apps/shared/models/__init__.py | 1 + core/apps/shared/models/settings.py | 31 + core/apps/shared/serializers/__init__.py | 1 + .../shared/serializers/settings/__init__.py | 1 + .../shared/serializers/settings/settings.py | 7 + core/apps/shared/tests/__init__.py | 1 + core/apps/shared/tests/test_settings.py | 20 + 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 | 168 ++++ core/services/sms.py | 84 ++ core/services/user.py | 64 ++ core/utils/__init__.py | 3 + core/utils/storage.py | 33 + docker-compose.yml | 57 ++ docker/Dockerfile.nginx | 3 + docker/Dockerfile.web | 20 + jst.json | 9 + manage.py | 24 + pyproject.toml | 28 + requirements.txt | 49 ++ resources/.gitignore | 1 + resources/docs/github-actions-deploy.md | 214 +++++ resources/docs/pre-push.md | 34 + resources/layout/.flake8 | 3 + resources/layout/Dockerfile.alpine | 19 + resources/layout/Dockerfile.nginx | 3 + resources/layout/Jenkinsfile | 188 +++++ resources/layout/docker-compose.prod.yml | 61 ++ resources/layout/docker-compose.test.yml | 46 ++ resources/layout/k8s/config.yaml | 60 ++ resources/layout/k8s/db-deployment.yaml | 33 + resources/layout/k8s/db-service.yaml | 11 + resources/layout/k8s/django-deployment.yaml | 33 + resources/layout/k8s/django-service.yaml | 11 + resources/layout/k8s/nginx-deployment.yaml | 39 + resources/layout/k8s/nginx-service.yaml | 12 + resources/layout/k8s/volume.yaml | 35 + resources/layout/mypy.ini | 5 + resources/layout/nginx.conf | 54 ++ resources/locale/.gitkeep | 0 resources/locale/en/LC_MESSAGES/django.po | 49 ++ resources/locale/ru/LC_MESSAGES/django.po | 51 ++ resources/locale/uz/LC_MESSAGES/django.po | 55 ++ resources/logs/.gitignore | 2 + resources/media/.gitignore | 2 + resources/scripts/backup.sh | 5 + resources/scripts/entrypoint-server.sh | 17 + resources/scripts/entrypoint.sh | 18 + resources/static/css/app.css | 0 resources/static/css/error.css | 109 +++ resources/static/css/input.css | 3 + resources/static/css/jazzmin.css | 5 + resources/static/css/output.css | 772 ++++++++++++++++++ resources/static/images/logo.png | Bin 0 -> 39362 bytes resources/static/js/alpine.js | 9 + resources/static/js/app.js | 1 + resources/static/js/counter.js | 3 + resources/static/js/customer.js | 49 ++ resources/static/js/vite-refresh.js | 9 + .../static/vite/assets/appCss-w40geAFS.js | 0 .../static/vite/assets/appJs-YH6iAcjX.js | 6 + .../static/vite/assets/outCss-r8J2MRAR.css | 1 + resources/static/vite/manifest.json | 17 + resources/templates/400.html | 148 ++++ resources/templates/401.html | 147 ++++ resources/templates/403.html | 145 ++++ resources/templates/404.html | 147 ++++ resources/templates/405.html | 145 ++++ resources/templates/408.html | 145 ++++ resources/templates/500.html | 145 ++++ resources/templates/502.html | 145 ++++ resources/templates/503.html | 145 ++++ resources/templates/504.html | 145 ++++ resources/templates/admin/index.html | 40 + resources/templates/registration/login.html | 15 + resources/templates/user/home.html | 48 ++ stack.yaml | 202 +++++ 228 files changed, 10859 insertions(+) create mode 100644 .cruft.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 SECURITY.md create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/celery.py create mode 100644 config/conf/__init__.py create mode 100644 config/conf/apps.py create mode 100644 config/conf/cache.py create mode 100644 config/conf/celery.py create mode 100644 config/conf/channels.py create mode 100644 config/conf/ckeditor.py create mode 100644 config/conf/cron.py create mode 100644 config/conf/jwt.py create mode 100644 config/conf/logs.py create mode 100644 config/conf/modules.py create mode 100644 config/conf/navigation.py create mode 100644 config/conf/rest_framework.py create mode 100644 config/conf/spectacular.py create mode 100644 config/conf/storage.py create mode 100644 config/conf/unfold.py create mode 100644 config/env.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/common.py create mode 100644 config/settings/local.py create mode 100644 config/settings/production.py create mode 100644 config/settings/test.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 core/__init__.py create mode 100644 core/apps/__init__.py create mode 100644 core/apps/accounts/__init__.py create mode 100644 core/apps/accounts/admin.py create mode 100644 core/apps/accounts/apps.py create mode 100644 core/apps/accounts/choices.py create mode 100644 core/apps/accounts/forms.py create mode 100644 core/apps/accounts/managers.py create mode 100644 core/apps/accounts/migrations/0001_initial.py create mode 100644 core/apps/accounts/migrations/0002_initial.py create mode 100644 core/apps/accounts/migrations/0003_user_warehouse.py create mode 100644 core/apps/accounts/migrations/__init__.py create mode 100644 core/apps/accounts/models.py create mode 100644 core/apps/accounts/templates/auth/login.html create mode 100644 core/apps/accounts/tests.py create mode 100644 core/apps/accounts/urls.py create mode 100644 core/apps/accounts/views.py create mode 100644 core/apps/logs/.gitignore create mode 100644 core/apps/management/__init__.py create mode 100644 core/apps/management/admin.py create mode 100644 core/apps/management/apps.py create mode 100644 core/apps/management/choice/ToyMovementType.py create mode 100644 core/apps/management/choice/__init__.py create mode 100644 core/apps/management/decorators.py create mode 100644 core/apps/management/forms/DeviceForm.py create mode 100644 core/apps/management/forms/ExpenseForm.py create mode 100644 core/apps/management/forms/IncomeForm.py create mode 100644 core/apps/management/forms/RentForm.py create mode 100644 core/apps/management/forms/ToyMovementEmployeeForm.py create mode 100644 core/apps/management/forms/ToyMovementForm.py create mode 100644 core/apps/management/forms/UserCreateForm.py create mode 100644 core/apps/management/forms/WarehouseForm.py create mode 100644 core/apps/management/forms/__init__.py create mode 100644 core/apps/management/forms/user/BaseUserForm.py create mode 100644 core/apps/management/forms/user/UserCreateFormBusinessman.py create mode 100644 core/apps/management/forms/user/UserCreateFormManagerToEmployee.py create mode 100644 core/apps/management/forms/user/UserEditFormBusinessman.py create mode 100644 core/apps/management/forms/user/UserEditFormManagerToEmployee.py create mode 100644 core/apps/management/forms/user/__init__.py create mode 100644 core/apps/management/migrations/0001_initial.py create mode 100644 core/apps/management/migrations/0002_expensetype_expense_income.py create mode 100644 core/apps/management/migrations/0003_load_uzbekistan_regions_districts.py create mode 100644 core/apps/management/migrations/0004_alter_device_name.py create mode 100644 core/apps/management/migrations/0005_alter_toymovement_from_warehouse_and_more.py create mode 100644 core/apps/management/migrations/0006_remove_device_warehouse.py create mode 100644 core/apps/management/migrations/0007_alter_expense_expense_type_delete_expensetype.py create mode 100644 core/apps/management/migrations/0008_alter_expense_expense_type.py create mode 100644 core/apps/management/migrations/0009_alter_expense_expense_type.py create mode 100644 core/apps/management/migrations/0010_expense_device_expense_employee_and_more.py create mode 100644 core/apps/management/migrations/0011_alter_expense_confirmed_by.py create mode 100644 core/apps/management/migrations/0012_remove_income_reported_by_income_created_by_and_more.py create mode 100644 core/apps/management/migrations/0013_rename_name_device_address_remove_device_monthly_fee_and_more.py create mode 100644 core/apps/management/migrations/0014_alter_income_amount.py create mode 100644 core/apps/management/migrations/0015_remove_income_confirmed_by.py create mode 100644 core/apps/management/migrations/__init__.py create mode 100644 core/apps/management/models/__init__.py create mode 100644 core/apps/management/models/device.py create mode 100644 core/apps/management/models/district.py create mode 100644 core/apps/management/models/expense.py create mode 100644 core/apps/management/models/income.py create mode 100644 core/apps/management/models/region.py create mode 100644 core/apps/management/models/rent.py create mode 100644 core/apps/management/models/toyMovement.py create mode 100644 core/apps/management/models/warehouse.py create mode 100644 core/apps/management/templates/base.html create mode 100644 core/apps/management/templates/businessman/businessman_dashboard.html create mode 100644 core/apps/management/templates/common/create/device_create.html create mode 100644 core/apps/management/templates/common/create/expense_create.html create mode 100644 core/apps/management/templates/common/create/income_create.html create mode 100644 core/apps/management/templates/common/create/toy_movement_create.html create mode 100644 core/apps/management/templates/common/create/user_create.html create mode 100644 core/apps/management/templates/common/create/warehouse_create.html create mode 100644 core/apps/management/templates/common/edit/device_edit.html create mode 100644 core/apps/management/templates/common/edit/expense_edit.html create mode 100644 core/apps/management/templates/common/edit/income_edit.html create mode 100644 core/apps/management/templates/common/edit/toy_movement_edit.html create mode 100644 core/apps/management/templates/common/edit/user_edit.html create mode 100644 core/apps/management/templates/common/edit/warehouse_edit.html create mode 100644 core/apps/management/templates/common/list.html create mode 100644 core/apps/management/templates/common/lists/device_list.html create mode 100644 core/apps/management/templates/common/lists/expense_list.html create mode 100644 core/apps/management/templates/common/lists/income_list.html create mode 100644 core/apps/management/templates/common/lists/rent_list.html create mode 100644 core/apps/management/templates/common/lists/toy_movement_list.html create mode 100644 core/apps/management/templates/common/lists/user_list.html create mode 100644 core/apps/management/templates/common/lists/warehouse_list.html create mode 100644 core/apps/management/templates/employee/employee_dashboard.html create mode 100644 core/apps/management/templates/manager/manager_dashboard.html create mode 100644 core/apps/management/templatetags/__init__.py create mode 100644 core/apps/management/templatetags/custom_filters.py create mode 100644 core/apps/management/tests.py create mode 100644 core/apps/management/translations/__init__.py create mode 100644 core/apps/management/translations/field_translations.py create mode 100644 core/apps/management/urls.py create mode 100644 core/apps/management/views/__init__.py create mode 100644 core/apps/management/views/businessman_dashboard.py create mode 100644 core/apps/management/views/common/__init__.py create mode 100644 core/apps/management/views/common/confirm_expense.py create mode 100644 core/apps/management/views/common/create.py create mode 100644 core/apps/management/views/common/decline_expense.py create mode 100644 core/apps/management/views/common/edit.py create mode 100644 core/apps/management/views/common/list.py create mode 100644 core/apps/management/views/employee_dashboard.py create mode 100644 core/apps/management/views/manager_dashboard.py create mode 100644 core/apps/shared/__init__.py create mode 100644 core/apps/shared/admin/__init__.py create mode 100644 core/apps/shared/admin/settings.py create mode 100644 core/apps/shared/apps.py create mode 100644 core/apps/shared/enums/__init__.py create mode 100644 core/apps/shared/migrations/0001_initial.py create mode 100644 core/apps/shared/migrations/0002_settingsmodel_created_at_settingsmodel_description_and_more.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/storage.py create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile.nginx create mode 100644 docker/Dockerfile.web create mode 100644 jst.json create mode 100644 manage.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 resources/.gitignore create mode 100644 resources/docs/github-actions-deploy.md create mode 100644 resources/docs/pre-push.md 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/Jenkinsfile create mode 100644 resources/layout/docker-compose.prod.yml create mode 100644 resources/layout/docker-compose.test.yml create mode 100644 resources/layout/k8s/config.yaml create mode 100644 resources/layout/k8s/db-deployment.yaml create mode 100644 resources/layout/k8s/db-service.yaml create mode 100644 resources/layout/k8s/django-deployment.yaml create mode 100644 resources/layout/k8s/django-service.yaml create mode 100644 resources/layout/k8s/nginx-deployment.yaml create mode 100644 resources/layout/k8s/nginx-service.yaml create mode 100644 resources/layout/k8s/volume.yaml 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 create mode 100644 stack.yaml diff --git a/.cruft.json b/.cruft.json new file mode 100644 index 0000000..ce612d5 --- /dev/null +++ b/.cruft.json @@ -0,0 +1,28 @@ +{ + "template": "https://github.com/JscorpTech/django", + "commit": "2be557271b22cc9e723a50737ccc117709c3c8b3", + "checkout": null, + "context": { + "cookiecutter": { + "cacheops": false, + "silk": false, + "storage": false, + "channels": false, + "ckeditor": false, + "modeltranslation": false, + "parler": false, + "rosetta": false, + "project_name": "aparat", + "settings_module": "config.settings.local", + "runner": "wsgi", + "script": "entrypoint.sh", + "key": "django-insecure-change-this-in-production", + "port": "8081", + "phone": "998000000000", + "password": "admin123", + "max_line_length": "120", + "project_slug": "aparat" + } + }, + "directory": null +} \ No newline at end of file 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..b60afdb --- /dev/null +++ b/.env.example @@ -0,0 +1,75 @@ +# Django configs +# WARNING: Change DJANGO_SECRET_KEY in production! Use a long, random string. +DJANGO_SECRET_KEY=django-insecure-change-this-in-production +DEBUG=True +DJANGO_SETTINGS_MODULE=config.settings.local +COMMAND=sh ./resources/scripts/entrypoint.sh +PORT=8081 +#! debug | prod +PROJECT_ENV=debug +PROTOCOL_HTTPS=False +SCRIPT=entrypoint.sh + +# OTP configs +OTP_SIZE=4 +OTP_PROD=false +OTP_DEFAULT=1111 + +# Database configs +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases +# WARNING: Change DB_PASSWORD in production! Use a strong, unique password. +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 +REDIS_HOST=redis +REDIS_PORT=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 + + +# !NOTE: on-storage +# # 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..f06b535 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ + +start: up seed + +up: + docker compose up -d + +down: + docker compose down + +build: + docker compose build + +rebuild: down build up + +deploy: down build up migrations + +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 + +makemigrations: + 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 + +migrations: makemigrations migrate + +fresh: reset_db migrations seed + +test: + docker compose exec web pytest -v + +shell: + docker compose exec web python manage.py shell diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..c6e43f8 --- /dev/null +++ b/README.MD @@ -0,0 +1,246 @@ +# JST-Django Template Documentation + +**Language:** [O'zbek](README.MD) | English + +Welcome! This is a comprehensive Django project template designed to streamline Django application development with pre-configured architecture, best practices, and powerful CLI tools. + +## Overview + +This template consists of two main components: + +1. **CLI Tool** - Command-line interface for generating Django apps and modules +2. **Architecture Template** - Production-ready Django project structure with Docker, pre-configured packages, and best practices + +> **Note:** While these components can be used independently, using them together provides the best development experience. + +## Key Features + +- πŸš€ Production-ready Django project structure +- 🐳 Docker & Docker Compose configuration +- πŸ“¦ Pre-configured popular packages (DRF, Celery, Redis, etc.) +- πŸ”§ CLI tool for rapid app/module generation +- 🌐 Multi-language support (modeltranslation/parler) +- πŸ”’ Security best practices included +- πŸ“ API documentation with Swagger/ReDoc +- βœ… Testing setup with pytest + +## Installation + +Install the CLI tool via pip: + +```bash +pip install -U jst-django +``` + +> **Important:** Always use the latest version of the CLI tool for compatibility with the template. + +## Quick Start + +### 1. Create a New Project + +```bash +jst create +``` + +You will be prompted for: + +- **Template**: Choose "django" (default) +- **Project Name**: Your project name (used throughout the project) +- **Settings File**: Keep default +- **Packages**: Select additional packages you need: + - modeltranslation or parler (choose one for translations) + - silk (performance profiling) + - channels (WebSocket support) + - ckeditor (rich text editor) + - and more... +- **Runner**: wsgi or asgi (choose asgi for WebSocket/async features) +- **Django Secret Key**: Change in production! +- **Port**: Default 8081 +- **Admin Password**: Set a strong password +- **Flake8**: Code style enforcement (recommended) + +### 2. Start the Project + +**Requirements:** Docker must be installed on your system. + +Navigate to your project directory: + +```bash +cd your_project_name +``` + +Start the project using Make: + +```bash +make up +``` + +Or manually: + +```bash +docker compose up -d +docker compose exec web python manage.py seed +``` + +The project will be available at `http://localhost:8081` + +### 3. Run Tests + +```bash +make test +``` + +## Creating Applications + +### Create a New App + +```bash +jst make:app +``` + +Choose a module type: +- **default**: Empty app structure +- **bot**: Telegram bot integration +- **authbot**: Telegram authentication +- **authv2**: New authentication system +- **websocket**: WebSocket support + +The app will be automatically created and registered. + +## Generating Modules + +The most powerful feature of JST-Django is module generation: + +```bash +jst make:module +``` + +You will be prompted for: + +1. **File Name**: Basename for generated files (e.g., "post") +2. **Module Names**: List of models to generate (e.g., "post, tag, category") +3. **App**: Target application +4. **Components**: Select what to generate: + - Model + - Serializer + - View (ViewSet) + - Admin + - Permissions + - Filters + - Tests + - URLs + +This generates complete CRUD APIs with all selected components! + +## Project Structure + +``` +β”œβ”€β”€ config/ # Configuration files +β”‚ β”œβ”€β”€ settings/ # Environment-specific settings +β”‚ β”‚ β”œβ”€β”€ common.py # Shared settings +β”‚ β”‚ β”œβ”€β”€ local.py # Development settings +β”‚ β”‚ β”œβ”€β”€ production.py # Production settings +β”‚ β”‚ └── test.py # Test settings +β”‚ β”œβ”€β”€ conf/ # Package configurations +β”‚ β”œβ”€β”€ urls.py +β”‚ └── wsgi.py / asgi.py +β”œβ”€β”€ core/ +β”‚ β”œβ”€β”€ apps/ # Django applications +β”‚ β”‚ β”œβ”€β”€ accounts/ # Pre-configured auth system +β”‚ β”‚ └── shared/ # Shared utilities +β”‚ β”œβ”€β”€ services/ # Business logic services +β”‚ └── utils/ # Utility functions +β”œβ”€β”€ docker/ # Docker configurations +β”œβ”€β”€ resources/ # Static resources, scripts +β”œβ”€β”€ Makefile # Convenience commands +β”œβ”€β”€ docker-compose.yml # Docker Compose config +β”œβ”€β”€ requirements.txt # Python dependencies +└── manage.py +``` + +## Available Make Commands + +```bash +make up # Start containers +make down # Stop containers +make build # Build containers +make rebuild # Rebuild and restart +make logs # View logs +make makemigrations # Create migrations +make migrate # Apply migrations +make migrations # Make and apply migrations +make seed # Seed database with initial data +make fresh # Reset DB, migrate, and seed +make test # Run tests +make deploy # Deploy (local) +make deploy-prod # Deploy (production) +``` + +## Security Considerations + +⚠️ **Important:** See [SECURITY.md](SECURITY.md) for detailed security guidelines. + +**Quick checklist:** +- βœ… Change `DJANGO_SECRET_KEY` in production +- βœ… Change default admin password +- βœ… Set `DEBUG=False` in production +- βœ… Configure proper `ALLOWED_HOSTS` +- βœ… Use HTTPS (`PROTOCOL_HTTPS=True`) +- βœ… Change database password +- βœ… Never commit `.env` file + +## Environment Variables + +Key environment variables in `.env`: + +- `DJANGO_SECRET_KEY`: Django secret key (change in production!) +- `DEBUG`: Debug mode (False in production) +- `DB_PASSWORD`: Database password (change in production!) +- `DJANGO_SETTINGS_MODULE`: Settings module to use +- `PROJECT_ENV`: debug | prod +- `SILK_ENABLED`: Enable Silk profiling (optional) + +See `.env.example` for all available options. + +## Additional Packages + +The template supports optional packages: + +- **modeltranslation**: Model field translation +- **parler**: Alternative translation solution +- **silk**: Performance profiling +- **channels**: WebSocket/async support +- **ckeditor**: Rich text editor +- **rosetta**: Translation management +- **cacheops**: Advanced caching + +## Testing + +Tests are written using pytest-django: + +```bash +# Run all tests +make test + +# Run specific tests +docker compose exec web pytest path/to/test.py -v +``` + +## Contributing + +Contributions are welcome! Please feel free to submit issues and pull requests. + +## License + +See [LICENSE](LICENSE) file for details. + +## Support + +For issues and questions: +- Create an issue on GitHub +- Check existing documentation + +--- + +**Happy Coding! πŸš€** + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b0db5e2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,79 @@ +# Security Best Practices / Xavfsizlik bo'yicha eng yaxshi amaliyotlar + +## English + +### Important Security Notes + +1. **Change Default Credentials** + - Never use the default password `2309` in production + - Change the admin phone number from the default value + - Generate a strong SECRET_KEY for production + +2. **Environment Variables** + - Never commit `.env` file to version control + - Keep production credentials secure and separate from development + - Use strong passwords for database and admin accounts + +3. **Database Security** + - Change default database password in production + - Use strong passwords for PostgreSQL + - Restrict database access to specific IP addresses + +4. **Django Security Settings** + - Set `DEBUG=False` in production + - Configure proper `ALLOWED_HOSTS` + - Use HTTPS in production (`PROTOCOL_HTTPS=True`) + - Keep `SECRET_KEY` secret and unique per environment + +5. **API Security** + - Configure proper CORS settings + - Use CSRF protection + - Implement rate limiting + - Use JWT tokens with appropriate expiration times + +6. **Docker Security** + - Don't expose unnecessary ports + - Use docker secrets for sensitive data + - Keep Docker images updated + +## O'zbekcha + +### Muhim xavfsizlik eslatmalari + +1. **Standart parollarni o'zgartiring** + - Production muhitida hech qachon standart parol `2309` dan foydalanmang + - Admin telefon raqamini standart qiymatdan o'zgartiring + - Production uchun kuchli SECRET_KEY yarating + +2. **Environment o'zgaruvchilari** + - Hech qachon `.env` faylini git repozitoriyasiga commit qilmang + - Production ma'lumotlarini xavfsiz va developmentdan alohida saqlang + - Ma'lumotlar bazasi va admin akkountlari uchun kuchli parollar ishlating + +3. **Ma'lumotlar bazasi xavfsizligi** + - Production muhitida standart parolni o'zgartiring + - PostgreSQL uchun kuchli parollar ishlating + - Ma'lumotlar bazasiga kirishni muayyan IP manzillarga cheklang + +4. **Django xavfsizlik sozlamalari** + - Production muhitida `DEBUG=False` qiling + - To'g'ri `ALLOWED_HOSTS` sozlang + - Production muhitida HTTPS dan foydalaning (`PROTOCOL_HTTPS=True`) + - `SECRET_KEY` ni maxfiy va har bir muhitda noyob qiling + +5. **API xavfsizligi** + - To'g'ri CORS sozlamalarini o'rnating + - CSRF himoyasidan foydalaning + - Rate limiting ni amalga oshiring + - JWT tokenlarni to'g'ri muddatda ishlating + +6. **Docker xavfsizligi** + - Keraksiz portlarni ochib qo'ymang + - Maxfiy ma'lumotlar uchun docker secrets dan foydalaning + - Docker imagelarni yangilab turing + +## Reporting Security Issues / Xavfsizlik muammolarini xabar qilish + +If you discover a security vulnerability, please email the maintainers directly instead of using the issue tracker. + +Agar xavfsizlik zaifligini topsangiz, iltimos issue tracker o'rniga to'g'ridan-to'g'ri maintainerlar ga email yuboring. 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..bbf0192 --- /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 + + + diff --git a/config/conf/apps.py b/config/conf/apps.py new file mode 100644 index 0000000..6721dca --- /dev/null +++ b/config/conf/apps.py @@ -0,0 +1,11 @@ +from config.env import env + +APPS = [ + 'django_core', + "core.apps.accounts.apps.AccountsConfig", +] + +if env.bool("SILK_ENABLED", False): + APPS += [ + + ] \ No newline at end of file diff --git a/config/conf/cache.py b/config/conf/cache.py new file mode 100644 index 0000000..ad3c7df --- /dev/null +++ b/config/conf/cache.py @@ -0,0 +1,12 @@ +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") + 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..fdf0774 --- /dev/null +++ b/config/conf/channels.py @@ -0,0 +1,12 @@ +# type: ignore +from config.env import env + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [(env.str("REDIS_HOST", "redis"), env.int("REDIS_PORT", 6379))], + }, + }, +} + 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..954362c --- /dev/null +++ b/config/conf/logs.py @@ -0,0 +1,60 @@ +import os +from pathlib import Path +import logging + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +LOG_DIR = BASE_DIR / "resources/logs" +os.makedirs(LOG_DIR, exist_ok=True) + + +class ExcludeErrorsFilter: + def filter(self, record): + return record.levelno < logging.ERROR + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(asctime)s %(name)s %(levelname)s %(pathname)s:%(lineno)d - %(message)s", + }, + }, + "filters": { + "exclude_errors": { + "()": ExcludeErrorsFilter, + }, + }, + "handlers": { + "daily_rotating_file": { + "level": "INFO", + "class": "logging.handlers.TimedRotatingFileHandler", + "filename": LOG_DIR / "django.log", + "when": "midnight", + "backupCount": 30, + "formatter": "verbose", + "filters": ["exclude_errors"], + }, + "error_file": { + "level": "ERROR", + "class": "logging.handlers.TimedRotatingFileHandler", + "filename": LOG_DIR / "django_error.log", + "when": "midnight", + "backupCount": 30, + "formatter": "verbose", + }, + }, + "loggers": { + "django": { + "handlers": ["daily_rotating_file", "error_file"], + "level": "INFO", + "propagate": True, + }, + "root": { + "handlers": ["daily_rotating_file", "error_file"], + "level": "INFO", + "propagate": True, + }, + }, +} diff --git a/config/conf/modules.py b/config/conf/modules.py new file mode 100644 index 0000000..70004fe --- /dev/null +++ b/config/conf/modules.py @@ -0,0 +1 @@ +MODULES = ["core.apps.shared", "core.apps.management"] 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..70ba65c --- /dev/null +++ b/config/conf/spectacular.py @@ -0,0 +1,31 @@ +SPECTACULAR_SETTINGS = { + "TITLE": "Your Project API", + "DESCRIPTION": "Your project description", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "CAMELIZE_NAMES": True, + "POSTPROCESSING_HOOKS": ["config.conf.spectacular.custom_postprocessing_hook"], +} + + +def custom_postprocessing_hook(result, generator, request, public): + """ + Customizes the API schema to wrap all responses in a standard format. + """ + for path, methods in result.get("paths", {}).items(): + for method, operation in methods.items(): + if "responses" in operation: + for status_code, response in operation["responses"].items(): + if "content" in response and status_code in ["200", "201", "202"]: + for content_type, content in response["content"].items(): + # Wrap original schema + original_schema = content.get("schema", {}) + response["content"][content_type]["schema"] = { + "type": "object", + "properties": { + "status": {"type": "boolean", "example": True}, + "data": original_schema, + }, + "required": ["status", "data"], + } + return result diff --git a/config/conf/storage.py b/config/conf/storage.py new file mode 100644 index 0000000..5d59a85 --- /dev/null +++ b/config/conf/storage.py @@ -0,0 +1,23 @@ +from config.env import env +from core.utils.storage import Storage + +AWS_ACCESS_KEY_ID = env.str("STORAGE_ID") +AWS_SECRET_ACCESS_KEY = env.str("STORAGE_KEY") +AWS_S3_ENDPOINT_URL = env.str("STORAGE_URL") +AWS_S3_CUSTOM_DOMAIN = env.str("STORAGE_PATH") +AWS_S3_URL_PROTOCOL = env.str("STORAGE_PROTOCOL", "https:") +AWS_S3_FILE_OVERWRITE = False + +default_storage = Storage(env.str("STORAGE_DEFAULT"), "default") +static_storage = Storage(env.str("STORAGE_STATIC"), "static") + +STORAGES = { + "default": { + "BACKEND": default_storage.get_backend(), + "OPTIONS": default_storage.get_options(), + }, + "staticfiles": { + "BACKEND": static_storage.get_backend(), + "OPTIONS": static_storage.get_options(), + }, +} diff --git a/config/conf/unfold.py b/config/conf/unfold.py new file mode 100644 index 0000000..8984f1c --- /dev/null +++ b/config/conf/unfold.py @@ -0,0 +1,95 @@ +from django.conf import settings +from django.templatetags.static import static +from django.utils.translation import gettext_lazy as _ + +from . import navigation + + +def environment_callback(request): + if settings.DEBUG: + return [_("Development"), "primary"] + + return [_("Production"), "primary"] + + +UNFOLD = { + "DASHBOARD_CALLBACK": "django_core.views.dashboard_callback", + "SITE_TITLE": "Django", + "SITE_HEADER": "Django", + "SITE_URL": "/", + # "SITE_DROPDOWN": [ + # {"icon": "local_library", "title": "Django", "link": "https://example.com"}, + # ], + "SITE_ICON": { + # "light": lambda request: static("images/pedagog.svg"), + # "dark": lambda request: static("images/pedagog.svg"), + }, + # "SITE_FAVICONS": [ + # { + # "rel": "icon", + # "sizes": "32x32", + # "type": "image/svg+xml", + # "href": lambda request: static("images/pedagog.svg"), + # }, + # ], + "SITE_SYMBOL": "speed", + "SHOW_HISTORY": True, + "SHOW_VIEW_ON_SITE": True, + "SHOW_BACK_BUTTON": True, + "SHOW_LANGUAGES": True, + "ENVIRONMENT": "core.config.unfold.environment_callback", + # "LOGIN": { + # "image": lambda request: static("images/login.png"), + # }, + "BORDER_RADIUS": "10px", + "COLORS": { + "base": { + "50": "250 250 250", + "100": "244 244 245", + "200": "228 228 231", + "300": "212 212 216", + "400": "161 161 170", + "500": "113 113 122", + "600": "82 82 91", + "700": "63 63 70", + "800": "39 39 42", + "900": "24 24 27", + "950": "9 9 11", + }, + "font": { + "subtle-light": "var(--color-base-500)", # text-base-500 + "subtle-dark": "var(--color-base-400)", # text-base-400 + "default-light": "var(--color-base-600)", # text-base-600 + "default-dark": "var(--color-base-300)", # text-base-300 + "important-light": "var(--color-base-900)", # text-base-900 + "important-dark": "var(--color-base-100)", # text-base-100 + }, + "primary": { + "50": "230 245 255", + "100": "180 225 255", + "200": "130 205 255", + "300": "80 185 255", + "400": "40 165 255", + "500": "0 145 255", + "600": "0 115 204", + "700": "0 85 153", + "800": "0 55 102", + "900": "0 30 51", + "950": "0 15 25", + }, + }, + "EXTENSIONS": { + "modeltranslation": { + "flags": { + "uz": "πŸ‡ΊπŸ‡Ώ", + "ru": "πŸ‡·πŸ‡Ί", + "en": "πŸ‡¬πŸ‡§", + }, + }, + }, + "SIDEBAR": { + "show_search": True, + "show_all_applications": True, + # "navigation": navigation.PAGES, + }, +} diff --git a/config/env.py b/config/env.py new file mode 100644 index 0000000..d9236a1 --- /dev/null +++ b/config/env.py @@ -0,0 +1,29 @@ +""" +Default value for environ variable +""" + +import os + +import environ + +environ.Env.read_env(os.path.join(".env")) + +env = environ.Env( + DEBUG=(bool, False), + CACHE_TIME=(int, 180), + OTP_EXPIRE_TIME=(int, 2), + VITE_LIVE=(bool, False), + ALLOWED_HOSTS=(str, "localhost"), + CSRF_TRUSTED_ORIGINS=(str, "localhost"), + DJANGO_SETTINGS_MODULE=(str, "config.settings.local"), + CACHE_TIMEOUT=(int, 120), + CACHE_ENABLED=(bool, False), + VITE_PORT=(int, 5173), + VITE_HOST=(str, "vite"), + NGROK_AUTHTOKEN=(str, "TOKEN"), + BOT_TOKEN=(str, "TOKEN"), + OTP_MODULE="core.services.otp", + OTP_SERVICE="EskizService", + PROJECT_ENV=(str, "prod"), + SILK_ENABLED=(bool, False), +) diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/common.py b/config/settings/common.py new file mode 100644 index 0000000..53d0cc9 --- /dev/null +++ b/config/settings/common.py @@ -0,0 +1,167 @@ +#type: ignore +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.bool("SILK_ENABLED", False): + MIDDLEWARE += [ + + ] + + +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(",") + +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..2a7a705 --- /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 += [] + +REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {"user": "60/min"} diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..cc2a481 --- /dev/null +++ b/config/settings/test.py @@ -0,0 +1,15 @@ +from config.settings.common import * # noqa + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, +} diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..cf9cb73 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,36 @@ +""" +All urls configurations tree +""" + +from django.conf import settings +from django.contrib import admin +from django.http import HttpResponse +from django.urls import include, path, re_path +from django.views.static import serve + +from config.env import env + +def home(request): + return HttpResponse("OK") + + +urlpatterns = [ + path("health/", home), + path("", include("core.apps.accounts.urls")), + path("", include("core.apps.shared.urls")), + path("", include("core.apps.management.urls")), +] +urlpatterns += [ + path("admin/", admin.site.urls), + # path("accounts/", include("django.contrib.auth.urls")), + path("i18n/", include("django.conf.urls.i18n")), +] +if env.bool("SILK_ENABLED", False): + urlpatterns += [] +if env.str("PROJECT_ENV") == "debug": + urlpatterns += [ + ] +urlpatterns += [ + re_path("static/(?P.*)", serve, {"document_root": settings.STATIC_ROOT}), + re_path("media/(?P.*)", serve, {"document_root": settings.MEDIA_ROOT}), +] \ No newline at end of file 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.py b/core/apps/accounts/admin.py new file mode 100644 index 0000000..b368672 --- /dev/null +++ b/core/apps/accounts/admin.py @@ -0,0 +1,42 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.forms import AdminPasswordChangeForm, UserChangeForm +from django.utils.translation import gettext_lazy as _ +from .models import User + +class CustomUserAdmin(UserAdmin): + model = User + change_password_form = AdminPasswordChangeForm + form = UserChangeForm + + list_display = ("id", "first_name", "last_name", "phone", "role", "region", "is_active", "is_staff") + list_filter = ("role", "region", "is_staff", "is_superuser", "is_active") + search_fields = ("phone", "first_name", "last_name", "email") + ordering = ("phone",) + autocomplete_fields = ["groups"] + + fieldsets = ( + (None, {"fields": ("phone", "password")}), + (_("Personal info"), {"fields": ("first_name", "last_name", "email", "region")}), + (_("Permissions"), { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + "role", + ), + }), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ) + + add_fieldsets = ( + (None, { + "classes": ("wide",), + "fields": ("phone", "password1", "password2", "role", "region", "is_active", "is_staff"), + }), + ) + +# Register the custom user +admin.site.register(User, CustomUserAdmin) \ No newline at end of file diff --git a/core/apps/accounts/apps.py b/core/apps/accounts/apps.py new file mode 100644 index 0000000..c3be98f --- /dev/null +++ b/core/apps/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core.apps.accounts' \ No newline at end of file diff --git a/core/apps/accounts/choices.py b/core/apps/accounts/choices.py new file mode 100644 index 0000000..37d895f --- /dev/null +++ b/core/apps/accounts/choices.py @@ -0,0 +1,9 @@ +from django.db import models +class RoleChoice(models.TextChoices): + """ + User Role Choice + """ + SUPERUSER = "superuser", "Superuser" + BUSINESSMAN = "businessman", "Businessman" + MANAGER = "manager", "Manager" + EMPLOYEE = "employee", "Employee" \ No newline at end of file diff --git a/core/apps/accounts/forms.py b/core/apps/accounts/forms.py new file mode 100644 index 0000000..47b8a42 --- /dev/null +++ b/core/apps/accounts/forms.py @@ -0,0 +1,37 @@ +from django import forms +from django.contrib.auth import authenticate +from django.contrib.auth.forms import AuthenticationForm + +class PhoneLoginForm(forms.Form): + phone = forms.CharField( + label="Phone", + max_length=255, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter phone number', + 'autofocus': True + }) + ) + password = forms.CharField( + label="Password", + widget=forms.PasswordInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Enter password' + }) + ) + + def clean(self): + cleaned_data = super().clean() + phone = cleaned_data.get("phone") + password = cleaned_data.get("password") + if phone and password: + from django.contrib.auth import get_user_model + User = get_user_model() + user = authenticate(username=phone, password=password) + if user is None: + raise forms.ValidationError("Invalid phone number or password") + self.user = user + return cleaned_data + + def get_user(self): + return getattr(self, 'user', None) \ No newline at end of file diff --git a/core/apps/accounts/managers.py b/core/apps/accounts/managers.py new file mode 100644 index 0000000..822382b --- /dev/null +++ b/core/apps/accounts/managers.py @@ -0,0 +1,22 @@ +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..79b534e --- /dev/null +++ b/core/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0.2 on 2026-02-04 12:15 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('phone', models.CharField(max_length=255, unique=True)), + ('username', models.CharField(blank=True, max_length=255, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('validated_at', models.DateTimeField(blank=True, null=True)), + ('role', models.CharField(choices=[('superuser', 'Superuser'), ('businessman', 'Businessman'), ('manager', 'Manager'), ('employee', 'Employee')], default='employee', max_length=255)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + ), + ] diff --git a/core/apps/accounts/migrations/0002_initial.py b/core/apps/accounts/migrations/0002_initial.py new file mode 100644 index 0000000..73e60b6 --- /dev/null +++ b/core/apps/accounts/migrations/0002_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.2 on 2026-02-04 12:15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ('auth', '0012_alter_user_first_name_max_length'), + ('management', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='region', + field=models.ForeignKey(blank=True, help_text='Only for managers', null=True, on_delete=django.db.models.deletion.SET_NULL, to='management.region'), + ), + migrations.AddField( + model_name='user', + name='user_permissions', + field=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'), + ), + ] diff --git a/core/apps/accounts/migrations/0003_user_warehouse.py b/core/apps/accounts/migrations/0003_user_warehouse.py new file mode 100644 index 0000000..45e012a --- /dev/null +++ b/core/apps/accounts/migrations/0003_user_warehouse.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.2 on 2026-02-06 09:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_initial'), + ('management', '0013_rename_name_device_address_remove_device_monthly_fee_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='warehouse', + field=models.ForeignKey(blank=True, help_text='Only for employees', null=True, on_delete=django.db.models.deletion.PROTECT, to='management.warehouse'), + ), + ] 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.py b/core/apps/accounts/models.py new file mode 100644 index 0000000..a5b9b29 --- /dev/null +++ b/core/apps/accounts/models.py @@ -0,0 +1,43 @@ +from django.contrib.auth import models as auth_models +from django.db import models +from .choices import RoleChoice +from .managers import UserManager +from ..management.models import Region, Warehouse + + +class User(auth_models.AbstractUser): + phone = models.CharField(max_length=255, unique=True) + username = models.CharField(max_length=255, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + validated_at = models.DateTimeField(null=True, blank=True) + role = models.CharField( + max_length=255, + choices=RoleChoice, + default=RoleChoice.EMPLOYEE, + ) + + region = models.ForeignKey( + Region, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Only for managers" + ) + + warehouse = models.ForeignKey( + Warehouse, + on_delete=models.PROTECT, + null=True, + blank=True, + help_text="Only for employees" + ) + + def get_full_name(self): + return f"{self.first_name} {self.last_name}".strip() or self.phone + + USERNAME_FIELD = "phone" + objects = UserManager() + + def __str__(self): + return self.phone \ No newline at end of file diff --git a/core/apps/accounts/templates/auth/login.html b/core/apps/accounts/templates/auth/login.html new file mode 100644 index 0000000..d27afd8 --- /dev/null +++ b/core/apps/accounts/templates/auth/login.html @@ -0,0 +1,147 @@ + + + + + + Login + + + + + +
+

Sign in

+ + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors.0 }} +
+ {% endif %} + +
+ {% csrf_token %} + +
+ {{ form.phone.label_tag }} + {{ form.phone }} +
+ +
+ {{ form.password.label_tag }} + {{ form.password }} +
+ + +
+
+ + \ No newline at end of file diff --git a/core/apps/accounts/tests.py b/core/apps/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/core/apps/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/core/apps/accounts/urls.py b/core/apps/accounts/urls.py new file mode 100644 index 0000000..fbd43fa --- /dev/null +++ b/core/apps/accounts/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import logout_view, login_view, dashboard + +urlpatterns = [ + path("", login_view, name="login"), + path("logout/", logout_view, name="logout"), + path('dashboard/', dashboard, name='dashboard'), +] \ No newline at end of file diff --git a/core/apps/accounts/views.py b/core/apps/accounts/views.py new file mode 100644 index 0000000..13d06ee --- /dev/null +++ b/core/apps/accounts/views.py @@ -0,0 +1,43 @@ +from django.shortcuts import render, redirect +from django.contrib.auth import login, logout +from .forms import PhoneLoginForm +from django.contrib import messages +from django.contrib.auth.decorators import login_required + +def login_view(request): + if request.user.is_authenticated: + return redirect('dashboard') + + if request.method == "POST": + form = PhoneLoginForm(request.POST) + if form.is_valid(): + user = form.get_user() + login(request, user) + return redirect('dashboard') + else: + messages.error(request, "Invalid phone number or password") + else: + form = PhoneLoginForm() + + return render(request, "auth/login.html", {"form": form}) + + +def logout_view(request): + logout(request) + return redirect('login') + +@login_required +def dashboard(request): + if request.user.role == "businessman": + return redirect("businessman_dashboard") + elif request.user.role == "manager": + return redirect("manager_dashboard") + elif request.user.role == "employee": + return redirect("employee_dashboard") + else: + return redirect("login") + + +def csrf_failure(request, reason=""): + logout(request) + return redirect("login") \ No newline at end of file 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/management/__init__.py b/core/apps/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/management/admin.py b/core/apps/management/admin.py new file mode 100644 index 0000000..02c1723 --- /dev/null +++ b/core/apps/management/admin.py @@ -0,0 +1,85 @@ +from django.contrib import admin +from .models import ( + Region, District, Warehouse, Device, + ToyMovement, Income, Expense +) +from core.apps.accounts.models import User # for ForeignKey references + + +# ------------------------- +# Region Admin +# ------------------------- +@admin.register(Region) +class RegionAdmin(admin.ModelAdmin): + list_display = ("name",) + search_fields = ("name",) + + +# ------------------------- +# District Admin +# ------------------------- +@admin.register(District) +class DistrictAdmin(admin.ModelAdmin): + list_display = ("name", "region") + list_filter = ("region",) + search_fields = ("name", "region__name") + + +# ------------------------- +# Warehouse Admin +# ------------------------- +@admin.register(Warehouse) +class WarehouseAdmin(admin.ModelAdmin): + list_display = ("name", "region", "toys_count", "created_at") + list_filter = ("region",) + search_fields = ("name", "region__name") + readonly_fields = ("created_at",) + + +# ------------------------- +# Device Admin +# ------------------------- +@admin.register(Device) +class DeviceAdmin(admin.ModelAdmin): + list_display = ("address", "district", "created_at") + list_filter = ("district",) + search_fields = ("name", "district__name",) + readonly_fields = ("created_at",) + + +# ------------------------- +# ToyMovement Admin +# ------------------------- +@admin.register(ToyMovement) +class ToyMovementAdmin(admin.ModelAdmin): + list_display = ( + "movement_type", "from_warehouse", "to_warehouse", + "device", "quantity", "created_by", "created_at" + ) + list_filter = ("movement_type", "from_warehouse", "to_warehouse") + search_fields = ("device__name", "created_by__phone") + autocomplete_fields = ["device", "created_by", "from_warehouse", "to_warehouse"] + readonly_fields = ("created_at",) + + +# ------------------------- +# Income Admin +# ------------------------- +@admin.register(Income) +class IncomeAdmin(admin.ModelAdmin): + list_display = ("device", "amount", "created_by", "created_at") + list_filter = ("device",) + search_fields = ("device__name", "created_by__phone") + autocomplete_fields = ["device", "created_by",] + readonly_fields = ("created_at",) + +# ------------------------- +# Expense Admin +# ------------------------- +@admin.register(Expense) +class ExpenseAdmin(admin.ModelAdmin): + list_display = ("amount", "expense_type", "created_by", "confirmed_by", "is_confirmed", "created_at") + list_filter = ("expense_type", "is_confirmed") + search_fields = ("expense_type__name", "created_by__phone", "confirmed_by__phone") + autocomplete_fields = ["created_by", "confirmed_by"] + readonly_fields = ("created_at",) diff --git a/core/apps/management/apps.py b/core/apps/management/apps.py new file mode 100644 index 0000000..cc1d083 --- /dev/null +++ b/core/apps/management/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core.apps.management' \ No newline at end of file diff --git a/core/apps/management/choice/ToyMovementType.py b/core/apps/management/choice/ToyMovementType.py new file mode 100644 index 0000000..ca00d83 --- /dev/null +++ b/core/apps/management/choice/ToyMovementType.py @@ -0,0 +1,5 @@ + +TOY_MOVEMENT_TYPE = [ + ("from_warehouse", "Ombordan β†’ Aparatga"), + ("between_warehouses", "Ombordan β†’ Omborga"), +] \ No newline at end of file diff --git a/core/apps/management/choice/__init__.py b/core/apps/management/choice/__init__.py new file mode 100644 index 0000000..a729cc6 --- /dev/null +++ b/core/apps/management/choice/__init__.py @@ -0,0 +1 @@ +from .ToyMovementType import * \ No newline at end of file diff --git a/core/apps/management/decorators.py b/core/apps/management/decorators.py new file mode 100644 index 0000000..10fbffc --- /dev/null +++ b/core/apps/management/decorators.py @@ -0,0 +1,16 @@ +from django.core.exceptions import PermissionDenied + +def role_required(allowed_roles): + """ + Usage: + @role_required(["manager", "businessman"]) + def view(request): + ... + """ + def decorator(view_func): + def _wrapped_view(request, *args, **kwargs): + if request.user.role not in allowed_roles: + raise PermissionDenied + return view_func(request, *args, **kwargs) + return _wrapped_view + return decorator diff --git a/core/apps/management/forms/DeviceForm.py b/core/apps/management/forms/DeviceForm.py new file mode 100644 index 0000000..571fc8b --- /dev/null +++ b/core/apps/management/forms/DeviceForm.py @@ -0,0 +1,19 @@ +from django import forms +from ..models import Device, District + +class DeviceForm(forms.ModelForm): + class Meta: + model = Device + fields = ["address", "district"] + + def __init__(self, *args, **kwargs): + user = kwargs.pop('user', None) # get the user from kwargs + super().__init__(*args, **kwargs) + + if user is not None: + if user.role == "manager": + # Manager: only districts in the same region + self.fields['district'].queryset = District.objects.filter(region=user.region) + else: + # Businessman: show all districts + self.fields['district'].queryset = District.objects.all() \ No newline at end of file diff --git a/core/apps/management/forms/ExpenseForm.py b/core/apps/management/forms/ExpenseForm.py new file mode 100644 index 0000000..9723d0c --- /dev/null +++ b/core/apps/management/forms/ExpenseForm.py @@ -0,0 +1,62 @@ +from django import forms +from ..models import Expense, Device +from core.apps.accounts.models import User + +# Base form +class BaseExpenseForm(forms.ModelForm): + class Meta: + model = Expense + fields = ["amount", "expense_type", "employee", "device"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Show all devices + self.fields["device"].queryset = Device.objects.all() + # Show all employees + self.fields["employee"].queryset = User.objects.filter(role="employee") + + def clean(self): + cleaned_data = super().clean() + expense_type = cleaned_data.get("expense_type") + employee = cleaned_data.get("employee") + device = cleaned_data.get("device") + + # Salary requires employee + if expense_type == Expense.ExpenseType.SALARY and not employee: + self.add_error("employee", "Employee must be set for Salary expenses.") + + # Device required for rent/maintenance + if expense_type in [Expense.ExpenseType.RENT, Expense.ExpenseType.MAINTENANCE] and not device: + self.add_error("device", "Device must be set for this type of expense.") + + return cleaned_data + + +# Employee form: cannot create Salary or Buy Toys +class ExpenseFormEmployee(BaseExpenseForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Remove forbidden types for employee + forbidden = [Expense.ExpenseType.SALARY, Expense.ExpenseType.BUY_TOYS] + self.fields["expense_type"].choices = [ + (value, label) + for value, label in Expense.ExpenseType.choices + if value not in forbidden + ] + + +# Manager form: cannot create Buy Toys +class ExpenseFormManager(BaseExpenseForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + forbidden = [Expense.ExpenseType.BUY_TOYS] + self.fields["expense_type"].choices = [ + (value, label) + for value, label in Expense.ExpenseType.choices + if value not in forbidden + ] + + +# Businessman form: full access +class ExpenseFormBusinessman(BaseExpenseForm): + pass \ No newline at end of file diff --git a/core/apps/management/forms/IncomeForm.py b/core/apps/management/forms/IncomeForm.py new file mode 100644 index 0000000..8aebfb0 --- /dev/null +++ b/core/apps/management/forms/IncomeForm.py @@ -0,0 +1,32 @@ +from django import forms +from ..models import Income, Device + +class IncomeForm(forms.ModelForm): + class Meta: + model = Income + fields = ["device", "amount"] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user", None) + super().__init__(*args, **kwargs) + + if self.user is not None: + # Filter devices + if self.user.role == "businessman": + self.fields["device"].queryset = Device.objects.all() + else: # manager or employee + self.fields["device"].queryset = Device.objects.filter(district__region=self.user.region) + + # Remove amount for employees + if self.user.role == "employee": + self.fields.pop("amount", None) + + def save(self, commit=True): + instance = super().save(commit=False) + if self.user: + instance.created_by = self.user + if getattr(self.user, "role", None) == "employee": + instance.amount = None + if commit: + instance.save() + return instance \ No newline at end of file diff --git a/core/apps/management/forms/RentForm.py b/core/apps/management/forms/RentForm.py new file mode 100644 index 0000000..7798fa0 --- /dev/null +++ b/core/apps/management/forms/RentForm.py @@ -0,0 +1,11 @@ +from django import forms +from ..models import Rent + +class RentForm(forms.ModelForm): + class Meta: + model = Rent + fields = ['address', 'district', 'device', 'due_date', 'amount'] + widgets = { + 'due_date': forms.DateInput(attrs={'type': 'date'}), + 'amount': forms.NumberInput(attrs={'min': 0}), + } \ No newline at end of file diff --git a/core/apps/management/forms/ToyMovementEmployeeForm.py b/core/apps/management/forms/ToyMovementEmployeeForm.py new file mode 100644 index 0000000..ecf751a --- /dev/null +++ b/core/apps/management/forms/ToyMovementEmployeeForm.py @@ -0,0 +1,21 @@ +from django import forms +from core.apps.management.models import ToyMovement + +class ToyMovementFormEmployee(forms.ModelForm): + class Meta: + model = ToyMovement + fields = ["device", "quantity"] # remove from_warehouse + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user", None) # pass user from view + super().__init__(*args, **kwargs) + + def save(self, commit=True): + instance = super().save(commit=False) + instance.movement_type = "from_warehouse" + instance.to_warehouse = None + if self.user and hasattr(self.user, "warehouse"): + instance.from_warehouse = self.user.warehouse + if commit: + instance.save() + return instance \ No newline at end of file diff --git a/core/apps/management/forms/ToyMovementForm.py b/core/apps/management/forms/ToyMovementForm.py new file mode 100644 index 0000000..4c4552c --- /dev/null +++ b/core/apps/management/forms/ToyMovementForm.py @@ -0,0 +1,30 @@ +from django import forms +from core.apps.management.models import ToyMovement, Warehouse, Device +from core.apps.management.choice import TOY_MOVEMENT_TYPE + +class ToyMovementForm(forms.ModelForm): + class Meta: + model = ToyMovement + fields = ["movement_type", "from_warehouse", "to_warehouse", "device", "quantity"] + widgets = { + "movement_type": forms.Select(choices=TOY_MOVEMENT_TYPE), + } + + def __init__(self, *args, **kwargs): + user = kwargs.pop("user", None) + super().__init__(*args, **kwargs) + self.fields["from_warehouse"].queryset = Warehouse.objects.all() + self.fields["to_warehouse"].queryset = Warehouse.objects.all() + self.fields["device"].queryset = Device.objects.all() + + def save(self, commit=True): + instance = super().save(commit=False) + + if instance.movement_type == "from_warehouse": + instance.to_warehouse = None + elif instance.movement_type == "between_warehouses": + instance.device = None + + if commit: + instance.save() + return instance \ No newline at end of file diff --git a/core/apps/management/forms/UserCreateForm.py b/core/apps/management/forms/UserCreateForm.py new file mode 100644 index 0000000..ba29dad --- /dev/null +++ b/core/apps/management/forms/UserCreateForm.py @@ -0,0 +1,78 @@ +# from django import forms +# from ...accounts.models import User, RoleChoice +# from ...management.models import Region +# +# class UserCreateForm(forms.ModelForm): +# role = forms.ChoiceField( +# choices=[ +# (RoleChoice.MANAGER, "Manager"), +# (RoleChoice.EMPLOYEE, "Employee") +# ], +# widget=forms.Select(attrs={"class": "form-control"}) +# ) +# +# password = forms.CharField( +# widget=forms.PasswordInput(attrs={"class": "form-control"}), +# required=False, +# label="Parol" +# ) +# +# region = forms.ModelChoiceField( +# queryset=Region.objects.none(), +# required=False, +# widget=forms.Select(attrs={"class": "form-control"}) +# ) +# +# class Meta: +# model = User +# fields = ["phone", "password", "role", "first_name", "last_name", "region"] +# widgets = { +# "phone": forms.TextInput(attrs={"class": "form-control"}), +# } +# +# def __init__(self, *args, **kwargs): +# self.creator = kwargs.pop("creator", None) +# super().__init__(*args, **kwargs) +# +# # Password required only on create +# if not self.instance or not self.instance.pk: +# self.fields["password"].required = True +# else: +# self.fields["password"].help_text = "Leave blank to keep current password" +# +# # Manager logic: remove role and region from form +# if self.creator and self.creator.role == "manager": +# if "role" in self.fields: +# self.fields.pop("role") +# if "region" in self.fields: +# self.fields.pop("region") +# +# +# +# # Businessman logic: region queryset +# elif self.creator and self.creator.role == "businessman": +# self.fields["region"].queryset = Region.objects.all() +# +# def save(self, commit=True): +# user = super().save(commit=False) +# +# # Manager-created users must have region set +# if self.creator and self.creator.role == "manager": +# user.region = self.creator.region +# +# # Only force EMPLOYEE if the target user is not a manager +# if user.role != RoleChoice.MANAGER: +# user.role = RoleChoice.EMPLOYEE +# +# # Password +# password = self.cleaned_data.get("password") +# if password: +# user.set_password(password) +# else: +# if user.pk: +# old_user = User.objects.get(pk=user.pk) +# user.password = old_user.password +# +# if commit: +# user.save() +# return user \ No newline at end of file diff --git a/core/apps/management/forms/WarehouseForm.py b/core/apps/management/forms/WarehouseForm.py new file mode 100644 index 0000000..559a4a1 --- /dev/null +++ b/core/apps/management/forms/WarehouseForm.py @@ -0,0 +1,7 @@ +from django import forms +from ..models import Warehouse + +class WarehouseForm(forms.ModelForm): + class Meta: + model = Warehouse + fields = ["name", "region", "toys_count"] \ No newline at end of file diff --git a/core/apps/management/forms/__init__.py b/core/apps/management/forms/__init__.py new file mode 100644 index 0000000..e924d8f --- /dev/null +++ b/core/apps/management/forms/__init__.py @@ -0,0 +1,8 @@ +from .IncomeForm import * +from .ExpenseForm import * +from .DeviceForm import * +from .WarehouseForm import * +from .UserCreateForm import * +from .ToyMovementEmployeeForm import ToyMovementFormEmployee +from .ToyMovementForm import ToyMovementForm +from .user import * \ No newline at end of file diff --git a/core/apps/management/forms/user/BaseUserForm.py b/core/apps/management/forms/user/BaseUserForm.py new file mode 100644 index 0000000..9ac1c9f --- /dev/null +++ b/core/apps/management/forms/user/BaseUserForm.py @@ -0,0 +1,50 @@ +# forms/base.py +from django import forms +from core.apps.accounts.models import User +from core.apps.accounts.choices import RoleChoice + +class BaseUserForm(forms.ModelForm): + password = forms.CharField( + required=False, + widget=forms.PasswordInput(attrs={"class": "form-control"}) + ) + role = forms.ChoiceField( + choices=[ + (RoleChoice.MANAGER, "Manager"), + (RoleChoice.EMPLOYEE, "Employee"), + ], + widget=forms.Select(attrs={"class": "form-control"}) + ) + + class Meta: + model = User + fields = [ + "phone", + "password", + "role", + "first_name", + "last_name", + "region", + "warehouse", + ] + + def clean_role(self): + role = self.cleaned_data["role"] + if role in (RoleChoice.BUSINESSMAN, RoleChoice.SUPERUSER): + raise forms.ValidationError("This role cannot be assigned.") + return role + + def save(self, commit=True): + user = super().save(commit=False) + + password = self.cleaned_data.get("password") + if password: + user.set_password(password) + else: + if user.pk: + old_user = User.objects.get(pk=user.pk) + user.password = old_user.password + + if commit: + user.save() + return user \ No newline at end of file diff --git a/core/apps/management/forms/user/UserCreateFormBusinessman.py b/core/apps/management/forms/user/UserCreateFormBusinessman.py new file mode 100644 index 0000000..32501ad --- /dev/null +++ b/core/apps/management/forms/user/UserCreateFormBusinessman.py @@ -0,0 +1,40 @@ +# forms/businessman_create.py +from .BaseUserForm import BaseUserForm +from core.apps.accounts.models import RoleChoice +from core.apps.management.models import Region, Warehouse + +class UserCreateFormBusinessman(BaseUserForm): + + class Meta(BaseUserForm.Meta): + fields = [ + "phone", + "password", + "role", + "first_name", + "last_name", + "region", + "warehouse", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["password"].required = True + self.fields["region"].queryset = Region.objects.all() + self.fields["warehouse"].queryset = Warehouse.objects.all() + + def clean(self): + cleaned = super().clean() + role = cleaned.get("role") + warehouse = cleaned.get("warehouse") + + if role == RoleChoice.MANAGER: + cleaned["warehouse"] = None + + if role == RoleChoice.EMPLOYEE: + if not warehouse: + self.add_error("warehouse", "Warehouse is required for employee") + else: + cleaned["region"] = warehouse.region + + return cleaned diff --git a/core/apps/management/forms/user/UserCreateFormManagerToEmployee.py b/core/apps/management/forms/user/UserCreateFormManagerToEmployee.py new file mode 100644 index 0000000..ed02e66 --- /dev/null +++ b/core/apps/management/forms/user/UserCreateFormManagerToEmployee.py @@ -0,0 +1,48 @@ +# forms/manager_create.py +from .BaseUserForm import BaseUserForm +from core.apps.accounts.models import RoleChoice +from core.apps.management.models import Warehouse + +class UserCreateFormManagerToEmployee(BaseUserForm): + class Meta(BaseUserForm.Meta): + fields = [ + "phone", + "password", + "first_name", + "last_name", + "warehouse", + ] + + def __init__(self, *args, **kwargs): + self.manager = kwargs.pop("manager") + super().__init__(*args, **kwargs) + + # Password is required for creation + self.fields["password"].required = True + + # Filter warehouses to manager's region + self.fields["warehouse"].queryset = Warehouse.objects.filter( + region=self.manager.region + ) + + # Hide role field, manager can only create employees + self.fields.pop("role", None) + + def save(self, commit=True): + user = super().save(commit=False) + + # Always set role to EMPLOYEE + user.role = RoleChoice.EMPLOYEE + + # Set region from selected warehouse + if user.warehouse: + user.region = user.warehouse.region + + # Only set password if provided + password = self.cleaned_data.get("password") + if password: + user.set_password(password) + + if commit: + user.save() + return user \ No newline at end of file diff --git a/core/apps/management/forms/user/UserEditFormBusinessman.py b/core/apps/management/forms/user/UserEditFormBusinessman.py new file mode 100644 index 0000000..b1a2d4a --- /dev/null +++ b/core/apps/management/forms/user/UserEditFormBusinessman.py @@ -0,0 +1,31 @@ +# forms/businessman_edit.py +from .BaseUserForm import BaseUserForm +from core.apps.accounts.models import RoleChoice +from core.apps.management.models import Warehouse +from django import forms + + +class UserEditFormBusinessman(BaseUserForm): + class Meta(BaseUserForm.Meta): + fields = BaseUserForm.Meta.fields + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["warehouse"].queryset = Warehouse.objects.all() + + if self.instance.role == RoleChoice.MANAGER: + self.fields["warehouse"].widget = forms.HiddenInput() + + def clean(self): + cleaned = super().clean() + role = cleaned.get("role") + warehouse = cleaned.get("warehouse") + + if role == RoleChoice.MANAGER: + cleaned["warehouse"] = None + + if role == RoleChoice.EMPLOYEE and warehouse: + cleaned["region"] = warehouse.region + + return cleaned \ No newline at end of file diff --git a/core/apps/management/forms/user/UserEditFormManagerToEmployee.py b/core/apps/management/forms/user/UserEditFormManagerToEmployee.py new file mode 100644 index 0000000..07c9952 --- /dev/null +++ b/core/apps/management/forms/user/UserEditFormManagerToEmployee.py @@ -0,0 +1,38 @@ +# forms/manager_edit.py +from .BaseUserForm import BaseUserForm +from core.apps.management.models import Warehouse +from core.apps.accounts.choices import RoleChoice + +class UserEditFormManagerToEmployee(BaseUserForm): + class Meta(BaseUserForm.Meta): + fields = [ + "phone", + "password", + "first_name", + "last_name", + "warehouse", + ] + + def __init__(self, *args, **kwargs): + self.manager = kwargs.pop("manager") + super().__init__(*args, **kwargs) + + self.fields.pop("role", None) + + # πŸ‘‡ decision based on edited user's role + if self.instance.role == RoleChoice.MANAGER: + # editing manager β†’ no warehouse + self.fields.pop("warehouse", None) + else: + # editing employee β†’ show warehouse + self.fields["warehouse"].queryset = Warehouse.objects.filter( + region=self.manager.region + ) + + def save(self, commit=True): + user = super().save(commit=False) + user.region = user.warehouse.region + + if commit: + user.save() + return user \ No newline at end of file diff --git a/core/apps/management/forms/user/__init__.py b/core/apps/management/forms/user/__init__.py new file mode 100644 index 0000000..d67c50e --- /dev/null +++ b/core/apps/management/forms/user/__init__.py @@ -0,0 +1,5 @@ +from .BaseUserForm import * +from .UserEditFormBusinessman import * +from .UserCreateFormBusinessman import * +from .UserCreateFormManagerToEmployee import * +from .UserEditFormManagerToEmployee import * \ No newline at end of file diff --git a/core/apps/management/migrations/0001_initial.py b/core/apps/management/migrations/0001_initial.py new file mode 100644 index 0000000..7f522ff --- /dev/null +++ b/core/apps/management/migrations/0001_initial.py @@ -0,0 +1,79 @@ +# Generated by Django 6.0.2 on 2026-02-04 12:15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='District', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name='Region', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ], + ), + migrations.CreateModel( + name='Device', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('toys_count', models.PositiveIntegerField(default=0)), + ('monthly_fee', models.DecimalField(decimal_places=2, max_digits=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('district', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='management.district')), + ], + ), + migrations.AddField( + model_name='district', + name='region', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='districts', to='management.region'), + ), + migrations.CreateModel( + name='Warehouse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('toys_count', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('region', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='warehouses', to='management.region')), + ], + ), + migrations.CreateModel( + name='ToyMovement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('movement_type', models.CharField(choices=[('from_warehouse', 'Warehouse β†’ Device'), ('to_device', 'Device refill'), ('between_warehouses', 'Warehouse β†’ Warehouse')], max_length=30)), + ('quantity', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='management.device')), + ('from_warehouse', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='outgoing', to='management.warehouse')), + ('to_warehouse', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='incoming', to='management.warehouse')), + ], + ), + migrations.AddField( + model_name='device', + name='warehouse', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='management.warehouse'), + ), + migrations.AlterUniqueTogether( + name='district', + unique_together={('region', 'name')}, + ), + ] diff --git a/core/apps/management/migrations/0002_expensetype_expense_income.py b/core/apps/management/migrations/0002_expensetype_expense_income.py new file mode 100644 index 0000000..2f5387c --- /dev/null +++ b/core/apps/management/migrations/0002_expensetype_expense_income.py @@ -0,0 +1,46 @@ +# Generated by Django 6.0.2 on 2026-02-04 12:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExpenseType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ], + ), + migrations.CreateModel( + name='Expense', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('is_confirmed', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('confirmed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmed_expenses', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ('expense_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to='management.expensetype')), + ], + ), + migrations.CreateModel( + name='Income', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('confirmed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='confirmed_income', to=settings.AUTH_USER_MODEL)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='management.device')), + ('reported_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reported_income', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/core/apps/management/migrations/0003_load_uzbekistan_regions_districts.py b/core/apps/management/migrations/0003_load_uzbekistan_regions_districts.py new file mode 100644 index 0000000..d493222 --- /dev/null +++ b/core/apps/management/migrations/0003_load_uzbekistan_regions_districts.py @@ -0,0 +1,125 @@ +from django.db import migrations + + +def load_data(apps, schema_editor): + Region = apps.get_model("management", "Region") + District = apps.get_model("management", "District") + + data = { + "QoraqalpogΚ»iston Respublikasi": [ + "Amudaryo tumani", "Beruniy tumani", "Chimboy tumani", + "EllikqalΚΌa tumani", "Kegeyli tumani", "MoΚ»ynoq tumani", + "Nukus tumani", "QanlikoΚ»l tumani", "QoΚ»ngΚ»irot tumani", + "QoraoΚ»zak tumani", "Shumanay tumani", "Taxiatosh tumani", + "TaxtakoΚ»pir tumani", "ToΚ»rtkoΚ»l tumani", "XoΚ»jayli tumani" + ], + "Andijon viloyati": [ + "Andijon tumani", "Asaka tumani", "Baliqchi tumani", + "BoΚ»z tumani", "Buloqboshi tumani", "Izboskan tumani", + "Jalaquduq tumani", "Marhamat tumani", "OltinkoΚ»l tumani", + "Paxtaobod tumani", "QoΚ»rgΚ»ontepa tumani", + "Shahrixon tumani", "UlugΚ»nor tumani", "XoΚ»jaobod tumani" + ], + "Buxoro viloyati": [ + "Buxoro tumani", "GΚ»ijduvon tumani", "Jondor tumani", + "Kogon tumani", "Olot tumani", "Peshku tumani", + "QorakoΚ»l tumani", "Qorovulbozor tumani", + "Romitan tumani", "Shofirkon tumani", "Vobkent tumani" + ], + "FargΚ»ona viloyati": [ + "BagΚ»dod tumani", "Beshariq tumani", "DangΚ»ara tumani", + "FargΚ»ona tumani", "Furqat tumani", "Oltiariq tumani", + "QoΚ»shtepa tumani", "Quva tumani", "Rishton tumani", + "SoΚ»x tumani", "Toshloq tumani", "UchkoΚ»prik tumani", + "Yozyovon tumani" + ], + "Jizzax viloyati": [ + "Arnasoy tumani", "Baxmal tumani", "DoΚ»stlik tumani", + "Forish tumani", "GΚ»allaorol tumani", "MirzachoΚ»l tumani", + "Paxtakor tumani", "Sharof Rashidov tumani", + "Yangiobod tumani", "Zomin tumani", "Zafarobod tumani" + ], + "Xorazm viloyati": [ + "BogΚ»ot tumani", "Gurlan tumani", "Hazorasp tumani", + "QoΚ»shkoΚ»pir tumani", "Shovot tumani", + "Urganch tumani", "Xazorasp tumani", "Xiva tumani", + "Yangiariq tumani", "Yangibozor tumani" + ], + "Namangan viloyati": [ + "Chortoq tumani", "Chust tumani", "Kosonsoy tumani", + "Mingbuloq tumani", "Namangan tumani", + "Norin tumani", "Pop tumani", "ToΚ»raqoΚ»rgΚ»on tumani", + "UchqoΚ»rgΚ»on tumani", "Uychi tumani", "YangiqoΚ»rgΚ»on tumani" + ], + "Navoiy viloyati": [ + "Karmana tumani", "Konimex tumani", "Navbahor tumani", + "Nurota tumani", "Qiziltepa tumani", + "Tomdi tumani", "Uchquduq tumani", "Xatirchi tumani" + ], + "Qashqadaryo viloyati": [ + "Chiroqchi tumani", "Dehqonobod tumani", + "GΚ»uzor tumani", "Kasbi tumani", "Kitob tumani", + "Koson tumani", "Mirishkor tumani", "Muborak tumani", + "Nishon tumani", "Qamashi tumani", + "Qarshi tumani", "Shahrisabz tumani", + "YakkabogΚ» tumani" + ], + "Samarqand viloyati": [ + "BulungΚ»ur tumani", "Ishtixon tumani", + "Jomboy tumani", "KattaqoΚ»rgΚ»on tumani", + "Narpay tumani", "Nurobod tumani", + "Oqdaryo tumani", "Paxtachi tumani", + "PastdargΚ»om tumani", "Payariq tumani", + "QoΚ»shrabot tumani", "Samarqand tumani", + "Toyloq tumani", "Urgut tumani" + ], + "Surxondaryo viloyati": [ + "Angor tumani", "Bandixon tumani", "Boysun tumani", + "Denov tumani", "JarqoΚ»rgΚ»on tumani", + "Muzrabot tumani", "Oltinsoy tumani", + "Qiziriq tumani", "QumqoΚ»rgΚ»on tumani", + "Sariosiyo tumani", "Sherobod tumani", + "ShoΚ»rchi tumani", "Termiz tumani", "Uzun tumani" + ], + "Sirdaryo viloyati": [ + "Boyovut tumani", "Guliston tumani", + "Mirzaobod tumani", "Oqoltin tumani", + "Sayxunobod tumani", "Sardoba tumani", + "Xovos tumani" + ], + "Toshkent viloyati": [ + "Angren tumani", "Bekobod tumani", + "BoΚ»ka tumani", "BoΚ»stonliq tumani", + "Chinoz tumani", "Ohangaron tumani", + "OqqoΚ»rgΚ»on tumani", "Parkent tumani", + "Piskent tumani", "Quyi Chirchiq tumani", + "YangiyoΚ»l tumani", "Yuqori Chirchiq tumani", + "Zangiota tumani" + ], + "Toshkent shahri": [ + "Bektemir tumani", "Chilonzor tumani", + "Hamza tumani", "Mirobod tumani", + "Mirzo UlugΚ»bek tumani", "Olmazor tumani", + "Sergeli tumani", "Shayxontohur tumani", + "Uchtepa tumani", "Yakkasaroy tumani", + "Yashnobod tumani", "Yunusobod tumani" + ], + } + + for region_name, districts in data.items(): + region, _ = Region.objects.get_or_create(name=region_name) + District.objects.bulk_create( + [District(name=d, region=region) for d in districts], + ignore_conflicts=True + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("management", "0002_expensetype_expense_income"), + ] + + operations = [ + migrations.RunPython(load_data), + ] diff --git a/core/apps/management/migrations/0004_alter_device_name.py b/core/apps/management/migrations/0004_alter_device_name.py new file mode 100644 index 0000000..e8dafd7 --- /dev/null +++ b/core/apps/management/migrations/0004_alter_device_name.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-02-05 05:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0003_load_uzbekistan_regions_districts'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + ] diff --git a/core/apps/management/migrations/0005_alter_toymovement_from_warehouse_and_more.py b/core/apps/management/migrations/0005_alter_toymovement_from_warehouse_and_more.py new file mode 100644 index 0000000..3ea1f16 --- /dev/null +++ b/core/apps/management/migrations/0005_alter_toymovement_from_warehouse_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.2 on 2026-02-05 06:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0004_alter_device_name'), + ] + + operations = [ + migrations.AlterField( + model_name='toymovement', + name='from_warehouse', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing', to='management.warehouse'), + ), + migrations.AlterField( + model_name='toymovement', + name='movement_type', + field=models.CharField(choices=[('from_warehouse', 'Warehouse β†’ Device'), ('between_warehouses', 'Warehouse β†’ Warehouse')], max_length=30), + ), + ] diff --git a/core/apps/management/migrations/0006_remove_device_warehouse.py b/core/apps/management/migrations/0006_remove_device_warehouse.py new file mode 100644 index 0000000..ea7f9a2 --- /dev/null +++ b/core/apps/management/migrations/0006_remove_device_warehouse.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.2 on 2026-02-05 06:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0005_alter_toymovement_from_warehouse_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='device', + name='warehouse', + ), + ] diff --git a/core/apps/management/migrations/0007_alter_expense_expense_type_delete_expensetype.py b/core/apps/management/migrations/0007_alter_expense_expense_type_delete_expensetype.py new file mode 100644 index 0000000..2dda04e --- /dev/null +++ b/core/apps/management/migrations/0007_alter_expense_expense_type_delete_expensetype.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.2 on 2026-02-05 07:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0006_remove_device_warehouse'), + ] + + operations = [ + migrations.AlterField( + model_name='expense', + name='expense_type', + field=models.CharField(choices=[('rent', 'Rent'), ('salary', 'Salary'), ('utilities', 'Utilities'), ('maintenance', 'Maintenance'), ('other', 'Other')], default='other', max_length=20), + ), + migrations.DeleteModel( + name='ExpenseType', + ), + ] diff --git a/core/apps/management/migrations/0008_alter_expense_expense_type.py b/core/apps/management/migrations/0008_alter_expense_expense_type.py new file mode 100644 index 0000000..6f2bbe1 --- /dev/null +++ b/core/apps/management/migrations/0008_alter_expense_expense_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-02-05 07:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0007_alter_expense_expense_type_delete_expensetype'), + ] + + operations = [ + migrations.AlterField( + model_name='expense', + name='expense_type', + field=models.CharField(choices=[('rent', 'Ijara'), ('salary', 'Maosh'), ('utilities', 'Kommunal toβ€˜lovlar'), ('maintenance', 'Texnik xizmat'), ('other', 'Boshqa')], default='other', max_length=20), + ), + ] diff --git a/core/apps/management/migrations/0009_alter_expense_expense_type.py b/core/apps/management/migrations/0009_alter_expense_expense_type.py new file mode 100644 index 0000000..ea0c064 --- /dev/null +++ b/core/apps/management/migrations/0009_alter_expense_expense_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-02-05 07:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0008_alter_expense_expense_type'), + ] + + operations = [ + migrations.AlterField( + model_name='expense', + name='expense_type', + field=models.CharField(choices=[('rent', 'Ijara'), ('salary', 'Maosh'), ('utilities', 'Kommunal toβ€˜lovlar'), ('maintenance', 'Texnik xizmat'), ('food', 'Oziq-ovqat'), ('transport', "Yo'lkira"), ('other', 'Boshqa')], default='other', max_length=20), + ), + ] diff --git a/core/apps/management/migrations/0010_expense_device_expense_employee_and_more.py b/core/apps/management/migrations/0010_expense_device_expense_employee_and_more.py new file mode 100644 index 0000000..000b197 --- /dev/null +++ b/core/apps/management/migrations/0010_expense_device_expense_employee_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.2 on 2026-02-05 08:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0009_alter_expense_expense_type'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='expense', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='management.device'), + ), + migrations.AddField( + model_name='expense', + name='employee', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='salaries', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='expense', + name='expense_type', + field=models.CharField(choices=[('rent', 'Ijara'), ('salary', 'Maosh'), ('utilities', 'Kommunal toβ€˜lovlar'), ('maintenance', 'Texnik xizmat'), ('food', 'Oziq-ovqat'), ('transport', "Yo'lkira"), ('buy_toys', 'OΚ»yinchoqlar sotib olish'), ('other', 'Boshqa')], default='other', max_length=20), + ), + ] diff --git a/core/apps/management/migrations/0011_alter_expense_confirmed_by.py b/core/apps/management/migrations/0011_alter_expense_confirmed_by.py new file mode 100644 index 0000000..17a48b2 --- /dev/null +++ b/core/apps/management/migrations/0011_alter_expense_confirmed_by.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.2 on 2026-02-05 08:04 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0010_expense_device_expense_employee_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='expense', + name='confirmed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='confirmed_expenses', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/core/apps/management/migrations/0012_remove_income_reported_by_income_created_by_and_more.py b/core/apps/management/migrations/0012_remove_income_reported_by_income_created_by_and_more.py new file mode 100644 index 0000000..965a120 --- /dev/null +++ b/core/apps/management/migrations/0012_remove_income_reported_by_income_created_by_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 6.0.2 on 2026-02-05 09:37 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0011_alter_expense_confirmed_by'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='income', + name='reported_by', + ), + migrations.AddField( + model_name='income', + name='created_by', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='created_incomes', to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='income', + name='is_confirmed', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='income', + name='confirmed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='confirmed_incomes', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='income', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incomes', to='management.device'), + ), + ] diff --git a/core/apps/management/migrations/0013_rename_name_device_address_remove_device_monthly_fee_and_more.py b/core/apps/management/migrations/0013_rename_name_device_address_remove_device_monthly_fee_and_more.py new file mode 100644 index 0000000..59e8a43 --- /dev/null +++ b/core/apps/management/migrations/0013_rename_name_device_address_remove_device_monthly_fee_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 6.0.2 on 2026-02-06 07:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0012_remove_income_reported_by_income_created_by_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='device', + old_name='name', + new_name='address', + ), + migrations.RemoveField( + model_name='device', + name='monthly_fee', + ), + migrations.RemoveField( + model_name='device', + name='toys_count', + ), + migrations.AlterField( + model_name='toymovement', + name='movement_type', + field=models.CharField(choices=[('from_warehouse', 'Ombordan β†’ Aparatga'), ('between_warehouses', 'Ombordan β†’ Omborga')], max_length=30), + ), + migrations.CreateModel( + name='Rent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address', models.CharField(max_length=100, unique=True)), + ('due_date', models.DateField()), + ('amount', models.IntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_rents', to='management.device')), + ('district', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='district_rents', to='management.district')), + ], + ), + ] diff --git a/core/apps/management/migrations/0014_alter_income_amount.py b/core/apps/management/migrations/0014_alter_income_amount.py new file mode 100644 index 0000000..86994ad --- /dev/null +++ b/core/apps/management/migrations/0014_alter_income_amount.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-02-06 12:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0013_rename_name_device_address_remove_device_monthly_fee_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='income', + name='amount', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True), + ), + ] diff --git a/core/apps/management/migrations/0015_remove_income_confirmed_by.py b/core/apps/management/migrations/0015_remove_income_confirmed_by.py new file mode 100644 index 0000000..7ffd0d0 --- /dev/null +++ b/core/apps/management/migrations/0015_remove_income_confirmed_by.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.2 on 2026-02-06 12:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('management', '0014_alter_income_amount'), + ] + + operations = [ + migrations.RemoveField( + model_name='income', + name='confirmed_by', + ), + ] diff --git a/core/apps/management/migrations/__init__.py b/core/apps/management/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/management/models/__init__.py b/core/apps/management/models/__init__.py new file mode 100644 index 0000000..36cdbc0 --- /dev/null +++ b/core/apps/management/models/__init__.py @@ -0,0 +1,8 @@ +from .region import * +from .device import * +from .income import * +from .district import * +from .toyMovement import * +from .warehouse import * +from .expense import * +from .rent import * \ No newline at end of file diff --git a/core/apps/management/models/device.py b/core/apps/management/models/device.py new file mode 100644 index 0000000..47fdff8 --- /dev/null +++ b/core/apps/management/models/device.py @@ -0,0 +1,10 @@ +from django.db import models +from .district import District + +class Device(models.Model): + address = models.CharField(max_length=100, unique=True) + district = models.ForeignKey(District, on_delete=models.PROTECT) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.address \ No newline at end of file diff --git a/core/apps/management/models/district.py b/core/apps/management/models/district.py new file mode 100644 index 0000000..55f12ef --- /dev/null +++ b/core/apps/management/models/district.py @@ -0,0 +1,16 @@ +from django.db import models +from .region import Region + +class District(models.Model): + region = models.ForeignKey( + Region, + on_delete=models.CASCADE, + related_name="districts" + ) + name = models.CharField(max_length=100) + + class Meta: + unique_together = ("region", "name") + + def __str__(self): + return f"{self.name} β€” {self.region.name}" \ No newline at end of file diff --git a/core/apps/management/models/expense.py b/core/apps/management/models/expense.py new file mode 100644 index 0000000..5a179f3 --- /dev/null +++ b/core/apps/management/models/expense.py @@ -0,0 +1,47 @@ +from django.db import models +from core.apps.management.models import Device + +class Expense(models.Model): + class ExpenseType(models.TextChoices): + RENT = "rent", "Ijara" + SALARY = "salary", "Maosh" + UTILITIES = "utilities", "Kommunal toβ€˜lovlar" + MAINTENANCE = "maintenance", "Texnik xizmat" + FOOD = "food", "Oziq-ovqat" + TRANSPORT = "transport", "Yo'lkira" + BUY_TOYS = "buy_toys", "OΚ»yinchoqlar sotib olish" + OTHER = "other", "Boshqa" + + amount = models.DecimalField(max_digits=12, decimal_places=2) + expense_type = models.CharField( + max_length=20, + choices=ExpenseType.choices, + default=ExpenseType.OTHER, + ) + + # Conditional fields + employee = models.ForeignKey("accounts.User", related_name="salaries", null=True, blank=True, on_delete=models.PROTECT) + device = models.ForeignKey(Device, null=True, blank=True, on_delete=models.PROTECT) + + created_by = models.ForeignKey("accounts.User", on_delete=models.PROTECT) + confirmed_by = models.ForeignKey( + "accounts.User", on_delete=models.PROTECT, + null=True, blank=True, related_name="confirmed_expenses" + ) + + is_confirmed = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + def clean(self): + from django.core.exceptions import ValidationError + + # Salary requires employee + if self.expense_type == self.ExpenseType.SALARY and not self.employee: + raise ValidationError({"employee": "Employee must be set for Salary expenses."}) + + # Device required for rent/utilities/maintenance + if self.expense_type in [ + self.ExpenseType.RENT, + self.ExpenseType.MAINTENANCE + ] and not self.device: + raise ValidationError({"device": "Device must be set for this type of expense."}) \ No newline at end of file diff --git a/core/apps/management/models/income.py b/core/apps/management/models/income.py new file mode 100644 index 0000000..0ec4b0a --- /dev/null +++ b/core/apps/management/models/income.py @@ -0,0 +1,10 @@ +from django.db import models +from .device import Device + +class Income(models.Model): + device = models.ForeignKey(Device, related_name='incomes',on_delete=models.PROTECT) + amount = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + created_by = models.ForeignKey("accounts.User", on_delete=models.PROTECT, related_name="created_incomes") + + is_confirmed = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) \ No newline at end of file diff --git a/core/apps/management/models/region.py b/core/apps/management/models/region.py new file mode 100644 index 0000000..fc2cd78 --- /dev/null +++ b/core/apps/management/models/region.py @@ -0,0 +1,7 @@ +from django.db import models + +class Region(models.Model): + name = models.CharField(max_length=100, unique=True) + + def __str__(self): + return self.name \ No newline at end of file diff --git a/core/apps/management/models/rent.py b/core/apps/management/models/rent.py new file mode 100644 index 0000000..1391691 --- /dev/null +++ b/core/apps/management/models/rent.py @@ -0,0 +1,15 @@ +from django.db import models +from .district import District +from .device import Device + +class Rent(models.Model): + address = models.CharField(max_length=100, unique=True) + district = models.ForeignKey(District, related_name="district_rents", on_delete=models.PROTECT) + device = models.ForeignKey(Device, related_name="device_rents", on_delete=models.PROTECT) + due_date = models.DateField() + amount = models.IntegerField() + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.address \ No newline at end of file diff --git a/core/apps/management/models/toyMovement.py b/core/apps/management/models/toyMovement.py new file mode 100644 index 0000000..bb985c2 --- /dev/null +++ b/core/apps/management/models/toyMovement.py @@ -0,0 +1,23 @@ +from django.db import models +from .device import Device +from .warehouse import Warehouse +from ..choice import TOY_MOVEMENT_TYPE + +class ToyMovement(models.Model): + movement_type = models.CharField(max_length=30, choices=TOY_MOVEMENT_TYPE) + from_warehouse = models.ForeignKey( + Warehouse, on_delete=models.PROTECT, + related_name="outgoing" + ) + to_warehouse = models.ForeignKey( + Warehouse, on_delete=models.PROTECT, + related_name="incoming", + null=True, blank=True + ) + device = models.ForeignKey( + Device, on_delete=models.PROTECT, + null=True, blank=True + ) + quantity = models.PositiveIntegerField() + created_by = models.ForeignKey("accounts.User", on_delete=models.PROTECT) + created_at = models.DateTimeField(auto_now_add=True) \ No newline at end of file diff --git a/core/apps/management/models/warehouse.py b/core/apps/management/models/warehouse.py new file mode 100644 index 0000000..49dfbf6 --- /dev/null +++ b/core/apps/management/models/warehouse.py @@ -0,0 +1,15 @@ +from django.db import models +from .region import Region + +class Warehouse(models.Model): + name = models.CharField(max_length=100, unique=True) + region = models.ForeignKey( + Region, + on_delete=models.PROTECT, + related_name="warehouses" + ) + toys_count = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name \ No newline at end of file diff --git a/core/apps/management/templates/base.html b/core/apps/management/templates/base.html new file mode 100644 index 0000000..fff04ef --- /dev/null +++ b/core/apps/management/templates/base.html @@ -0,0 +1,239 @@ + + + + + {% block title %}Xvatayka{% endblock %} + + + + + + +
+ + +
+ + + +
+ {% block content %}{% endblock %} +
+ + + + \ No newline at end of file diff --git a/core/apps/management/templates/businessman/businessman_dashboard.html b/core/apps/management/templates/businessman/businessman_dashboard.html new file mode 100644 index 0000000..f32d0e0 --- /dev/null +++ b/core/apps/management/templates/businessman/businessman_dashboard.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} + +{% block content %} +
+

Businessman Paneli

+ + + + + +{% endblock %} diff --git a/core/apps/management/templates/common/create/device_create.html b/core/apps/management/templates/common/create/device_create.html new file mode 100644 index 0000000..5d4a638 --- /dev/null +++ b/core/apps/management/templates/common/create/device_create.html @@ -0,0 +1,147 @@ +{% extends "base.html" %} + +{% block title %}{{ title|default:"Yaratish" }}{% endblock %} + +{% block content %} +
+

{{ title|default:"Yaratish" }}

+ + + + + + Orqaga + + +
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + + +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/core/apps/management/templates/common/create/expense_create.html b/core/apps/management/templates/common/create/expense_create.html new file mode 100644 index 0000000..3995dd6 --- /dev/null +++ b/core/apps/management/templates/common/create/expense_create.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} + +{% block title %}{{ title|default:"Yaratish" }}{% endblock %} + +{% block content %} +
+

{{ title|default:"Yaratish" }}

+ + + + + + Orqaga + + +
+ {% csrf_token %} + + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + + +
+
+ + + + +{% endblock %} diff --git a/core/apps/management/templates/common/create/income_create.html b/core/apps/management/templates/common/create/income_create.html new file mode 100644 index 0000000..601228e --- /dev/null +++ b/core/apps/management/templates/common/create/income_create.html @@ -0,0 +1,150 @@ +{% extends "base.html" %} + +{% block title %}{{ title|default:"Yaratish" }}{% endblock %} + +{% block content %} +
+

{{ title|default:"Yaratish" }}

+ + + + + + Orqaga + + +
+ {% csrf_token %} + + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + + +
+
+ + +{% endblock %} diff --git a/core/apps/management/templates/common/create/toy_movement_create.html b/core/apps/management/templates/common/create/toy_movement_create.html new file mode 100644 index 0000000..da5cd59 --- /dev/null +++ b/core/apps/management/templates/common/create/toy_movement_create.html @@ -0,0 +1,196 @@ +{% extends "base.html" %} + +{% block title %}{{ title|default:"Yaratish" }}{% endblock %} + +{% block content %} +
+

{{ title|default:"Yaratish" }}

+ + + + + + Orqaga + + +
+ {% csrf_token %} + + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + + +
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/core/apps/management/templates/common/create/user_create.html b/core/apps/management/templates/common/create/user_create.html new file mode 100644 index 0000000..804933e --- /dev/null +++ b/core/apps/management/templates/common/create/user_create.html @@ -0,0 +1,184 @@ +{% extends "base.html" %} + +{% block title %}{{ title|default:"Yaratish" }}{% endblock %} + +{% block content %} +
+

{{ title|default:"Yaratish" }}

+ + + + + + Orqaga + + +
+ {% csrf_token %} + + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + + +
+
+ + + + + + + + +{% endblock %} diff --git a/core/apps/management/templates/common/create/warehouse_create.html b/core/apps/management/templates/common/create/warehouse_create.html new file mode 100644 index 0000000..566549b --- /dev/null +++ b/core/apps/management/templates/common/create/warehouse_create.html @@ -0,0 +1,10 @@ + + + + + Title + + + + + \ No newline at end of file diff --git a/core/apps/management/templates/common/edit/device_edit.html b/core/apps/management/templates/common/edit/device_edit.html new file mode 100644 index 0000000..a6a1c33 --- /dev/null +++ b/core/apps/management/templates/common/edit/device_edit.html @@ -0,0 +1,147 @@ +{% extends "base.html" %} + +{% block title %}{{ title|default:"Tahrirlash" }}{% endblock %} + +{% block content %} +
+

{{ title|default:"Tahrirlash" }}

+ + + + + + Orqaga + + +
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + + +
+
+ + +{% endblock %} diff --git a/core/apps/management/templates/common/edit/expense_edit.html b/core/apps/management/templates/common/edit/expense_edit.html new file mode 100644 index 0000000..5780bdc --- /dev/null +++ b/core/apps/management/templates/common/edit/expense_edit.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} + +{% block title %}Create Expense{% endblock %} + +{% block content %} + + +
+

Create Expense

+ + {% if form.non_field_errors %} +
{{ form.non_field_errors.0 }}
+ {% endif %} + +
+ {% csrf_token %} + +
+ {{ form.expense_type.label_tag }} + {{ form.expense_type }} + {{ form.expense_type.errors }} +
+ +
+ {{ form.amount.label_tag }} + {{ form.amount }} + {{ form.amount.errors }} +
+ + + + + + +
+
+ + +{% endblock %} diff --git a/core/apps/management/templates/common/edit/income_edit.html b/core/apps/management/templates/common/edit/income_edit.html new file mode 100644 index 0000000..cd916ce --- /dev/null +++ b/core/apps/management/templates/common/edit/income_edit.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} + +{% block title %}Create Income{% endblock %} + +{% block content %} + + +
+

Create Income

+ + {% if form.non_field_errors %} +
{{ form.non_field_errors.0 }}
+ {% endif %} + +
+ {% csrf_token %} + +
+ {{ form.device.label_tag }} + {{ form.device }} + {{ form.device.errors }} +
+ +
+ {{ form.amount.label_tag }} + {{ form.amount }} + {{ form.amount.errors }} +
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/core/apps/management/templates/common/edit/toy_movement_edit.html b/core/apps/management/templates/common/edit/toy_movement_edit.html new file mode 100644 index 0000000..4126de6 --- /dev/null +++ b/core/apps/management/templates/common/edit/toy_movement_edit.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} + +{% block title %}Create Toy Movement{% endblock %} + +{% block content %} + + +
+

Create Toy Movement

+ + {% if form.non_field_errors %} +
{{ form.non_field_errors.0 }}
+ {% endif %} + +
+ {% csrf_token %} + + {% if user_role != "employee" %} +
+ {{ form.movement_type.label_tag }} + {{ form.movement_type }} + {{ form.movement_type.errors }} +
+ {% endif %} + +
+ {{ form.from_warehouse.label_tag }} + {{ form.from_warehouse }} + {{ form.from_warehouse.errors }} +
+ +
+ {{ form.to_warehouse.label_tag }} + {{ form.to_warehouse }} + {{ form.to_warehouse.errors }} +
+ +
+ {{ form.device.label_tag }} + {{ form.device }} + {{ form.device.errors }} +
+ +
+ {{ form.quantity.label_tag }} + {{ form.quantity }} + {{ form.quantity.errors }} +
+ + +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/core/apps/management/templates/common/edit/user_edit.html b/core/apps/management/templates/common/edit/user_edit.html new file mode 100644 index 0000000..37fde61 --- /dev/null +++ b/core/apps/management/templates/common/edit/user_edit.html @@ -0,0 +1,183 @@ +{% extends "base.html" %} + +{% block title %}{{ title|default:"Yaratish" }}{% endblock %} + +{% block content %} +
+

{{ title|default:"Yaratish" }}

+ + + + + + Orqaga + + +
+ {% csrf_token %} + + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + + +
+
+ + + + + + + +{% endblock %} diff --git a/core/apps/management/templates/common/edit/warehouse_edit.html b/core/apps/management/templates/common/edit/warehouse_edit.html new file mode 100644 index 0000000..566549b --- /dev/null +++ b/core/apps/management/templates/common/edit/warehouse_edit.html @@ -0,0 +1,10 @@ + + + + + Title + + + + + \ No newline at end of file diff --git a/core/apps/management/templates/common/list.html b/core/apps/management/templates/common/list.html new file mode 100644 index 0000000..9bb2f4f --- /dev/null +++ b/core/apps/management/templates/common/list.html @@ -0,0 +1,33 @@ +{% load custom_filters %} +

{{ title }}

+ + + + {% for field in fields %} + + {% endfor %} + + {% if model_name != "toy_movement" %} + + {% endif %} + + + + {% for obj in objects %} + + {% for field in fields %} + + {% endfor %} + {% if model_name != "toy_movement" %} + + {% endif %} + + {% empty %} + + + + {% endfor %} + +
{{ field_translations|get_item:field|default:field|capfirst }}Amallar
{{ obj|attr:field }} + Tahrirlash +
Hech narsa topilmadi
diff --git a/core/apps/management/templates/common/lists/device_list.html b/core/apps/management/templates/common/lists/device_list.html new file mode 100644 index 0000000..f54162f --- /dev/null +++ b/core/apps/management/templates/common/lists/device_list.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}Aparatlar{% endblock %} + +{% block content %} +

{{ title|default:"Aparatlar" }}

+ +
+ {% for device in devices %} +
+
Address: {{ device.address }}
+
Hudud: {{ device.district.name }}
+ + {% if role == "manager" or role == "businessman" %} + + {% endif %} +
+ {% empty %} +

Hech narsa topilmadi

+ {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/core/apps/management/templates/common/lists/expense_list.html b/core/apps/management/templates/common/lists/expense_list.html new file mode 100644 index 0000000..5277925 --- /dev/null +++ b/core/apps/management/templates/common/lists/expense_list.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% load custom_filters %} + +{% block title %}Xarajatlar{% endblock %} + +{% block content %} +

{{ title|default:"Xarajatlar" }}

+ +
+ {% for obj in expenses %} +
+
Miqdor: {{ obj.amount }}
+
Tur: {{ obj|attr:"expense_type" }}
+ {% if obj.device %}
Aparat: {{ obj.device.name }}
{% else %}{% endif %} + {% if obj.employee %}
Hodim: {{ obj.employee.get_full_name }}
{% else %}{% endif %} +
Yaratgan: {{ obj.created_by.get_full_name }}
+
Tasdiqlanganmi: {% if obj.confirmed_by %}{{ obj.confirmed_by.get_full_name }}{% else %}Yo'q{% endif %}
+
Yaratilgan sana: {{ obj.created_at|date:"d.m.Y H:i" }}
+ + {% if role == "manager" or role == "businessman" %} +
+ {% if not obj.is_confirmed %} + βœ” + βœ– + {% endif %} + {% if role == "businessman" %} + Tahrirlash + {% endif %} +
+ {% endif %} +
+ {% empty %} +

Hech narsa topilmadi

+ {% endfor %} +
+{% endblock %} diff --git a/core/apps/management/templates/common/lists/income_list.html b/core/apps/management/templates/common/lists/income_list.html new file mode 100644 index 0000000..112f2f3 --- /dev/null +++ b/core/apps/management/templates/common/lists/income_list.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} + +{% block title %}Kirimlar{% endblock %} + +{% block content %} +

{{ title|default:"Kirimlar" }}

+ +
+ {% for income in incomes %} +
+
Miqdor: {{ income.amount|default:"-" }}
+
Aparat: {% if income.device %}{{ income.device.address }}{% else %}-{% endif %}
+
Yaratgan: {{ income.created_by.get_full_name }}
+
Yaratilgan sana: {{ income.created_at|date:"d.m.Y H:i" }}
+ + {% if role == "manager" or role == "businessman" %} + + {% endif %} +
+ {% empty %} +

Hech narsa topilmadi

+ {% endfor %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/core/apps/management/templates/common/lists/rent_list.html b/core/apps/management/templates/common/lists/rent_list.html new file mode 100644 index 0000000..566549b --- /dev/null +++ b/core/apps/management/templates/common/lists/rent_list.html @@ -0,0 +1,10 @@ + + + + + Title + + + + + \ No newline at end of file diff --git a/core/apps/management/templates/common/lists/toy_movement_list.html b/core/apps/management/templates/common/lists/toy_movement_list.html new file mode 100644 index 0000000..5bb1a8d --- /dev/null +++ b/core/apps/management/templates/common/lists/toy_movement_list.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}O'yinchoq harakatlari{% endblock %} + +{% block content %} +

{{ title|default:"O'yinchoq harakatlari" }}

+ +{% if role == "manager" or role == "businessman" %} + +
+{% endif %} + +
+ {% for tm in toy_movements %} +
+
Tur: {{ tm.get_movement_type_display }}
+
From: {{ tm.from_warehouse.name }}
+ {% if tm.to_warehouse %} +
To: {{ tm.to_warehouse.name }}
+ {% endif %} + {% if tm.device %} +
Aparat: {{ tm.device.name }}
+ {% endif %} +
Miqdor: {{ tm.quantity }}
+
Yaratgan: {{ tm.created_by.get_full_name }}
+
Sana: {{ tm.created_at|date:"d.m.Y H:i" }}
+
+ {% empty %} +

Hech narsa topilmadi

+ {% endfor %} +
+{% endblock %} diff --git a/core/apps/management/templates/common/lists/user_list.html b/core/apps/management/templates/common/lists/user_list.html new file mode 100644 index 0000000..7548ffe --- /dev/null +++ b/core/apps/management/templates/common/lists/user_list.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block title %}Foydalanuvchilar{% endblock %} + +{% block content %} +

{{ title|default:"Foydalanuvchilar" }}

+ +
+ {% for user in users %} +
+
Ism: {{ user.get_full_name }}
+
Telefon: {{ user.phone }}
+
Rol: {{ user.role }}
+ {% if user.region %} +
Hudud: {{ user.region.name }}
+ {% endif %} + + {% if role != "manager" or user.role != "manager" %} + + {% endif %} +
+ {% empty %} +

Hech narsa topilmadi

+ {% endfor %} +
+{% endblock %} diff --git a/core/apps/management/templates/common/lists/warehouse_list.html b/core/apps/management/templates/common/lists/warehouse_list.html new file mode 100644 index 0000000..9b8fb2d --- /dev/null +++ b/core/apps/management/templates/common/lists/warehouse_list.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Omborlar{% endblock %} + +{% block content %} +

{{ title|default:"Omborlar" }}

+ +
+ {% for wh in warehouses %} +
+
Nomi: {{ wh.name }}
+
Hudud: {{ wh.region.name }}
+
O'yinchoqlar: {{ wh.toys_count }}
+ + {% if role == "businessman" %} + + {% endif %} +
+ {% empty %} +

Hech narsa topilmadi

+ {% endfor %} +
+{% endblock %} diff --git a/core/apps/management/templates/employee/employee_dashboard.html b/core/apps/management/templates/employee/employee_dashboard.html new file mode 100644 index 0000000..941bdb7 --- /dev/null +++ b/core/apps/management/templates/employee/employee_dashboard.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} + +{% block content %} +

Hodim Paneli

+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/core/apps/management/templates/manager/manager_dashboard.html b/core/apps/management/templates/manager/manager_dashboard.html new file mode 100644 index 0000000..8adb945 --- /dev/null +++ b/core/apps/management/templates/manager/manager_dashboard.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} + +{% block content %} +

Manager Paneli

+ + + + + +{% endblock %} diff --git a/core/apps/management/templatetags/__init__.py b/core/apps/management/templatetags/__init__.py new file mode 100644 index 0000000..1e8a839 --- /dev/null +++ b/core/apps/management/templatetags/__init__.py @@ -0,0 +1 @@ +from .custom_filters import * \ No newline at end of file diff --git a/core/apps/management/templatetags/custom_filters.py b/core/apps/management/templatetags/custom_filters.py new file mode 100644 index 0000000..7af953f --- /dev/null +++ b/core/apps/management/templatetags/custom_filters.py @@ -0,0 +1,16 @@ +from django import template + +register = template.Library() + +@register.filter +def attr(obj, name): + value = getattr(obj, name, None) + # use the display method if it exists (for TextChoices) + get_display = getattr(obj, f"get_{name}_display", None) + if callable(get_display): + return get_display() + return value + +@register.filter +def get_item(dictionary, key): + return dictionary.get(key) \ No newline at end of file diff --git a/core/apps/management/tests.py b/core/apps/management/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/core/apps/management/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/core/apps/management/translations/__init__.py b/core/apps/management/translations/__init__.py new file mode 100644 index 0000000..d44a155 --- /dev/null +++ b/core/apps/management/translations/__init__.py @@ -0,0 +1 @@ +from .field_translations import * \ No newline at end of file diff --git a/core/apps/management/translations/field_translations.py b/core/apps/management/translations/field_translations.py new file mode 100644 index 0000000..6252189 --- /dev/null +++ b/core/apps/management/translations/field_translations.py @@ -0,0 +1,14 @@ +FIELD_TRANSLATIONS_UZ = { + "movement_type": "Harakat turi", + "from_warehouse": "Ombor (chiqish)", + "to_warehouse": "Ombor (kirish)", + "device": "Aparat", + "quantity": "OΚ»yinchoqlar soni", + "created_by": "Yaratgan", + "created_at": "Yaratilgan sana", + "name": "Nomi", + "region": "Viloyat", + "district": "Tuman", + "toys_count": "OΚ»yinchoqlar soni", + "monthly_fee": "Oylik toΚ»lov" +} \ No newline at end of file diff --git a/core/apps/management/urls.py b/core/apps/management/urls.py new file mode 100644 index 0000000..1d20de2 --- /dev/null +++ b/core/apps/management/urls.py @@ -0,0 +1,39 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path("businessman/dashboard/", views.businessman_dashboard, name="businessman_dashboard"), + path("manager/dashboard/", views.manager_dashboard, name="manager_dashboard"), + path('employee/dashboard/', views.employee_dashboard, name='employee_dashboard'), + + # Create + path("create/device/", views.create_device, name="create_device"), + path("create/income/", views.create_income, name="create_income"), + path("create/expense/", views.create_expense, name="create_expense"), + path("create/warehouse/", views.create_warehouse, name="create_warehouse"), + path("create/user/", views.create_user, name="create_user"), + path("create/toy-movement/", views.create_toy_movement, name="create_toy_movement"), + path("create/toy-movement/auto/", views.create_toy_movement_auto, name="create_toy_movement_auto"), + + # # List + path("list/device/", views.device_list, name="device_list"), + path("list/income/", views.income_list, name="income_list"), + path("list/expense/", views.expense_list, name="expense_list"), + path("list/warehouse/", views.warehouse_list, name="warehouse_list"), + path("list/user/", views.user_list, name="user_list"), + path("list/toy-movement/", views.toy_movement_list, name="toy_movement_list"), + + # Edit + path("edit/device//", views.edit_device, name="edit_device"), + path("edit/income//", views.edit_income, name="edit_income"), + path("edit/expense//", views.edit_expense, name="edit_expense"), + path("edit/warehouse//", views.edit_warehouse, name="edit_warehouse"), + path("edit/user//", views.edit_user, name="edit_user"), + # path("edit/toy-movement//", views.edit_toy_movement, name="edit_toy_movement"), + + + + #Expense Confirm or Decline + path("expense/confirm//", views.confirm_expense, name="confirm_expense"), + path("expense/decline//", views.decline_expense, name="decline_expense"), +] diff --git a/core/apps/management/views/__init__.py b/core/apps/management/views/__init__.py new file mode 100644 index 0000000..46811a5 --- /dev/null +++ b/core/apps/management/views/__init__.py @@ -0,0 +1,4 @@ +from .businessman_dashboard import * +from .manager_dashboard import * +from .employee_dashboard import * +from .common import * \ No newline at end of file diff --git a/core/apps/management/views/businessman_dashboard.py b/core/apps/management/views/businessman_dashboard.py new file mode 100644 index 0000000..c70a951 --- /dev/null +++ b/core/apps/management/views/businessman_dashboard.py @@ -0,0 +1,11 @@ +from django.shortcuts import render, redirect +from django.contrib.auth.decorators import login_required +from core.apps.management.models import Device, Income, Expense, Warehouse, ToyMovement +from core.apps.accounts.models import User +from core.apps.management.decorators import role_required +from ..translations import FIELD_TRANSLATIONS_UZ + +@login_required +@role_required(["businessman"]) +def businessman_dashboard(request): + return render(request, "businessman/businessman_dashboard.html", {"role": request.user.role}) \ No newline at end of file diff --git a/core/apps/management/views/common/__init__.py b/core/apps/management/views/common/__init__.py new file mode 100644 index 0000000..a6b0c20 --- /dev/null +++ b/core/apps/management/views/common/__init__.py @@ -0,0 +1,5 @@ +from .create import * +from .list import * +from .edit import * +from .confirm_expense import * +from .decline_expense import * \ No newline at end of file diff --git a/core/apps/management/views/common/confirm_expense.py b/core/apps/management/views/common/confirm_expense.py new file mode 100644 index 0000000..92b16b5 --- /dev/null +++ b/core/apps/management/views/common/confirm_expense.py @@ -0,0 +1,17 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import get_object_or_404, redirect +from ...models import Expense +from core.apps.management.decorators import role_required + + +@login_required +@role_required(["manager", "businessman"]) +def confirm_expense(request, pk): + expense = get_object_or_404(Expense, pk=pk) + + if not expense.is_confirmed: + expense.is_confirmed = True + expense.confirmed_by = request.user + expense.save(update_fields=["is_confirmed", "confirmed_by"]) + + return redirect(request.META.get("HTTP_REFERER", "/")) \ No newline at end of file diff --git a/core/apps/management/views/common/create.py b/core/apps/management/views/common/create.py new file mode 100644 index 0000000..3f1b941 --- /dev/null +++ b/core/apps/management/views/common/create.py @@ -0,0 +1,220 @@ +from core.apps.management.forms import DeviceForm, IncomeForm, WarehouseForm, UserCreateForm, ExpenseFormEmployee, \ + ExpenseFormManager, ExpenseFormBusinessman +from django.db import transaction +from django.shortcuts import render, redirect +from core.apps.management.forms import ToyMovementForm, ToyMovementFormEmployee +from django.contrib.auth.decorators import login_required +from core.apps.management.decorators import role_required +from core.apps.management.forms import UserCreateFormManagerToEmployee, UserCreateFormBusinessman +from core.apps.management.models import ToyMovement + +@login_required +@role_required(["manager", "businessman"]) +def create_device(request): + form = DeviceForm(request.POST or None, user=request.user) + if form.is_valid(): + form.save() + return redirect("dashboard") + return render(request, "common/create/device_create.html", { + "form": form, + "title": "Aparat Yaratish" + }) + + +@login_required +def create_income(request): + if request.method == "POST": + form = IncomeForm(request.POST, user=request.user) + if form.is_valid(): + income = form.save(commit=False) + if request.user.role == "employee": + income.amount = None + income.save() + return redirect("income_list") + else: + form = IncomeForm(user=request.user) + return render(request, "common/create/income_create.html", {"form": form}) + + +@login_required +def create_expense(request): + user = request.user + + # select form based on role + if user.role == "employee": + form_class = ExpenseFormEmployee + elif user.role == "manager": + form_class = ExpenseFormManager + else: # businessman or superuser + form_class = ExpenseFormBusinessman + + if request.method == "POST": + form = form_class(request.POST) + if form.is_valid(): + with transaction.atomic(): + expense = form.save(commit=False) + expense.created_by = user + + # AUTO CONFIRM for manager & businessman + if user.role in ["manager", "businessman"]: + expense.is_confirmed = True + expense.confirmed_by = user + + expense.save() + + return redirect("dashboard") + else: + form = form_class() + + return render(request, "common/create/expense_create.html", { + "form": form, + "title": "Xarajat qoΚ»shish", + "user_role": user.role + }) + + +@login_required +@role_required(["businessman"]) +def create_warehouse(request): + form = WarehouseForm(request.POST or None) + if form.is_valid(): + form.save() + return redirect("businessman_dashboard") + + return render(request, "common/create/warehouse_create.html", { + "form": form, + "title": "Sklad Yaratish" + }) + + +@login_required +@role_required(["manager", "businessman"]) +def create_user(request): + if request.user.role == "businessman": + form_class = UserCreateFormBusinessman + form_kwargs = {} + redirect_to = "businessman_dashboard" + + else: # manager + form_class = UserCreateFormManagerToEmployee + form_kwargs = {"manager": request.user} + redirect_to = "manager_dashboard" + + form = form_class(request.POST or None, **form_kwargs) + + if form.is_valid(): + form.save() + return redirect(redirect_to) + + return render(request, "common/create/user_create.html", { + "form": form, + "title": "Foydalanuvchi yaratish", + }) + + +@login_required +def create_toy_movement(request): + user = request.user + + # Choose form based on role + form_class = ToyMovementFormEmployee if user.role == "employee" else ToyMovementForm + + if request.method == "POST": + form = form_class(request.POST, user=user) + if form.is_valid(): + with transaction.atomic(): + movement = form.save(commit=False) + + # Stock validation + from_wh = movement.from_warehouse + if from_wh.toys_count < movement.quantity: + form.add_error("quantity", "Not enough toys in warehouse.") + return render( + request, + "common/create/toy_movement_create.html", + {"form": form, "user_role": user.role} + ) + + # Deduct from source warehouse + from_wh.toys_count -= movement.quantity + from_wh.save() + + # Add to destination warehouse if moving between warehouses + if movement.movement_type == "between_warehouses" and movement.to_warehouse: + to_wh = movement.to_warehouse + to_wh.toys_count += movement.quantity + to_wh.save() + + # Set creator + movement.created_by = user + movement.save() + + return redirect("dashboard") + else: + form = form_class(user=user) + + return render( + request, + "common/create/toy_movement_create.html", + {"form": form, "user_role": user.role} + ) + + + + +@login_required +@role_required(["manager", "businessman"]) +def create_toy_movement_auto(request): + user = request.user + + # Only employees can use this auto-creation + + if request.method == "POST": + # We force movement_type to "between_warehouses" + movement = ToyMovement( + movement_type="between_warehouses", + from_warehouse=user.warehouse, + to_warehouse_id=request.POST.get("to_warehouse"), + device=None, # not used for between_warehouses + quantity=int(request.POST.get("quantity", 0)), + created_by=user + ) + + # Stock validation + from_wh = movement.from_warehouse + if from_wh.toys_count < movement.quantity: + form = ToyMovementForm(user=user) # just render empty form with message + return render( + request, + "common/create/toy_movement_create.html", + { + "form": form, + "user_role": user.role, + "error": "Not enough toys in warehouse." + } + ) + + with transaction.atomic(): + # Update warehouse stock + from_wh.toys_count -= movement.quantity + from_wh.save() + + # Save movement + movement.save() + + return redirect("dashboard") + + # GET request β†’ render the create form + form = ToyMovementForm(user=user) + # Pre-fill movement_type and from_warehouse + form.fields["movement_type"].initial = "between_warehouses" + form.fields["from_warehouse"].initial = user.warehouse + # Optionally, disable editing these fields + form.fields["movement_type"].widget.attrs["readonly"] = True + form.fields["from_warehouse"].widget.attrs["readonly"] = True + + return render( + request, + "common/create/toy_movement_create.html", + {"form": form, "user_role": user.role} + ) \ No newline at end of file diff --git a/core/apps/management/views/common/decline_expense.py b/core/apps/management/views/common/decline_expense.py new file mode 100644 index 0000000..c7150cf --- /dev/null +++ b/core/apps/management/views/common/decline_expense.py @@ -0,0 +1,14 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import get_object_or_404, redirect +from ...models import Expense +from core.apps.management.decorators import role_required + +@login_required +@role_required(["manager", "businessman"]) +def decline_expense(request, pk): + expense = get_object_or_404(Expense, pk=pk) + + if not expense.is_confirmed: + expense.delete() + + return redirect(request.META.get("HTTP_REFERER", "/")) \ No newline at end of file diff --git a/core/apps/management/views/common/edit.py b/core/apps/management/views/common/edit.py new file mode 100644 index 0000000..f47a8ea --- /dev/null +++ b/core/apps/management/views/common/edit.py @@ -0,0 +1,188 @@ +from django.contrib.auth.decorators import login_required +from core.apps.accounts.models import User + +from core.apps.management.forms import DeviceForm, IncomeForm, ExpenseForm, WarehouseForm, UserCreateForm, \ + ToyMovementEmployeeForm, ToyMovementForm, ExpenseFormEmployee, ExpenseFormManager, ExpenseFormBusinessman +from django.shortcuts import render, redirect, get_object_or_404 +from core.apps.management.models import Device, Income, Expense, Warehouse, ToyMovement +from django.db import transaction +from django.contrib.auth import logout +from core.apps.management.decorators import role_required +from core.apps.management.forms import UserEditFormBusinessman, UserEditFormManagerToEmployee + +@login_required +@role_required(["manager", "businessman"]) +def edit_device(request, pk): + device = get_object_or_404(Device, pk=pk) + form = DeviceForm(request.POST or None, instance=device, user=request.user) + if form.is_valid(): + form.save() + return redirect("device_list") + return render(request, "common/edit/device_edit.html", {"form": form, "title": "Aparatni tahrirlash"}) + + +@login_required +@role_required(["manager", "businessman"]) +def edit_income(request, pk): + income = get_object_or_404(Income, pk=pk) + + if request.method == "POST": + form = IncomeForm(request.POST, instance=income) + if form.is_valid(): + form.save() + return redirect("income_list") + else: + form = IncomeForm(instance=income) + + return render(request, "common/edit/income_edit.html", { + "form": form, + "title": "Kirimni tahrirlash" + }) + +@login_required +@role_required(["businessman"]) +def edit_expense(request, pk): + user = request.user + expense = get_object_or_404(Expense, pk=pk) + + # select form based on role + if user.role == "employee": + form_class = ExpenseFormEmployee + elif user.role == "manager": + form_class = ExpenseFormManager + else: # businessman or superuser + form_class = ExpenseFormBusinessman + + if request.method == "POST": + form = form_class(request.POST, instance=expense) + if form.is_valid(): + with transaction.atomic(): + expense = form.save(commit=False) + expense.save() + + # redirect based on role + if user.role == "employee": + return redirect("expense_list") + elif user.role == "manager": + return redirect("expense_list") + else: + return redirect("expense_list") + else: + form = form_class(instance=expense) + + return render(request, "common/edit/expense_edit.html", { + "form": form, + "title": "Xarajatni tahrirlash", + "user_role": user.role + }) + +@login_required +@role_required(["businessman"]) +def edit_warehouse(request, pk): + warehouse = get_object_or_404(Warehouse, pk=pk) + form = WarehouseForm(request.POST or None, instance=warehouse) + if form.is_valid(): + form.save() + return redirect("warehouse_list") + return render(request, "common/edit/warehouse_edit.html", {"form": form, "title": "Omborni tahrirlash"}) + +@login_required +@role_required(["manager", "businessman"]) +def edit_user(request, pk): + user = get_object_or_404(User, pk=pk) + + if request.user.role == "manager" and user.role == "manager": + return redirect("user_list") + + if request.user.role == "businessman": + form_class = UserEditFormBusinessman + form_kwargs = {} + redirect_to = "user_list" + + else: # manager + form_class = UserEditFormManagerToEmployee + form_kwargs = {"manager": request.user} + redirect_to = "user_list" + + form = form_class( + request.POST or None, + instance=user, + **form_kwargs + ) + + if form.is_valid(): + form.save() + + # if user edited himself β†’ logout β†’ normal login + if request.user.pk == user.pk: + logout(request) + return redirect("login") + + return redirect(redirect_to) + + return render(request, "common/edit/user_edit.html", { + "form": form, + "title": "Foydalanuvchini tahrirlash", + }) + +# @role_required(["businessman"]) +# @login_required +# def edit_toy_movement(request, pk): +# movement = get_object_or_404(ToyMovement, pk=pk) +# user = request.user +# +# # auto-detect form based on role +# if user.role == "employee": +# form_class = ToyMovementEmployeeForm +# else: +# form_class = ToyMovementForm +# +# if request.method == "POST": +# form = form_class(request.POST, instance=movement) +# if form.is_valid(): +# with transaction.atomic(): +# movement = form.save(commit=False) +# +# # Employee logic +# if user.role == "employee": +# movement.movement_type = ToyMovement.FROM_WAREHOUSE +# movement.to_warehouse = None +# +# # Stock validation +# from_wh = movement.from_warehouse +# if from_wh.toys_count < movement.quantity: +# form.add_error("quantity", "Not enough toys in warehouse.") +# return render( +# request, +# "common/toy_movement_edit.html", +# {"form": form, "user_role": user.role, "title": "OΚ»yinchoq harakatini tahrirlash"} +# ) +# +# # Update counts +# from_wh.toys_count -= movement.quantity +# from_wh.save() +# +# if movement.device: +# movement.device.toys_count += movement.quantity +# movement.device.save() +# +# movement.created_by = user +# movement.save() +# +# # role-based redirect +# if user.role == "employee": +# return redirect("employee_dashboard") +# elif user.role == "manager": +# return redirect("manager_dashboard") +# elif user.role == "businessman": +# return redirect("businessman_dashboard") +# else: +# return redirect("login") +# else: +# form = form_class(instance=movement) +# +# return render( +# request, +# "common/toy_movement_edit.html", +# {"form": form, "user_role": user.role, "title": "OΚ»yinchoq harakatini tahrirlash"} +# ) \ No newline at end of file diff --git a/core/apps/management/views/common/list.py b/core/apps/management/views/common/list.py new file mode 100644 index 0000000..57a6e27 --- /dev/null +++ b/core/apps/management/views/common/list.py @@ -0,0 +1,90 @@ +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from core.apps.management.models import Device, Income, Expense, Warehouse, ToyMovement +from core.apps.accounts.models import User +from core.apps.management.decorators import role_required + +@login_required +@role_required(["manager", "businessman"]) +def user_list(request): + if request.user.role == "businessman": + users = User.objects.exclude(role__in=["superuser", "businessman"]) + elif request.user.role == "manager": + users = User.objects.filter(region=request.user.region)\ + .exclude(role__in=["superuser", "businessman"]) + else: + users = User.objects.none() + context = { + "users": users, + "role": request.user.role, + } + return render(request, "common/lists/user_list.html", context) + + +@login_required +@role_required(["manager", "businessman"]) +def device_list(request): + if request.user.role == "businessman": + devices = Device.objects.all() + elif request.user.role == "manager": + devices = Device.objects.select_related("district", "district__region").filter(district__region = request.user.region) + else: + devices = Device.objects.none() + context = { + "devices": devices, + "role": request.user.role, + } + return render(request, "common/lists/device_list.html", context) + + +@login_required +def expense_list(request): + expenses = Expense.objects.select_related("created_by", "confirmed_by").order_by("-created_at") + context = { + "expenses": expenses, + "role": request.user.role, + } + return render(request, "common/lists/expense_list.html", context) + + +@login_required +def income_list(request): + incomes = Income.objects.select_related("device", "created_by", "created_by__region").order_by("-created_at") + if request.user.role == "employee": + incomes = incomes.filter(created_by=request.user) + elif request.user.role == "manager": + incomes = incomes.filter(created_by__region_id=request.user.region_id) + + context = { + "incomes": incomes, + "role": request.user.role, + } + return render(request, "common/lists/income_list.html", context) + +@login_required +@role_required(["manager", "businessman"]) +def warehouse_list(request): + warehouses = Warehouse.objects.all() + if request.user.role == "manager": + warehouses = warehouses.filter(region_id=request.user.region_id) + context = { + "warehouses": warehouses, + "role": request.user.role, + } + return render(request, "common/lists/warehouse_list.html", context) + + +@login_required +def toy_movement_list(request): + toy_movements = ToyMovement.objects.select_related( + "from_warehouse", "to_warehouse", "device", "created_by" + ).order_by("-created_at") + if request.user.role == "employee": + toy_movements = toy_movements.filter(created_by=request.user) + elif request.user.role == "manager": + toy_movements = toy_movements.filter(created_by__region_id=request.user.region_id) + context = { + "toy_movements": toy_movements, + "role": request.user.role, + } + return render(request, "common/lists/toy_movement_list.html", context) \ No newline at end of file diff --git a/core/apps/management/views/employee_dashboard.py b/core/apps/management/views/employee_dashboard.py new file mode 100644 index 0000000..f55a360 --- /dev/null +++ b/core/apps/management/views/employee_dashboard.py @@ -0,0 +1,10 @@ +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from core.apps.management.models import Income, Expense, ToyMovement +from core.apps.management.decorators import role_required +from ..translations import FIELD_TRANSLATIONS_UZ + +@login_required +@role_required(["employee"]) +def employee_dashboard(request): + return render(request, "employee/employee_dashboard.html", {"role": request.user.role}) \ No newline at end of file diff --git a/core/apps/management/views/manager_dashboard.py b/core/apps/management/views/manager_dashboard.py new file mode 100644 index 0000000..220cf4b --- /dev/null +++ b/core/apps/management/views/manager_dashboard.py @@ -0,0 +1,11 @@ +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from core.apps.management.models import Device, Income, Expense, Warehouse, ToyMovement +from core.apps.accounts.models import User +from core.apps.management.decorators import role_required +from ..translations import FIELD_TRANSLATIONS_UZ + +@login_required +@role_required(["manager"]) +def manager_dashboard(request): + return render(request, "manager/manager_dashboard.html", {"role": request.user.role}) \ 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..636abac --- /dev/null +++ b/core/apps/shared/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1.3 on 2025-07-11 15:00 + +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')), + ('key', models.CharField(verbose_name='key')), + ], + 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(verbose_name='key')), + ('value', models.CharField(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/0002_settingsmodel_created_at_settingsmodel_description_and_more.py b/core/apps/shared/migrations/0002_settingsmodel_created_at_settingsmodel_description_and_more.py new file mode 100644 index 0000000..9d512b7 --- /dev/null +++ b/core/apps/shared/migrations/0002_settingsmodel_created_at_settingsmodel_description_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.3 on 2025-07-12 05:19 + +import django.contrib.postgres.fields +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shared', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='settingsmodel', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='settingsmodel', + name='description', + field=models.TextField(blank=True, null=True, verbose_name='description'), + ), + migrations.AddField( + model_name='settingsmodel', + name='is_public', + field=models.BooleanField(default=False, verbose_name='is public'), + ), + migrations.AddField( + model_name='settingsmodel', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + migrations.AlterField( + model_name='optionsmodel', + name='key', + field=models.CharField(max_length=255, verbose_name='key'), + ), + migrations.AlterField( + model_name='optionsmodel', + name='value', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, verbose_name='value'), size=None, verbose_name='value'), + ), + ] diff --git a/core/apps/shared/migrations/__init__.py b/core/apps/shared/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/shared/models/__init__.py b/core/apps/shared/models/__init__.py new file mode 100644 index 0000000..134e613 --- /dev/null +++ b/core/apps/shared/models/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/models/settings.py b/core/apps/shared/models/settings.py new file mode 100644 index 0000000..98537a0 --- /dev/null +++ b/core/apps/shared/models/settings.py @@ -0,0 +1,31 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel + + +class SettingsModel(AbstractBaseModel): + key = models.CharField(_("key")) + is_public = models.BooleanField(_("is public"), default=False) + description = models.TextField(_("description"), blank=True, null=True) + + class Meta: + db_table = "settings" + verbose_name = _("Settings") + verbose_name_plural = _("Settings") + + +class OptionsModel(models.Model): + settings = models.ForeignKey( + "SettingsModel", verbose_name=_("settings"), on_delete=models.CASCADE, related_name="options" + ) + key = models.CharField(_("key"), max_length=255) + value = ArrayField( + models.CharField(_("value"), max_length=255), + verbose_name=_("value"), + ) + + class Meta: + db_table = "options" + verbose_name = _("Options") + verbose_name_plural = _("Options") diff --git a/core/apps/shared/serializers/__init__.py b/core/apps/shared/serializers/__init__.py new file mode 100644 index 0000000..fdf02cf --- /dev/null +++ b/core/apps/shared/serializers/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/serializers/settings/__init__.py b/core/apps/shared/serializers/settings/__init__.py new file mode 100644 index 0000000..134e613 --- /dev/null +++ b/core/apps/shared/serializers/settings/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/serializers/settings/settings.py b/core/apps/shared/serializers/settings/settings.py new file mode 100644 index 0000000..37fd78d --- /dev/null +++ b/core/apps/shared/serializers/settings/settings.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + + +class ListLanguageSerializer(serializers.Serializer): + code = serializers.CharField(read_only=True) + name = serializers.CharField(read_only=True) + is_default = serializers.BooleanField(read_only=True, default=False) diff --git a/core/apps/shared/tests/__init__.py b/core/apps/shared/tests/__init__.py new file mode 100644 index 0000000..838c01f --- /dev/null +++ b/core/apps/shared/tests/__init__.py @@ -0,0 +1 @@ +from .test_settings import * # noqa diff --git a/core/apps/shared/tests/test_settings.py b/core/apps/shared/tests/test_settings.py new file mode 100644 index 0000000..d09910e --- /dev/null +++ b/core/apps/shared/tests/test_settings.py @@ -0,0 +1,20 @@ +import pytest +from django.urls import reverse +from rest_framework.test import APIClient + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def settings_urls(): + return { + "languages": reverse("settings-languages"), + } + + +def test_languages(api_client, settings_urls): + response = api_client.get(settings_urls["languages"]) + assert 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..e537621 --- /dev/null +++ b/core/services/otp.py @@ -0,0 +1,168 @@ +#type: ignore +import requests +from config.env import env + + +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): + """[TODO:summary] + + [TODO:description] + + Args: + api_path ([TODO:type]): [TODO:description] + data ([TODO:type]): [TODO:description] + method ([TODO:type]): [TODO:description] + headers ([TODO:type]): [TODO:description] + + Raises: + Exception: [TODO:description] + """ + 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): + """[TODO:summary] + + [TODO:description] + """ + data = {"email": self.email, "password": self.password} + + return self.request(self.methods["auth_login"], data=data, method=self.POST) + + def refresh_token(self): + """[TODO:summary] + + [TODO:description] + """ + 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): + """[TODO:summary] + + [TODO:description] + """ + 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): + """[TODO:summary] + + [TODO:description] + + Args: + first_name ([TODO:type]): [TODO:description] + phone_number ([TODO:type]): [TODO:description] + group ([TODO:type]): [TODO:description] + """ + 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): + """Sms yuborish + + Args: + phone_number (str): telefon no'mer + message (str): xabar + """ + 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..bd0d7c7 --- /dev/null +++ b/core/services/sms.py @@ -0,0 +1,84 @@ +#type: ignore +import random +from datetime import datetime, timedelta + +from config.env import env +from core.apps.accounts.tasks.sms import SendConfirm +from django_core import exceptions, models + + +class SmsService: + @staticmethod + def send_confirm(phone): + """Tasdiqlash ko'dini yuborish + + Args: + phone (str): telefon no'mer + + Raises: + exceptions.SmsException: [TODO:description] + """ + # TODO: Deploy this change when deploying -> code = random.randint(1000, 9999) # noqa + if env.bool("OTP_PROD", False): + code = "".join(str(random.randint(0, 9)) for _ in range(env.int("OTP_SIZE", 4))) + else: + code = env.int("OTP_DEFAULT", 1111) + + sms_confirm, status = models.SmsConfirm.objects.get_or_create(phone=phone, defaults={"code": code}) + + sms_confirm.sync_limits() + + if sms_confirm.resend_unlock_time is not None: + expired = sms_confirm.interval(sms_confirm.resend_unlock_time) + exception = exceptions.SmsException(f"Resend blocked, try again in {expired}", expired=expired) + raise exception + + sms_confirm.code = code + sms_confirm.try_count = 0 + sms_confirm.resend_count += 1 + sms_confirm.phone = phone + sms_confirm.expire_time = datetime.now() + timedelta(seconds=models.SmsConfirm.SMS_EXPIRY_SECONDS) # noqa + sms_confirm.resend_unlock_time = datetime.now() + timedelta( + seconds=models.SmsConfirm.SMS_EXPIRY_SECONDS + ) # noqa + sms_confirm.save() + + SendConfirm.delay(phone, code) + return True + + @staticmethod + def check_confirm(phone, code): + """Tasdiqlash ko'dini haqiqiyligini tekshirish + + Args: + phone ([TODO:type]): [TODO:description] + code ([TODO:type]): [TODO:description] + + Raises: + exceptions.SmsException: [TODO:description] + exceptions.SmsException: [TODO:description] + exceptions.SmsException: [TODO:description] + exceptions.SmsException: [TODO:description] + """ + sms_confirm = models.SmsConfirm.objects.filter(phone=phone).first() + + if sms_confirm is None: + raise exceptions.SmsException("Invalid confirmation code") + + sms_confirm.sync_limits() + + if sms_confirm.is_expired(): + raise exceptions.SmsException("Time for confirmation has expired") + + if sms_confirm.is_block(): + expired = sms_confirm.interval(sms_confirm.unlock_time) + raise exceptions.SmsException(f"Try again in {expired}") + + if str(sms_confirm.code) == str(code): + sms_confirm.delete() + return True + + sms_confirm.try_count += 1 + sms_confirm.save() + + raise exceptions.SmsException("Invalid confirmation code") diff --git a/core/services/user.py b/core/services/user.py new file mode 100644 index 0000000..31e4830 --- /dev/null +++ b/core/services/user.py @@ -0,0 +1,64 @@ +from datetime import datetime + +from core.services import sms +from django.contrib.auth import get_user_model, hashers +from django.utils.translation import gettext as _ +from django_core import exceptions +from rest_framework.exceptions import PermissionDenied +from rest_framework_simplejwt import tokens + + +class UserService(sms.SmsService): + def get_token(self, user): + refresh = tokens.RefreshToken.for_user(user) + + return { + "refresh": str(refresh), + "access": str(refresh.access_token), + } + + def create_user(self, phone, first_name, last_name, password): + get_user_model().objects.update_or_create( + phone=phone, + defaults={ + "phone": phone, + "first_name": first_name, + "last_name": last_name, + "password": hashers.make_password(password), + }, + ) + + def send_confirmation(self, phone) -> bool: + try: + self.send_confirm(phone) + return True + except exceptions.SmsException as e: + raise PermissionDenied(_("Qayta sms yuborish uchun kuting: {}").format(e.kwargs.get("expired"))) + except Exception: + raise PermissionDenied(_("Serverda xatolik yuz berdi")) + + def validate_user(self, user) -> dict: + """ + Create user if user not found + """ + if user.validated_at is None: + user.validated_at = datetime.now() + user.save() + token = self.get_token(user) + return token + + def is_validated(self, user) -> bool: + """ + User is validated check + """ + if user.validated_at is not None: + return True + return False + + def change_password(self, phone, password): + """ + Change password + """ + user = get_user_model().objects.filter(phone=phone).first() + user.set_password(password) + user.save() diff --git a/core/utils/__init__.py b/core/utils/__init__.py new file mode 100644 index 0000000..f10075c --- /dev/null +++ b/core/utils/__init__.py @@ -0,0 +1,3 @@ +from .cache import * # noqa +from .console import * # noqa +from .core import * # noqa diff --git a/core/utils/storage.py b/core/utils/storage.py new file mode 100644 index 0000000..50e6d33 --- /dev/null +++ b/core/utils/storage.py @@ -0,0 +1,33 @@ +from typing import Optional, Union + +from config.env import env + + +class Storage: + + storages = ["AWS", "MINIO", "FILE", "STATIC"] + + def __init__(self, storage: Union[str], storage_type: Union[str] = "default") -> None: + self.storage = storage + self.sorage_type = storage_type + if storage not in self.storages: + raise ValueError(f"Invalid storage type: {storage}") + + def get_backend(self) -> Optional[str]: + match self.storage: + case "AWS" | "MINIO": + return "storages.backends.s3boto3.S3Boto3Storage" + case "FILE": + return "django.core.files.storage.FileSystemStorage" + case "STATIC": + return "django.contrib.staticfiles.storage.StaticFilesStorage" + + def get_options(self) -> Optional[str]: + match self.storage: + case "AWS" | "MINIO": + if self.sorage_type == "default": + return {"bucket_name": env.str("STORAGE_BUCKET_MEDIA")} + elif self.sorage_type == "static": + return {"bucket_name": env.str("STORAGE_BUCKET_STATIC")} + case _: + return {} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d90b642 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +networks: + aparat: + driver: bridge + +volumes: + pg_data: null + pycache: null + +services: + nginx: + networks: + - aparat + 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: + env_file: + - .env + networks: + - aparat + build: + context: . + dockerfile: ./docker/Dockerfile.web + restart: always + environment: + - PYTHONPYCACHEPREFIX=/var/cache/pycache + - SCRIPT=${SCRIPT:-entrypoint.sh} + volumes: + - .:/code + - pycache:/var/cache/pycache + depends_on: + - db + - redis + db: + networks: + - aparat + image: postgres:16 + restart: always + environment: + POSTGRES_DB: ${DB_NAME:-django} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-2309} + volumes: + - pg_data:/var/lib/postgresql/data + redis: + networks: + - aparat + restart: always + + image: redis \ No newline at end of file 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..24b1a6f --- /dev/null +++ b/docker/Dockerfile.web @@ -0,0 +1,20 @@ +FROM jscorptech/django:v1.0.0 + +ARG SCRIPT="entrypoint.sh" +ENV SCRIPT=$SCRIPT + +WORKDIR /code + +COPY requirements.txt /code/requirements.txt + +RUN uv pip install -r requirements.txt + +COPY ./ /code + +COPY ./resources/scripts/$SCRIPT /code/$SCRIPT + +RUN chmod +x /code/resources/scripts/$SCRIPT + +CMD sh /code/resources/scripts/$SCRIPT + + diff --git a/jst.json b/jst.json new file mode 100644 index 0000000..09b00d0 --- /dev/null +++ b/jst.json @@ -0,0 +1,9 @@ +{ + "dirs": { + "apps": "./core/apps/", + "locale": "./resources/locale/" + }, + "stubs": {}, + "apps": "core.apps.", + "jst": true +} 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..ac16523 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[tool.black] +line-length = 120 + +[tool.isort] +profile = "black" +line_length = 120 + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.local" +python_files = "tests.py test_*.py *_tests.py" +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", + "ignore::ResourceWarning", + "ignore::Warning" # This line will ignore all warnings +] + + +[tool.flake8] +max-line-length = 120 +ignore = ["E701", "E704", "W503"] + +[tool.pyright] +typeCheckingMode = "basic" +reportMissingImports = false +reportMissingTypeStubs = false +pythonVersion = "3.12" +enableReachabilityAnalysis = false diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..575ce0a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,49 @@ +backports.tarfile==1.2.0 +celery==5.4.0 +django-cors-headers==4.6.0 +django-environ==0.11.2 +django-extensions==3.2.3 +django-filter==24.3 +django-redis==5.4.0 +django-unfold==0.65.0 +djangorestframework-simplejwt==5.3.1 +drf-spectacular==0.28.0 +importlib-metadata==8.5.0 +importlib-resources==6.4.5 +inflect==7.3.1 +jaraco.collections==5.1.0 +packaging==24.2 +pip-chill==1.0.3 +platformdirs==4.3.6 +psycopg2-binary==2.9.10 +tomli==2.2.1 +uvicorn==0.32.1 +jst-django-core~=1.2.2 +rich +pydantic +bcrypt +pytest-django +requests +model_bakery + + + + + + + + + + +# !NOTE: on-server +# gunicorn + + +# !NOTE: on-storage +# django-storages +# boto3 + + +# !NOTE: on-websocket +# websockets +# channels-redis diff --git a/resources/.gitignore b/resources/.gitignore new file mode 100644 index 0000000..7627088 --- /dev/null +++ b/resources/.gitignore @@ -0,0 +1 @@ +staticfiles/ \ No newline at end of file diff --git a/resources/docs/github-actions-deploy.md b/resources/docs/github-actions-deploy.md new file mode 100644 index 0000000..c7164e3 --- /dev/null +++ b/resources/docs/github-actions-deploy.md @@ -0,0 +1,214 @@ + +# GitHub Actions Deploy.yaml Tushuntirish + +`.github/workflows/deploy.yaml` faylidagi har bir qismning tushuntirishi: + +```yaml +name: Deploy to Production + +on: + push: + branches: + - main + +env: + PROJECT_NAME: aparat # O'ZGARTIRING: Loyihangiz nomi + + +permissions: + contents: write + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: testdb + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Copy env + run: | + cp .env.example .env + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./docker/Dockerfile.web + push: false + load: true + tags: ${{ env.PROJECT_NAME }}:test + no-cache: true + + - name: Run migrations and tests + run: | + docker run --rm \ + --network host \ + -e DB_HOST=localhost \ + -e DB_PORT=5432 \ + -e DB_NAME=testdb \ + -e DB_USER=postgres \ + -e REDIS_URL=redis://localhost:6379 \ + -e DB_PASSWORD=postgres \ + -e DJANGO_SETTINGS_MODULE=config.settings.test \ + ${{ env.PROJECT_NAME }}:test \ + sh -c "python manage.py migrate && pytest -v" + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Tag and push to Docker Hub + run: | + docker tag ${{ env.PROJECT_NAME }}:test ${{ secrets.DOCKER_USERNAME }}/${{ env.PROJECT_NAME }}:latest + docker tag ${{ env.PROJECT_NAME }}:test ${{ secrets.DOCKER_USERNAME }}/${{ env.PROJECT_NAME }}:${{ github.run_number }} + docker push ${{ secrets.DOCKER_USERNAME }}/${{ env.PROJECT_NAME }}:latest + docker push ${{ secrets.DOCKER_USERNAME }}/${{ env.PROJECT_NAME }}:${{ github.run_number }} + echo "SUCCESS TAGS: latest, ${{ github.run_number }}" + + - name: Update stack.yaml and version + run: | + sed -i 's|image: ${{ secrets.DOCKER_USERNAME }}/${{ env.PROJECT_NAME }}:.*|image: ${{ secrets.DOCKER_USERNAME }}/${{ env.PROJECT_NAME }}:${{ github.run_number }}|' stack.yaml + sed -i 's/return HttpResponse("OK.*"/return HttpResponse("OK: #${{ github.sha }}"/' config/urls.py + + - name: Commit and push updated version + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add . + git commit -m "πŸ”„ Update image to ${{ github.run_number }} [CI SKIP]" || echo "No changes" + git pull origin main --rebase + git push origin main + + - name: Deploy to server via SSH + uses: appleboy/ssh-action@v1.2.2 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + # key: ${{ secrets.KEY }} + password: ${{ secrets.PASSWORD }} + port: ${{ secrets.PORT }} + script: | + PROJECTS=/opt/projects/ + DIR=/opt/projects/${{ env.PROJECT_NAME }}/ + + if [ -d "$PROJECTS" ]; then + echo "projects papkasi mavjud" + else + mkdir -p $PROJECTS + echo "projects papkasi yaratildi" + fi + + if [ -d "$DIR" ]; then + echo "loyiha mavjud" + else + cd $PROJECTS + git clone git@github.com:${{ github.repository }}.git ${{ env.PROJECT_NAME }} + echo "Clone qilindi"; + fi + + cd $DIR + git fetch origin main + git reset --hard origin/main + cp .env.example .env + + update_env() { + local env_file=".env" + cp .env.example "$env_file" + + for kv in "$@"; do + local key="${kv%%=*}" + local value="${kv#*=}" + sed -i "s|^$key=.*|$key=$value|" "$env_file" + done + } + + export PORT=8000 + docker stack deploy -c stack.yaml ${{ env.PROJECT_NAME }} +``` + +## O'zgartirish Kerak Bo'lgan Joylar + +### 1. PROJECT_NAME +```yaml +env: + PROJECT_NAME: myproject # Loyihangiz nomi +``` + +### 2. Branch nomi +```yaml +on: + push: + branches: + - main # Agar 'master' bo'lsa, o'zgartiring +``` + +### 3. Database test sozlamalari +```yaml +services: + postgres: + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: testdb +``` + +### 4. Django settings module +```yaml +-e DJANGO_SETTINGS_MODULE=config.settings.test # Loyihangizga mos o'zgartiring +``` + +### 5. Domain va allowed hosts +```bash +update_env \ + "ALLOWED_HOSTS=127.0.0.1,web,yourdomain.com" \ + "CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8081,https://yourdomain.com" \ +``` + +### 6. Server path +```bash +PROJECTS=/opt/projects/ # Serveringizdagi katalog +DIR=/opt/projects/${{ env.PROJECT_NAME }}/ +``` + +## GitHub Secrets + +Repository Settings β†’ Secrets and variables β†’ Actions: + +- `DOCKER_USERNAME` - Docker Hub username +- `DOCKER_PASSWORD` - Docker Hub token +- `HOST` - Server IP +- `USERNAME` - SSH user +- `KEY` - SSH private key +- `PORT` - SSH port (22) + diff --git a/resources/docs/pre-push.md b/resources/docs/pre-push.md new file mode 100644 index 0000000..93eb392 --- /dev/null +++ b/resources/docs/pre-push.md @@ -0,0 +1,34 @@ +# jst pre-push o’rnatish + +`pre-push vazifasi`: gitga push qilishdan avval testlarni avtomatik bajarib barcha testlardan muvofaqiyatli o’tsa push qiladi + +# O’rnatish + +`.git/hooks/pre-push` faylini yarating va manabu ko’dlarni fayilga yozing + +```bash +#!/bin/bash + +echo "πŸš€ Testlar ishga tushmoqda (Docker konteyner ichida)..." + +docker compose run --rm -T web pytest -v + +RESULT=$? + +if [ $RESULT -ne 0 ]; then + echo "❌ Testlar muvaffaqiyatsiz tugadi. Push bekor qilindi." + exit 1 +fi + +echo "βœ… Barcha testlar muvaffaqiyatli oβ€˜tdi. Pushga ruxsat berildi." +exit 0 + +``` + +fayilga kerakli permissionlarni bering + +```bash +sudo chmod +x .git/hooks/pre-push +``` + +va hammasi tayyor 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..68a9769 --- /dev/null +++ b/resources/layout/Dockerfile.alpine @@ -0,0 +1,19 @@ +FROM python:3.13-alpine + +ENV PYTHONPYCACHEPREFIX=/dev/null +ENV UV_CACHE_DIR=/root/.cache/uv +ENV UV_LINK_MODE=copy +ENV VENV_PATH=/opt/venv + +RUN apk update && apk add --no-cache git gettext curl netcat-openbsd + +WORKDIR /code + +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + +ENV PATH="/root/.cargo/bin:$VENV_PATH/bin:/root/.local/bin:$PATH" + +COPY requirements.txt /code/requirements.txt +RUN uv venv $VENV_PATH +RUN --mount=type=cache,target=/root/.cache/uv uv 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/Jenkinsfile b/resources/layout/Jenkinsfile new file mode 100644 index 0000000..9d5487c --- /dev/null +++ b/resources/layout/Jenkinsfile @@ -0,0 +1,188 @@ +pipeline { + agent any + + environment { + PROD_ENV = "/opt/env/.env.aparat" + IMAGE_NAME = "aparat" + TEST_TAG = "test" + PROD_TAG = "latest" + CONTAINER_DB = "aparat_db_test" + CONTAINER_WEB = "aparat_web_test" + CONTAINER_REDIS = "aparat_redis_test" + STACK_NAME = "aparat" + } + + stages { + stage('Check Commit Message') { + steps { + script { + def commitMsg = sh( + script: "git log -1 --pretty=%B", + returnStdout: true + ).trim() + + if (commitMsg.contains("[ci skip]")) { + echo "Commit message contains [ci skip], aborting pipeline 🚫" + currentBuild.result = 'ABORTED' + error("Pipeline aborted because of [ci skip]") + } + } + } + } + stage('Checkout Code') { + steps { + git branch: 'main', credentialsId: 'ssh', url: 'git@github.com:JscorpTech/aparat.git' + } + } + stage('Prepare') { + steps { + script { + env.GIT_MESSAGE = sh( + script: "git log -1 --pretty=%B ${env.GIT_COMMIT}", + returnStdout: true + ).trim() + } + } + } + stage("Update files") { + when { + expression { currentBuild.currentResult == "SUCCESS" } + } + steps { + withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh """ + sed -i 's|image: ${DOCKER_USER}/${IMAGE_NAME}:.*|image: ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER}|' stack.yaml + sed -i 's/return HttpResponse("OK.*"/return HttpResponse("OK: #${GIT_COMMIT}"/' config/urls.py + """ + // git config --global user.email "admin@jscorp.uz" + // git config --global user.name "Jenkins" + // if ! git diff --quiet stack.yaml; then + // git add stack.yaml + // git commit -m "feat(swarm) Update image tag to ${BUILD_NUMBER} [ci skip]" + // git push origin main + // else + // echo "No changes in stack.yaml" + // fi + } + + } + } + stage('Build Image') { + steps { + sh ''' + if [ -e ${PROD_ENV} ]; then + echo env exists + else + mkdir -p $(dirname ${PROD_ENV}) + cp ./.env.example ${PROD_ENV} + fi + cp ${PROD_ENV} ./.env + ''' + sh """ + docker build -t ${IMAGE_NAME}:${PROD_TAG} --build-arg SCRIPT=entrypoint-server.sh -f ./docker/Dockerfile.web . + """ + } + } + + + stage('Start Test DB') { + steps { + sh """ + docker run -d --rm --name ${CONTAINER_DB} -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=testdb postgres:16 + docker run -d --rm --name ${CONTAINER_REDIS} redis + echo "⏳ Waiting for database..." + for i in {1..30}; do + if docker exec ${CONTAINER_DB} pg_isready -U postgres >/dev/null 2>&1; then + echo "βœ… Database ready" + break + fi + echo "Database not ready yet... retrying..." + sleep 2 + done + """ + } + } + + stage('Run Migrations & Tests') { + steps { + sh """ + docker run --rm --name ${CONTAINER_WEB} --link ${CONTAINER_DB}:db --link ${CONTAINER_REDIS}:redis \ + -e DB_HOST=db \ + -e DB_PORT=5432 \ + -e DB_NAME=testdb \ + -e DB_USER=postgres \ + -e DB_PASSWORD=postgres \ + -e DJANGO_SETTINGS_MODULE=config.settings.test \ + ${IMAGE_NAME}:${PROD_TAG} \ + sh -c "python manage.py migrate && pytest -v" + """ + } + } + + stage('Publish to DockerHub') { + when { + expression { currentBuild.currentResult == "SUCCESS" } + } + steps { + withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + echo "${DOCKER_PASS}" | docker login -u "${DOCKER_USER}" --password-stdin + docker tag ${IMAGE_NAME}:${PROD_TAG} ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER} + docker tag ${IMAGE_NAME}:${PROD_TAG} ${DOCKER_USER}/${IMAGE_NAME}:${PROD_TAG} + docker push ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER} + docker push ${DOCKER_USER}/${IMAGE_NAME}:${PROD_TAG} + ''' + } + } + } + stage('Deploy stack') { + when { + expression { currentBuild.currentResult == "SUCCESS" } + } + steps { + withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + docker stack deploy -c stack.yaml ${STACK_NAME} + ''' + } + } + } + + } + + post { + always { + sh """ + docker stop ${CONTAINER_DB} || true + docker stop ${CONTAINER_REDIS} || true + """ + echo "Pipeline finished: ${currentBuild.currentResult}" + } + + success { + withCredentials([ + string(credentialsId: 'bot-token', variable: 'BOT_TOKEN'), + string(credentialsId: 'chat-id', variable: 'CHAT_ID') + ]) { + sh ''' + curl -s -X POST https://api.telegram.org/bot${BOT_TOKEN}/sendMessage \ + -d chat_id=${CHAT_ID} \ + -d text="βœ… SUCCESS: ${JOB_NAME} #${BUILD_NUMBER}\nRepo: ${GIT_URL}\nBranch: ${GIT_BRANCH}\nCommit: ${GIT_COMMIT}\nMessage: ${GIT_MESSAGE}" + ''' + } + } + + failure { + withCredentials([ + string(credentialsId: 'bot-token', variable: 'BOT_TOKEN'), + string(credentialsId: 'chat-id', variable: 'CHAT_ID') + ]) { + sh ''' + curl -s -X POST https://api.telegram.org/bot${BOT_TOKEN}/sendMessage \ + -d chat_id=${CHAT_ID} \ + -d text="🚨 FAILED: ${JOB_NAME} #${BUILD_NUMBER}\nRepo: ${GIT_URL}\nBranch: ${GIT_BRANCH}\nCommit: ${GIT_COMMIT}\nMessage: ${GIT_MESSAGE}" + ''' + } + } + } +} diff --git a/resources/layout/docker-compose.prod.yml b/resources/layout/docker-compose.prod.yml new file mode 100644 index 0000000..1db0c00 --- /dev/null +++ b/resources/layout/docker-compose.prod.yml @@ -0,0 +1,61 @@ +networks: + aparat: + driver: bridge + +volumes: + pg_data: null + pycache: null + media: null + static: null + +services: + nginx: + networks: + - aparat + ports: + - ${PORT:-8001}:80 + volumes: + - ./resources/layout/nginx.conf:/etc/nginx/nginx.conf + - media:/usr/share/nginx/html/resources/media/:ro + - static:/usr/share/nginx/html/resources/staticfiles/:ro + build: + context: . + dockerfile: ./docker/Dockerfile.nginx + depends_on: + - web + web: + networks: + - aparat + build: + context: . + dockerfile: ./docker/Dockerfile.web + restart: always + env_file: + - .env + environment: + - PYTHONPYCACHEPREFIX=/var/cache/pycache + - SCRIPT=${SCRIPT:-entrypoint.sh} + volumes: + - media:/code/resources/media/ + - static:/code/resources/staticfiles/ + - pycache:/var/cache/pycache + depends_on: + - db + - redis + db: + networks: + - aparat + image: postgres:16 + restart: always + environment: + POSTGRES_DB: ${DB_NAME:-django} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:?Database password must be set in .env file} + volumes: + - pg_data:/var/lib/postgresql/data + redis: + networks: + - aparat + restart: always + + image: redis diff --git a/resources/layout/docker-compose.test.yml b/resources/layout/docker-compose.test.yml new file mode 100644 index 0000000..3e42c61 --- /dev/null +++ b/resources/layout/docker-compose.test.yml @@ -0,0 +1,46 @@ +networks: + aparat: + driver: bridge + +volumes: + pg_data: null + pycache: null + +services: + web: + env_file: + - .env + networks: + - aparat + build: + context: . + dockerfile: ./docker/Dockerfile.web + restart: always + container_name: test_web + environment: + - PYTHONPYCACHEPREFIX=/var/cache/pycache + - SCRIPT=${SCRIPT:-entrypoint.sh} + volumes: + - pycache:/var/cache/pycache + depends_on: + - db + - redis + db: + networks: + - aparat + image: postgres:16 + restart: always + container_name: test_db + environment: + POSTGRES_DB: ${DB_NAME:-django} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-2309} + volumes: + - pg_data:/var/lib/postgresql/data + redis: + container_name: test_redis + networks: + - aparat + restart: always + + image: redis diff --git a/resources/layout/k8s/config.yaml b/resources/layout/k8s/config.yaml new file mode 100644 index 0000000..76e98b9 --- /dev/null +++ b/resources/layout/k8s/config.yaml @@ -0,0 +1,60 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-config +data: + nginx.conf: | + worker_processes 1; + + events { + worker_connections 1024; + } + + http { + include mime.types; + default_type application/octet-stream; + + # Logging settings (optional) + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 1024M; + + # Server block for handling requests + server { + listen 80; + + server_name _; + + location / { + proxy_pass http://django:8000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Host $http_host; + } + location /ws/ { + proxy_pass http://django:8000; # Uvicorn serveri ishga tushadigan port + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $http_host; + } + + location /resources/static/ { + alias /usr/share/nginx/html/resources/staticfiles/; + } + + location /resources/media/ { + alias /usr/share/nginx/html/resources/media/; + } + } + } + diff --git a/resources/layout/k8s/db-deployment.yaml b/resources/layout/k8s/db-deployment.yaml new file mode 100644 index 0000000..4600325 --- /dev/null +++ b/resources/layout/k8s/db-deployment.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: db +spec: + replicas: 1 + selector: + matchLabels: + app: db + template: + metadata: + labels: + app: db + spec: + containers: + - name: db + image: postgres:16 + env: + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_PASSWORD + value: "2309" + - name: POSTGRES_DB + value: django + ports: + - containerPort: 5432 + volumeMounts: + - name: db + mountPath: /var/lib/postgresql/data + volumes: + - name: db + persistentVolumeClaim: + claimName: db diff --git a/resources/layout/k8s/db-service.yaml b/resources/layout/k8s/db-service.yaml new file mode 100644 index 0000000..15131d2 --- /dev/null +++ b/resources/layout/k8s/db-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: db +spec: + type: ClusterIP + selector: + app: db + ports: + - port: 5432 + targetPort: 5432 diff --git a/resources/layout/k8s/django-deployment.yaml b/resources/layout/k8s/django-deployment.yaml new file mode 100644 index 0000000..c6618eb --- /dev/null +++ b/resources/layout/k8s/django-deployment.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: django +spec: + replicas: 1 + selector: + matchLabels: + app: django + template: + metadata: + labels: + app: django + spec: + containers: + - name: django + image: "2.0" + ports: + - containerPort: 8000 + volumeMounts: + - name: assets + mountPath: /code/resources/staticfiles + - name: media + mountPath: /code/resources/media + volumes: + - name: assets + persistentVolumeClaim: + claimName: assets + - name: media + persistentVolumeClaim: + claimName: media + + diff --git a/resources/layout/k8s/django-service.yaml b/resources/layout/k8s/django-service.yaml new file mode 100644 index 0000000..9652d67 --- /dev/null +++ b/resources/layout/k8s/django-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: django +spec: + type: ClusterIP + selector: + app: django + ports: + - port: 8000 + targetPort: 8000 diff --git a/resources/layout/k8s/nginx-deployment.yaml b/resources/layout/k8s/nginx-deployment.yaml new file mode 100644 index 0000000..4203284 --- /dev/null +++ b/resources/layout/k8s/nginx-deployment.yaml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx +spec: + replicas: 2 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:latest + ports: + - containerPort: 80 + volumeMounts: + - name: nginx-config-volume + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + - name: assets + mountPath: /usr/share/nginx/html/resources/staticfiles + readOnly: true + - name: media + mountPath: /usr/share/nginx/html/resources/media + readOnly: true + volumes: + - name: assets + persistentVolumeClaim: + claimName: assets + - name: media + persistentVolumeClaim: + claimName: media + - name: nginx-config-volume + configMap: + name: nginx-config diff --git a/resources/layout/k8s/nginx-service.yaml b/resources/layout/k8s/nginx-service.yaml new file mode 100644 index 0000000..479cadf --- /dev/null +++ b/resources/layout/k8s/nginx-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: nginx +spec: + type: NodePort + selector: + app: nginx + ports: + - port: 80 + targetPort: 80 + nodePort: 30000 diff --git a/resources/layout/k8s/volume.yaml b/resources/layout/k8s/volume.yaml new file mode 100644 index 0000000..675b7bb --- /dev/null +++ b/resources/layout/k8s/volume.yaml @@ -0,0 +1,35 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: assets +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: media +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: db +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 10Gi diff --git a/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..0b98736 --- /dev/null +++ b/resources/scripts/entrypoint-server.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +while ! nc -z $DB_HOST $DB_PORT; do + sleep 2 + echo "Waiting postgress...." +done + +python3 manage.py collectstatic --noinput +python3 manage.py migrate --noinput + +gunicorn config.wsgi:application -b 0.0.0.0:8000 --workers $(($(nproc) * 2 + 1)) + + + +exit $? + + diff --git a/resources/scripts/entrypoint.sh b/resources/scripts/entrypoint.sh new file mode 100644 index 0000000..596a692 --- /dev/null +++ b/resources/scripts/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +while ! nc -z $DB_HOST $DB_PORT; do + sleep 2 + echo "Waiting postgress...." +done + + +python3 manage.py collectstatic --noinput +python3 manage.py migrate --noinput + +uvicorn config.asgi:application --host 0.0.0.0 --port 8000 --reload --reload-dir core --reload-dir config + + + +exit $? + + diff --git a/resources/static/css/app.css b/resources/static/css/app.css new file mode 100644 index 0000000..e69de29 diff --git a/resources/static/css/error.css b/resources/static/css/error.css new file mode 100644 index 0000000..11201a8 --- /dev/null +++ b/resources/static/css/error.css @@ -0,0 +1,109 @@ +* { + -webkit-box-sizing: border-box; + box-sizing: border-box; +} + +body { + padding: 0; + margin: 0; +} + +#notfound { + position: relative; + height: 100vh; +} + +#notfound .notfound { + position: absolute; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + -ms-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.notfound { + max-width: 710px; + width: 100%; + padding-left: 190px; + line-height: 1.4; +} + +.notfound .notfound-404 { + position: absolute; + left: 0; + top: 0; + width: 150px; + height: 150px; +} + +.notfound .notfound-404 h1 { + font-family: 'Passion One', cursive; + color: #00b5c3; + font-size: 150px; + letter-spacing: 15.5px; + margin: 0px; + font-weight: 900; + position: absolute; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + -ms-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.notfound h2 { + font-family: 'Raleway', sans-serif; + color: #292929; + font-size: 28px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 2.5px; + margin-top: 0; +} + +.notfound p { + font-family: 'Raleway', sans-serif; + font-size: 14px; + font-weight: 400; + margin-top: 0; + margin-bottom: 15px; + color: #333; +} + +.notfound a { + font-family: 'Raleway', sans-serif; + font-size: 14px; + text-decoration: none; + text-transform: uppercase; + background: #fff; + display: inline-block; + padding: 15px 30px; + border-radius: 40px; + color: #292929; + font-weight: 700; + -webkit-box-shadow: 0px 4px 15px -5px rgba(0, 0, 0, 0.3); + box-shadow: 0px 4px 15px -5px rgba(0, 0, 0, 0.3); + -webkit-transition: 0.2s all; + transition: 0.2s all; +} + +.notfound a:hover { + color: #fff; + background-color: #00b5c3; +} + +@media only screen and (max-width: 480px) { + .notfound { + text-align: center; + } + .notfound .notfound-404 { + position: relative; + width: 100%; + margin-bottom: 15px; + } + .notfound { + padding-left: 15px; + padding-right: 15px; + } +} diff --git a/resources/static/css/input.css b/resources/static/css/input.css new file mode 100644 index 0000000..04b35af --- /dev/null +++ b/resources/static/css/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/resources/static/css/jazzmin.css b/resources/static/css/jazzmin.css new file mode 100644 index 0000000..8b8be66 --- /dev/null +++ b/resources/static/css/jazzmin.css @@ -0,0 +1,5 @@ +.login-logo img { + border-radius: 100%; + width: 100px; + height: 100px; +} \ No newline at end of file diff --git a/resources/static/css/output.css b/resources/static/css/output.css new file mode 100644 index 0000000..99bc6dc --- /dev/null +++ b/resources/static/css/output.css @@ -0,0 +1,772 @@ +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +/* +! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden]:where(:not([hidden="until-found"])) { + display: none; +} + +.static { + position: static; +} + +.m-2 { + margin: 0.5rem; +} + +.mx-4 { + margin-left: 1rem; + margin-right: 1rem; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.mb-5 { + margin-bottom: 1.25rem; +} + +.ml-1 { + margin-left: 0.25rem; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mt-5 { + margin-top: 1.25rem; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.flex { + display: flex; +} + +.grid { + display: grid; +} + +.h-\[100vh\] { + height: 100vh; +} + +.w-\[100vw\] { + width: 100vw; +} + +.w-full { + width: 100%; +} + +.cursor-pointer { + cursor: pointer; +} + +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +.items-center { + align-items: center; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: center; +} + +.gap-8 { + gap: 2rem; +} + +.gap-x-3 { + -moz-column-gap: 0.75rem; + column-gap: 0.75rem; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded { + border-radius: 0.25rem; +} + +.border { + border-width: 1px; +} + +.border-black { + --tw-border-opacity: 1; + border-color: rgb(0 0 0 / var(--tw-border-opacity)); +} + +.bg-blue-100 { + --tw-bg-opacity: 1; + background-color: rgb(219 234 254 / var(--tw-bg-opacity)); +} + +.bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity)); +} + +.p-4 { + padding: 1rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.px-\[40px\] { + padding-left: 40px; + padding-right: 40px; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.text-center { + text-align: center; +} + +.text-\[20px\] { + font-size: 20px; +} + +.text-\[25px\] { + font-size: 25px; +} + +.text-\[30px\] { + font-size: 30px; +} + +.text-\[40px\] { + font-size: 40px; +} + +.font-\[400\] { + font-weight: 400; +} + +.font-\[600\] { + font-weight: 600; +} + +.font-bold { + font-weight: 700; +} + +.font-semibold { + font-weight: 600; +} + +.uppercase { + text-transform: uppercase; +} + +.leading-normal { + line-height: 1.5; +} + +.text-black { + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +.text-blue-500 { + --tw-text-opacity: 1; + color: rgb(59 130 246 / var(--tw-text-opacity)); +} + +.text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + +@media (min-width: 640px) { + .sm\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 768px) { + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} diff --git a/resources/static/images/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 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 ({ + count: 2309 +}) \ No newline at end of file diff --git a/resources/static/js/customer.js b/resources/static/js/customer.js new file mode 100644 index 0000000..06bacfb --- /dev/null +++ b/resources/static/js/customer.js @@ -0,0 +1,49 @@ + +class Changer { + constructor() { + this.inputs = []; + + this.legal = [ + "bank_mfo", + "bank_name", + "bank_account", + "name", + "director_name", + "responsible_person", + "inn" + ]; + + this.physical = [ + "passport_series", + "jshir", + "first_name", + "last_name", + ] + this.legal.concat(this.physical).forEach((item) => { + this.inputs[item] = document.querySelector(`#id_${item}`).closest(".form-row"); + }) + } + toggleDisplay(showItems, hideItems) { + showItems.forEach(item => { + this.inputs[item].style.display = "block"; + }); + hideItems.forEach(item => { + this.inputs[item].style.display = "none"; + }); + }; + + change(e) { + if (e == "PHYSICAL") { + this.toggleDisplay(this.physical, this.legal); + } else if (e == "LEGAL") { + this.toggleDisplay(this.legal, this.physical); + } + } +} + +document.addEventListener("DOMContentLoaded", () => { + let obj = new Changer(); + let select = document.querySelector("#id_person_type"); + select.addEventListener("change", (e) => obj.change(e.target.value)); + obj.change(select.value); +}) \ No newline at end of file diff --git a/resources/static/js/vite-refresh.js b/resources/static/js/vite-refresh.js new file mode 100644 index 0000000..019ed44 --- /dev/null +++ b/resources/static/js/vite-refresh.js @@ -0,0 +1,9 @@ +import RefreshRuntime from 'http://localhost:5173/@react-refresh' + +if (RefreshRuntime) { + RefreshRuntime.injectIntoGlobalHook(window) + window.$RefreshReg$ = () => { + } + window.$RefreshSig$ = () => (type) => type + window.__vite_plugin_react_preamble_installed__ = true +} \ No newline at end of file diff --git a/resources/static/vite/assets/appCss-w40geAFS.js b/resources/static/vite/assets/appCss-w40geAFS.js new file mode 100644 index 0000000..e69de29 diff --git a/resources/static/vite/assets/appJs-YH6iAcjX.js b/resources/static/vite/assets/appJs-YH6iAcjX.js new file mode 100644 index 0000000..7274c06 --- /dev/null +++ b/resources/static/vite/assets/appJs-YH6iAcjX.js @@ -0,0 +1,6 @@ +var Ce=!1,Me=!1,L=[],Te=-1;function zn(e){Hn(e)}function Hn(e){L.includes(e)||L.push(e),qn()}function Mt(e){let t=L.indexOf(e);t!==-1&&t>Te&&L.splice(t,1)}function qn(){!Me&&!Ce&&(Ce=!0,queueMicrotask(Wn))}function Wn(){Ce=!1,Me=!0;for(let e=0;ee.effect(t,{scheduler:n=>{Ie?zn(n):n()}}),Tt=e.raw}function _t(e){K=e}function Vn(e){let t=()=>{};return[r=>{let i=K(r);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),q(i))},i},()=>{t()}]}function It(e,t){let n=!0,r,i=K(()=>{let o=e();JSON.stringify(o),n?r=o:queueMicrotask(()=>{t(o,r),r=o}),n=!1});return()=>q(i)}function X(e,t,n={}){e.dispatchEvent(new CustomEvent(t,{detail:n,bubbles:!0,composed:!0,cancelable:!0}))}function I(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>I(i,t));return}let n=!1;if(t(e,()=>n=!0),n)return;let r=e.firstElementChild;for(;r;)I(r,t),r=r.nextElementSibling}function O(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var ht=!1;function Yn(){ht&&O("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),ht=!0,document.body||O("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `