From 256e80cc232bcc5c6a0a1063d1fb7713e116ebcb Mon Sep 17 00:00:00 2001 From: A'zamov Samandar Date: Fri, 21 Nov 2025 14:41:16 +0500 Subject: [PATCH] first commit --- .cruft.json | 28 + .dockerignore | 2 + .env.example | 74 ++ .flake8 | 3 + .gitignore | 158 ++++ Jenkinsfile | 188 +++++ Makefile | 43 + README.MD | 246 ++++++ SECURITY.md | 79 ++ config/__init__.py | 3 + config/asgi.py | 23 + config/celery.py | 16 + config/conf/__init__.py | 12 + config/conf/apps.py | 22 + config/conf/cache.py | 26 + config/conf/celery.py | 7 + config/conf/channels.py | 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 | 3 + 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 | 175 ++++ config/settings/local.py | 11 + config/settings/production.py | 6 + config/settings/test.py | 15 + config/urls.py | 63 ++ config/wsgi.py | 8 + core/__init__.py | 0 core/apps/__init__.py | 0 core/apps/accounts/__init__.py | 0 core/apps/accounts/admin/__init__.py | 2 + core/apps/accounts/admin/core.py | 18 + core/apps/accounts/admin/user.py | 52 ++ core/apps/accounts/apps.py | 9 + core/apps/accounts/choices/__init__.py | 1 + core/apps/accounts/choices/user.py | 12 + core/apps/accounts/managers/__init__.py | 1 + core/apps/accounts/managers/user.py | 23 + core/apps/accounts/migrations/0001_initial.py | 60 ++ core/apps/accounts/migrations/__init__.py | 0 core/apps/accounts/models/__init__.py | 3 + core/apps/accounts/models/reset_token.py | 15 + core/apps/accounts/models/user.py | 24 + core/apps/accounts/seeder/__init__.py | 1 + core/apps/accounts/seeder/core.py | 10 + core/apps/accounts/serializers/__init__.py | 4 + core/apps/accounts/serializers/auth.py | 60 ++ .../accounts/serializers/change_password.py | 6 + .../apps/accounts/serializers/set_password.py | 6 + core/apps/accounts/serializers/user.py | 23 + core/apps/accounts/signals/__init__.py | 1 + core/apps/accounts/signals/user.py | 17 + core/apps/accounts/tasks/__init__.py | 1 + core/apps/accounts/tasks/sms.py | 38 + core/apps/accounts/tests/__init__.py | 0 core/apps/accounts/tests/test_auth.py | 126 +++ .../accounts/tests/test_change_password.py | 77 ++ core/apps/accounts/urls.py | 26 + core/apps/accounts/views/__init__.py | 1 + core/apps/accounts/views/auth.py | 209 +++++ core/apps/logs/.gitignore | 2 + core/apps/shared/__init__.py | 0 core/apps/shared/admin/__init__.py | 1 + core/apps/shared/admin/settings.py | 20 + core/apps/shared/apps.py | 6 + core/apps/shared/enums/__init__.py | 17 + core/apps/shared/migrations/0001_initial.py | 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/cache.py | 18 + core/utils/console.py | 78 ++ core/utils/core.py | 6 + core/utils/storage.py | 33 + docker-compose.prod.yml | 61 ++ docker-compose.test.yml | 46 ++ docker-compose.yml | 57 ++ docker/Dockerfile.nginx | 3 + docker/Dockerfile.web | 20 + jst.json | 9 + manage.py | 24 + pyproject.toml | 27 + requirements.txt | 48 ++ 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/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 +++++ 161 files changed, 7052 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 Jenkinsfile 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/__init__.py create mode 100644 core/apps/accounts/admin/core.py create mode 100644 core/apps/accounts/admin/user.py create mode 100644 core/apps/accounts/apps.py create mode 100644 core/apps/accounts/choices/__init__.py create mode 100644 core/apps/accounts/choices/user.py create mode 100644 core/apps/accounts/managers/__init__.py create mode 100644 core/apps/accounts/managers/user.py create mode 100644 core/apps/accounts/migrations/0001_initial.py create mode 100644 core/apps/accounts/migrations/__init__.py create mode 100644 core/apps/accounts/models/__init__.py create mode 100644 core/apps/accounts/models/reset_token.py create mode 100644 core/apps/accounts/models/user.py create mode 100644 core/apps/accounts/seeder/__init__.py create mode 100644 core/apps/accounts/seeder/core.py create mode 100644 core/apps/accounts/serializers/__init__.py create mode 100644 core/apps/accounts/serializers/auth.py create mode 100644 core/apps/accounts/serializers/change_password.py create mode 100644 core/apps/accounts/serializers/set_password.py create mode 100644 core/apps/accounts/serializers/user.py create mode 100644 core/apps/accounts/signals/__init__.py create mode 100644 core/apps/accounts/signals/user.py create mode 100644 core/apps/accounts/tasks/__init__.py create mode 100644 core/apps/accounts/tasks/sms.py create mode 100644 core/apps/accounts/tests/__init__.py create mode 100644 core/apps/accounts/tests/test_auth.py create mode 100644 core/apps/accounts/tests/test_change_password.py create mode 100644 core/apps/accounts/urls.py create mode 100644 core/apps/accounts/views/__init__.py create mode 100644 core/apps/accounts/views/auth.py create mode 100644 core/apps/logs/.gitignore create mode 100644 core/apps/shared/__init__.py create mode 100644 core/apps/shared/admin/__init__.py create mode 100644 core/apps/shared/admin/settings.py create mode 100644 core/apps/shared/apps.py create mode 100644 core/apps/shared/enums/__init__.py create mode 100644 core/apps/shared/migrations/0001_initial.py create mode 100644 core/apps/shared/migrations/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/cache.py create mode 100644 core/utils/console.py create mode 100644 core/utils/core.py create mode 100644 core/utils/storage.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.test.yml create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile.nginx create mode 100644 docker/Dockerfile.web create mode 100644 jst.json create mode 100644 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/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..b48ea2d --- /dev/null +++ b/.cruft.json @@ -0,0 +1,28 @@ +{ + "template": "https://github.com/JscorpTech/django", + "commit": "38afe9dd67ae4b080fcc6e5da47b494c448214b6", + "checkout": null, + "context": { + "cookiecutter": { + "cacheops": true, + "silk": true, + "storage": true, + "channels": true, + "ckeditor": true, + "modeltranslation": true, + "parler": false, + "rosetta": false, + "project_name": "uzxarid", + "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": "uzxarid" + } + }, + "directory": null +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9da44e1 --- /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..85fbdb7 --- /dev/null +++ b/.env.example @@ -0,0 +1,74 @@ +# 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 + + +# 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..a15fde9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,158 @@ +node_modules + +# OS ignores +*.DS_Store + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +poetry.lock + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +.idea/ + +# Visual Studio Code +.vscode diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..5f74504 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,188 @@ +pipeline { + agent any + + environment { + PROD_ENV = "/opt/env/.env.uzxarid" + IMAGE_NAME = "uzxarid" + TEST_TAG = "test" + PROD_TAG = "latest" + CONTAINER_DB = "uzxarid_db_test" + CONTAINER_WEB = "uzxarid_web_test" + CONTAINER_REDIS = "uzxarid_redis_test" + STACK_NAME = "uzxarid" + } + + 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/uzxarid.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/Makefile b/Makefile new file mode 100644 index 0000000..2e4f5b1 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ + +start: up seed + +up: + docker compose up -d + +down: + docker compose down + +build: + docker compose build + +rebuild: down build up + +deploy: down build up 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 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..be38ddf --- /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..3be6b1b --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,23 @@ +import os + +from django.core.asgi import get_asgi_application + +asgi_application = get_asgi_application() +from config.env import env # noqa + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) + + +from channels.routing import ProtocolTypeRouter # noqa +from channels.routing import URLRouter # noqa + +# from core.apps.websocket.urls import websocket_urlpatterns # noqa +# from core.apps.websocket.middlewares import JWTAuthMiddlewareStack # noqa + +application = ProtocolTypeRouter( + { + "http": asgi_application, + # "websocket": JWTAuthMiddlewareStack(URLRouter(websocket_urlpatterns)), + } +) + diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..dc94054 --- /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..cbdb90c --- /dev/null +++ b/config/conf/__init__.py @@ -0,0 +1,12 @@ +from .cache import * # noqa +from .celery import * # noqa +from .cron import * # noqa +from .jwt import * # noqa +from .logs import * # noqa +from .rest_framework import * # noqa +from .unfold import * # noqa +from .spectacular import * # noqa + +from .ckeditor import * # noqa +from .storage import * # noqa +from .channels import * # noqa \ No newline at end of file diff --git a/config/conf/apps.py b/config/conf/apps.py new file mode 100644 index 0000000..b06405d --- /dev/null +++ b/config/conf/apps.py @@ -0,0 +1,22 @@ +from config.env import env + +APPS = [ + "channels", + "cacheops", + + "django_ckeditor_5", + + "drf_spectacular", + "rest_framework", + "corsheaders", + "django_filters", + "django_redis", + "rest_framework_simplejwt", + "django_core", + "core.apps.accounts.apps.AccountsConfig", +] + +if env.bool("SILK_ENABLED", False): + APPS += [ + "silk", + ] diff --git a/config/conf/cache.py b/config/conf/cache.py new file mode 100644 index 0000000..d29ec48 --- /dev/null +++ b/config/conf/cache.py @@ -0,0 +1,26 @@ +from config.env import env + +CACHES = { + "default": { + "BACKEND": env.str("CACHE_BACKEND"), + "LOCATION": env.str("REDIS_URL"), + "TIMEOUT": env.str("CACHE_TIMEOUT"), + }, +} + +CACHE_MIDDLEWARE_SECONDS = env("CACHE_TIMEOUT") + + +CACHEOPS_REDIS = env.str("REDIS_URL") +CACHEOPS_DEFAULTS = { + "timeout": env.str("CACHE_TIMEOUT"), +} +CACHEOPS = { + # !NOTE: api => "you app name" + # "api.*": { + # "ops": "all", # Barcha turdagi so'rovlarni keshga olish + # "timeout": 60 * 5, # 5 daqiqa davomida saqlash + # }, +} +CACHEOPS_DEGRADE_ON_FAILURE = True +CACHEOPS_ENABLED = env.bool("CACHE_ENABLED", False) diff --git a/config/conf/celery.py b/config/conf/celery.py new file mode 100644 index 0000000..5f46855 --- /dev/null +++ b/config/conf/celery.py @@ -0,0 +1,7 @@ +CELERY_BEAT_SCHEDULE = { + # "test": { + # "task": "core.apps.home.tasks.demo.add", + # "schedule": 5.0, + # "args": (1, 2) + # }, +} diff --git a/config/conf/channels.py b/config/conf/channels.py new file mode 100644 index 0000000..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..04da3a7 --- /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..5eda9f3 --- /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..4e1d24d --- /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 %(filename)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..71dad20 --- /dev/null +++ b/config/conf/modules.py @@ -0,0 +1,3 @@ +MODULES = [ + "core.apps.shared", +] diff --git a/config/conf/navigation.py b/config/conf/navigation.py new file mode 100644 index 0000000..377ba52 --- /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..74f169c --- /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..09ecb3e --- /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..82a964c --- /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..aeca8c2 --- /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..1902b46 --- /dev/null +++ b/config/settings/common.py @@ -0,0 +1,175 @@ +#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 = [ + "modeltranslation", + "unfold", + "unfold.contrib.filters", + "unfold.contrib.forms", + "unfold.contrib.guardian", + "unfold.contrib.simple_history", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + APPS + +MODULES = [app for app in MODULES if isinstance(app, str)] + +for module_path in MODULES: + INSTALLED_APPS.append("{}.apps.ModuleConfig".format(module_path)) + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", # Cors middleware + "django.middleware.locale.LocaleMiddleware", # Locale middleware + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] +if env.bool("SILK_ENABLED", False): + MIDDLEWARE += [ + "silk.middleware.SilkyMiddleware", + ] + + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "resources/templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] +# fmt: off + +WSGI_APPLICATION = "config.wsgi.application" + +# fmt: on + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.{}".format(validator) + } for validator in [ + "UserAttributeSimilarityValidator", + "MinimumLengthValidator", + "CommonPasswordValidator", + "NumericPasswordValidator" + ] +] + +TIME_ZONE = "Asia/Tashkent" +USE_I18N = True +USE_TZ = True +STATIC_URL = "resources/static/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Date formats +## +DATE_FORMAT = "d.m.y" +TIME_FORMAT = "H:i:s" +DATE_INPUT_FORMATS = ["%d.%m.%Y", "%Y.%d.%m", "%Y.%d.%m"] + + +SEEDERS = ["core.apps.accounts.seeder.UserSeeder"] + +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "resources/static"), +] + +CORS_ORIGIN_ALLOW_ALL = True + +STATIC_ROOT = os.path.join(BASE_DIR, "resources/staticfiles") +VITE_APP_DIR = os.path.join(BASE_DIR, "resources/static/vite") + +LANGUAGES = ( + ("ru", _("Russia")), + ("en", _("English")), + ("uz", _("Uzbek")), +) +LOCALE_PATHS = [os.path.join(BASE_DIR, "resources/locale")] + +LANGUAGE_CODE = "uz" + +MEDIA_ROOT = os.path.join(BASE_DIR, "resources/media") # Media files +MEDIA_URL = "/resources/media/" + +AUTH_USER_MODEL = "accounts.User" + +CELERY_BROKER_URL = env("REDIS_URL") +CELERY_RESULT_BACKEND = env("REDIS_URL") + +ALLOWED_HOSTS += env("ALLOWED_HOSTS").split(",") +CSRF_TRUSTED_ORIGINS = env("CSRF_TRUSTED_ORIGINS").split(",") +SILKY_AUTHORISATION = True +SILKY_PYTHON_PROFILER = True + +MODELTRANSLATION_LANGUAGES = ("uz", "ru", "en") +MODELTRANSLATION_DEFAULT_LANGUAGE = "uz" + + + +JST_LANGUAGES = [ + { + "code": "uz", + "name": "Uzbek", + "is_default": True, + }, + { + "code": "en", + "name": "English", + }, + { + "code": "ru", + "name": "Russia", + } +] diff --git a/config/settings/local.py b/config/settings/local.py new file mode 100644 index 0000000..4a903f7 --- /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..3c8b8bf --- /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..74072d0 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,63 @@ +""" +All urls configurations tree +""" + +from config.env import env +from django.conf import settings +from django.contrib import admin +from django.http import HttpResponse +from django.urls import include, path, re_path +from django.views.static import serve +from drf_spectacular.views import (SpectacularAPIView, SpectacularRedocView, + SpectacularSwaggerView) + + +def home(request): + return HttpResponse("OK") + +################ +# My apps url +################ +urlpatterns = [ + path("health/", home), + path("", include("core.apps.accounts.urls")), + path("api/", include("core.apps.shared.urls")), +] + + +################ +# Library urls +################ +urlpatterns += [ + path("admin/", admin.site.urls), + path("accounts/", include("django.contrib.auth.urls")), + path("i18n/", include("django.conf.urls.i18n")), + + path("ckeditor5/", include("django_ckeditor_5.urls"), name="ck_editor_5_upload_file"), +] + +################ +# Project env debug mode +################ +if env.bool("SILK_ENABLED", False): + urlpatterns += [ + path('silk/', include('silk.urls', namespace='silk')) + ] +if env.str("PROJECT_ENV") == "debug": + + ################ + # Swagger urls + ################ + urlpatterns += [ + path("schema/", SpectacularAPIView.as_view(), name="schema"), + path("swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), + ] + +################ +# Media urls +################ +urlpatterns += [ + re_path(r"static/(?P.*)", serve, {"document_root": settings.STATIC_ROOT}), + re_path(r"media/(?P.*)", serve, {"document_root": settings.MEDIA_ROOT}), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..982626f --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,8 @@ +import os + +from config.env import env +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) + +application = get_wsgi_application() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/__init__.py b/core/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/__init__.py b/core/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/admin/__init__.py b/core/apps/accounts/admin/__init__.py new file mode 100644 index 0000000..6e3a821 --- /dev/null +++ b/core/apps/accounts/admin/__init__.py @@ -0,0 +1,2 @@ +from .core import * # noqa +from .user import * # noqa diff --git a/core/apps/accounts/admin/core.py b/core/apps/accounts/admin/core.py new file mode 100644 index 0000000..4a807e7 --- /dev/null +++ b/core/apps/accounts/admin/core.py @@ -0,0 +1,18 @@ +""" +Admin panel register +""" + +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth import models as db_models +from django_core.models import SmsConfirm + +from ..admin import user +from .user import SmsConfirmAdmin + +admin.site.unregister(db_models.Group) +admin.site.register(db_models.Group, user.GroupAdmin) +admin.site.register(db_models.Permission, user.PermissionAdmin) + +admin.site.register(get_user_model(), user.CustomUserAdmin) +admin.site.register(SmsConfirm, SmsConfirmAdmin) diff --git a/core/apps/accounts/admin/user.py b/core/apps/accounts/admin/user.py new file mode 100644 index 0000000..9aed37d --- /dev/null +++ b/core/apps/accounts/admin/user.py @@ -0,0 +1,52 @@ +from django.contrib.auth import admin +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin +from unfold.forms import AdminPasswordChangeForm # UserCreationForm, +from unfold.forms import UserChangeForm + + +class CustomUserAdmin(admin.UserAdmin, ModelAdmin): + change_password_form = AdminPasswordChangeForm + # add_form = UserCreationForm + form = UserChangeForm + list_display = ( + "first_name", + "last_name", + "phone", + "role", + ) + autocomplete_fields = ["groups", "user_permissions"] + fieldsets = ((None, {"fields": ("phone",)}),) + ( + (None, {"fields": ("username", "password")}), + (_("Personal info"), {"fields": ("first_name", "last_name", "email")}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + "role", + ), + }, + ), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ) + + +class PermissionAdmin(ModelAdmin): + list_display = ("name",) + search_fields = ("name",) + + +class GroupAdmin(ModelAdmin): + list_display = ["name"] + search_fields = ["name"] + autocomplete_fields = ("permissions",) + + +class SmsConfirmAdmin(ModelAdmin): + list_display = ["phone", "code", "resend_count", "try_count"] + search_fields = ["phone", "code"] diff --git a/core/apps/accounts/apps.py b/core/apps/accounts/apps.py new file mode 100644 index 0000000..b7ca71d --- /dev/null +++ b/core/apps/accounts/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.accounts" + + def ready(self): + from core.apps.accounts import signals # noqa diff --git a/core/apps/accounts/choices/__init__.py b/core/apps/accounts/choices/__init__.py new file mode 100644 index 0000000..1000b27 --- /dev/null +++ b/core/apps/accounts/choices/__init__.py @@ -0,0 +1 @@ +from .user import * # noqa diff --git a/core/apps/accounts/choices/user.py b/core/apps/accounts/choices/user.py new file mode 100644 index 0000000..b93b918 --- /dev/null +++ b/core/apps/accounts/choices/user.py @@ -0,0 +1,12 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class RoleChoice(models.TextChoices): + """ + User Role Choice + """ + + SUPERUSER = "superuser", _("Superuser") + ADMIN = "admin", _("Admin") + USER = "user", _("User") diff --git a/core/apps/accounts/managers/__init__.py b/core/apps/accounts/managers/__init__.py new file mode 100644 index 0000000..1000b27 --- /dev/null +++ b/core/apps/accounts/managers/__init__.py @@ -0,0 +1 @@ +from .user import * # noqa diff --git a/core/apps/accounts/managers/user.py b/core/apps/accounts/managers/user.py new file mode 100644 index 0000000..e7df8d9 --- /dev/null +++ b/core/apps/accounts/managers/user.py @@ -0,0 +1,23 @@ +from django.contrib.auth import base_user + + +class UserManager(base_user.BaseUserManager): + def create_user(self, phone, password=None, **extra_fields): + if not phone: + raise ValueError("The phone number must be set") + + user = self.model(phone=phone, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, phone, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self.create_user(phone, password, **extra_fields) diff --git a/core/apps/accounts/migrations/0001_initial.py b/core/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..872d44c --- /dev/null +++ b/core/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.3 on 2024-12-13 19:04 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +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'), ('admin', 'Admin'), ('user', 'User')], default='user', max_length=255)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ResetToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('token', models.CharField(max_length=255, unique=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Reset Token', + 'verbose_name_plural': 'Reset Tokens', + }, + ), + ] diff --git a/core/apps/accounts/migrations/__init__.py b/core/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/models/__init__.py b/core/apps/accounts/models/__init__.py new file mode 100644 index 0000000..bcfdb95 --- /dev/null +++ b/core/apps/accounts/models/__init__.py @@ -0,0 +1,3 @@ +# isort: skip_file +from .user import * # noqa +from .reset_token import * # noqa diff --git a/core/apps/accounts/models/reset_token.py b/core/apps/accounts/models/reset_token.py new file mode 100644 index 0000000..eb2a3a1 --- /dev/null +++ b/core/apps/accounts/models/reset_token.py @@ -0,0 +1,15 @@ +from django.contrib.auth import get_user_model +from django.db import models +from django_core.models import AbstractBaseModel + + +class ResetToken(AbstractBaseModel): + token = models.CharField(max_length=255, unique=True) + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + + def __str__(self): + return self.token + + class Meta: + verbose_name = "Reset Token" + verbose_name_plural = "Reset Tokens" diff --git a/core/apps/accounts/models/user.py b/core/apps/accounts/models/user.py new file mode 100644 index 0000000..d49fe0c --- /dev/null +++ b/core/apps/accounts/models/user.py @@ -0,0 +1,24 @@ +from django.contrib.auth import models as auth_models +from django.db import models + +from ..choices import RoleChoice +from ..managers import UserManager + + +class User(auth_models.AbstractUser): + phone = models.CharField(max_length=255, unique=True) + 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.USER, + ) + + USERNAME_FIELD = "phone" + objects = UserManager() + + def __str__(self): + return self.phone diff --git a/core/apps/accounts/seeder/__init__.py b/core/apps/accounts/seeder/__init__.py new file mode 100644 index 0000000..151ee18 --- /dev/null +++ b/core/apps/accounts/seeder/__init__.py @@ -0,0 +1 @@ +from .core import * # noqa diff --git a/core/apps/accounts/seeder/core.py b/core/apps/accounts/seeder/core.py new file mode 100644 index 0000000..c487218 --- /dev/null +++ b/core/apps/accounts/seeder/core.py @@ -0,0 +1,10 @@ +""" +Create a new user/superuser +""" + +from django.contrib.auth import get_user_model + + +class UserSeeder: + def run(self): + get_user_model().objects.create_superuser("998888112309", "2309") diff --git a/core/apps/accounts/serializers/__init__.py b/core/apps/accounts/serializers/__init__.py new file mode 100644 index 0000000..4b59d74 --- /dev/null +++ b/core/apps/accounts/serializers/__init__.py @@ -0,0 +1,4 @@ +from .auth import * # noqa +from .change_password import * # noqa +from .set_password import * # noqa +from .user import * # noqa diff --git a/core/apps/accounts/serializers/auth.py b/core/apps/accounts/serializers/auth.py new file mode 100644 index 0000000..325eff6 --- /dev/null +++ b/core/apps/accounts/serializers/auth.py @@ -0,0 +1,60 @@ +from config.env import env +from django.contrib.auth import get_user_model +from django.utils.translation import gettext as _ +from rest_framework import exceptions, serializers + +OTP_SIZE = env.int("OTP_SIZE", 4) +class LoginSerializer(serializers.Serializer): + username = serializers.CharField(max_length=255) + password = serializers.CharField(max_length=255) + + +class RegisterSerializer(serializers.ModelSerializer): + phone = serializers.CharField(max_length=255) + + def validate_phone(self, value): + user = get_user_model().objects.filter(phone=value, validated_at__isnull=False) + if user.exists(): + raise exceptions.ValidationError(_("Phone number already registered."), code="unique") + return value + + class Meta: + model = get_user_model() + fields = ["first_name", "last_name", "phone", "password"] + extra_kwargs = { + "first_name": { + "required": True, + }, + "last_name": {"required": True}, + } + + +class ConfirmSerializer(serializers.Serializer): + code = serializers.CharField(max_length=OTP_SIZE, min_length=OTP_SIZE) + phone = serializers.CharField(max_length=255) + + +class ResetPasswordSerializer(serializers.Serializer): + phone = serializers.CharField(max_length=255) + + def validate_phone(self, value): + user = get_user_model().objects.filter(phone=value) + if user.exists(): + return value + + raise serializers.ValidationError(_("User does not exist")) + + +class ResetConfirmationSerializer(serializers.Serializer): + code = serializers.CharField(min_length=OTP_SIZE, max_length=OTP_SIZE) + phone = serializers.CharField(max_length=255) + + def validate_phone(self, value): + user = get_user_model().objects.filter(phone=value) + if user.exists(): + return value + raise serializers.ValidationError(_("User does not exist")) + + +class ResendSerializer(serializers.Serializer): + phone = serializers.CharField(max_length=255) diff --git a/core/apps/accounts/serializers/change_password.py b/core/apps/accounts/serializers/change_password.py new file mode 100644 index 0000000..f3482b3 --- /dev/null +++ b/core/apps/accounts/serializers/change_password.py @@ -0,0 +1,6 @@ +from rest_framework import serializers + + +class ChangePasswordSerializer(serializers.Serializer): + old_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) diff --git a/core/apps/accounts/serializers/set_password.py b/core/apps/accounts/serializers/set_password.py new file mode 100644 index 0000000..5ffc6ff --- /dev/null +++ b/core/apps/accounts/serializers/set_password.py @@ -0,0 +1,6 @@ +from rest_framework import serializers + + +class SetPasswordSerializer(serializers.Serializer): + password = serializers.CharField() + token = serializers.CharField(max_length=255) diff --git a/core/apps/accounts/serializers/user.py b/core/apps/accounts/serializers/user.py new file mode 100644 index 0000000..60f10d7 --- /dev/null +++ b/core/apps/accounts/serializers/user.py @@ -0,0 +1,23 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + exclude = [ + "created_at", + "updated_at", + "password", + "groups", + "user_permissions" + ] + model = get_user_model() + + +class UserUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = [ + "first_name", + "last_name" + ] diff --git a/core/apps/accounts/signals/__init__.py b/core/apps/accounts/signals/__init__.py new file mode 100644 index 0000000..6a1ab45 --- /dev/null +++ b/core/apps/accounts/signals/__init__.py @@ -0,0 +1 @@ +from .user import * # noqa \ No newline at end of file diff --git a/core/apps/accounts/signals/user.py b/core/apps/accounts/signals/user.py new file mode 100644 index 0000000..8355569 --- /dev/null +++ b/core/apps/accounts/signals/user.py @@ -0,0 +1,17 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth import get_user_model + + +@receiver(post_save, sender=get_user_model()) +def user_signal(sender, created, instance, **kwargs): + """[TODO:summary] + + Args: + sender ([TODO:type]): [TODO:description] + created ([TODO:type]): [TODO:description] + instance ([TODO:type]): [TODO:description] + """ + if created and instance.username is None: + instance.username = "U%(id)s" % {"id": 1000 + instance.id} + instance.save() diff --git a/core/apps/accounts/tasks/__init__.py b/core/apps/accounts/tasks/__init__.py new file mode 100644 index 0000000..23f58fb --- /dev/null +++ b/core/apps/accounts/tasks/__init__.py @@ -0,0 +1 @@ +from .sms import * # noqa diff --git a/core/apps/accounts/tasks/sms.py b/core/apps/accounts/tasks/sms.py new file mode 100644 index 0000000..d7b0529 --- /dev/null +++ b/core/apps/accounts/tasks/sms.py @@ -0,0 +1,38 @@ +#type: ignore +""" +Base celery tasks +""" + +import logging +import os +from importlib import import_module + +from celery import shared_task +from config.env import env +from django.utils.translation import gettext as _ + + +@shared_task +def SendConfirm(phone, code): + """Tasdiqlash ko'dini yuborish + + Args: + phone (str, int): telefon no'mer + code (str, int): tasdiqlash ko'di + + Raises: + Exception: [TODO:description] + """ + try: + service = getattr( + import_module(os.getenv("OTP_MODULE")), os.getenv("OTP_SERVICE") + )() + service.send_sms( + phone, env.str("OTP_MESSAGE", _("Sizning Tasdiqlash ko'dingiz: %(code)s")) % {"code": code} + ) + logging.info("Sms send: %s-%s" % (phone, code)) + except Exception as e: + logging.error( + "Error: {phone}-{code}\n\n{error}".format(phone=phone, code=code, error=e) + ) # noqa + raise Exception diff --git a/core/apps/accounts/tests/__init__.py b/core/apps/accounts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/tests/test_auth.py b/core/apps/accounts/tests/test_auth.py new file mode 100644 index 0000000..7b3411a --- /dev/null +++ b/core/apps/accounts/tests/test_auth.py @@ -0,0 +1,126 @@ +from unittest.mock import patch + +import pytest +from core.apps.accounts.models import ResetToken +from core.services import SmsService +from django.contrib.auth import get_user_model +from django.urls import reverse +from django_core.models import SmsConfirm +from pydantic import BaseModel +from rest_framework import status +from rest_framework.test import APIClient + + +class TokenModel(BaseModel): + access: str + refresh: str + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def test_user(db): + phone = "998999999999" + password = "password" + user = get_user_model().objects.create_user(phone=phone, first_name="John", last_name="Doe", password=password) + return user + + +@pytest.fixture +def sms_code(test_user): + code = "1111" + SmsConfirm.objects.create(phone=test_user.phone, code=code) + return code + + +@pytest.mark.django_db +def test_reg_view(api_client): + data = { + "phone": "998999999991", + "first_name": "John", + "last_name": "Doe", + "password": "password", + } + with patch.object(SmsService, "send_confirm", return_value=True): + response = api_client.post(reverse("auth-register"), data=data) + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.data["data"]["detail"] == f"Sms {data['phone']} raqamiga yuborildi" + + +@pytest.mark.django_db +def test_confirm_view(api_client, test_user, sms_code): + data = {"phone": test_user.phone, "code": sms_code} + response = api_client.post(reverse("auth-confirm"), data=data) + assert response.status_code == status.HTTP_202_ACCEPTED + + +@pytest.mark.django_db +def test_invalid_confirm_view(api_client, test_user): + data = {"phone": test_user.phone, "code": "1112"} + response = api_client.post(reverse("auth-confirm"), data=data) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_reset_confirmation_code_view(api_client, test_user, sms_code): + data = {"phone": test_user.phone, "code": sms_code} + response = api_client.post(reverse("auth-confirm"), data=data) + assert response.status_code == status.HTTP_202_ACCEPTED + assert "token" in response.data["data"] + + +@pytest.mark.django_db +def test_reset_confirmation_code_view_invalid_code(api_client, test_user): + data = {"phone": test_user.phone, "code": "123456"} + response = api_client.post(reverse("auth-confirm"), data=data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_reset_set_password_view(api_client, test_user): + token = ResetToken.objects.create(user=test_user, token="token") + data = {"token": token.token, "password": "new_password"} + response = api_client.post(reverse("reset-password-reset-password-set"), data=data) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_reset_set_password_view_invalid_token(api_client): + token = "test_token" + data = {"token": token, "password": "new_password"} + with patch.object(get_user_model().objects, "filter", return_value=get_user_model().objects.none()): + response = api_client.post(reverse("reset-password-reset-password-set"), data=data) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["data"]["detail"] == "Invalid token" + + +@pytest.mark.django_db +def test_resend_view(api_client, test_user): + data = {"phone": test_user.phone} + response = api_client.post(reverse("auth-resend"), data=data) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_reset_password_view(api_client, test_user): + data = {"phone": test_user.phone} + response = api_client.post(reverse("reset-password-reset-password"), data=data) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_me_view(api_client, test_user): + api_client.force_authenticate(user=test_user) + response = api_client.get(reverse("me-me")) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_me_update_view(api_client, test_user): + api_client.force_authenticate(user=test_user) + data = {"first_name": "Updated"} + response = api_client.patch(reverse("me-user-update"), data=data) + assert response.status_code == status.HTTP_200_OK diff --git a/core/apps/accounts/tests/test_change_password.py b/core/apps/accounts/tests/test_change_password.py new file mode 100644 index 0000000..6d3031c --- /dev/null +++ b/core/apps/accounts/tests/test_change_password.py @@ -0,0 +1,77 @@ +import pytest +from core.apps.accounts.serializers import ChangePasswordSerializer +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def test_user(db): + phone = "9981111111" + password = "12345670" + user = get_user_model().objects.create_user(phone=phone, password=password, email="test@example.com") + return user + + +@pytest.fixture +def change_password_url(): + return reverse("change-password-change-password") + + +@pytest.mark.django_db +def test_change_password_success(api_client, test_user, change_password_url): + api_client.force_authenticate(user=test_user) + data = { + "old_password": "12345670", + "new_password": "newpassword", + } + response = api_client.post(change_password_url, data=data, format="json") + assert response.status_code == status.HTTP_200_OK + assert response.data["data"]["detail"] == "password changed successfully" + + # Yangi parolni bazadan tekshiramiz + test_user.refresh_from_db() + assert test_user.check_password("newpassword") + + +@pytest.mark.django_db +def test_change_password_invalid_old_password(api_client, test_user, change_password_url): + api_client.force_authenticate(user=test_user) + data = { + "old_password": "wrongpassword", + "new_password": "newpassword", + } + response = api_client.post(change_password_url, data=data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["data"]["detail"] == "invalida password" + + +@pytest.mark.django_db +def test_change_password_serializer_validation(): + valid_data = { + "old_password": "12345670", + "new_password": "newpassword", + } + serializer = ChangePasswordSerializer(data=valid_data) + assert serializer.is_valid() + + invalid_data = { + "old_password": "12345670", + "new_password": "123", + } + serializer = ChangePasswordSerializer(data=invalid_data) + assert not serializer.is_valid() + + +@pytest.mark.django_db +def test_change_password_view_permissions(api_client, change_password_url): + # autentifikatsiyasiz request + api_client.force_authenticate(user=None) + response = api_client.post(change_password_url, data={}, format="json") + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/core/apps/accounts/urls.py b/core/apps/accounts/urls.py new file mode 100644 index 0000000..d701a07 --- /dev/null +++ b/core/apps/accounts/urls.py @@ -0,0 +1,26 @@ +""" +Accounts app urls +""" + +from django.urls import path, include +from rest_framework_simplejwt import views as jwt_views +from .views import RegisterView, ResetPasswordView, MeView, ChangePasswordView +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() +router.register("auth", RegisterView, basename="auth") +router.register("auth", ResetPasswordView, basename="reset-password") +router.register("auth", MeView, basename="me") +router.register("auth", ChangePasswordView, basename="change-password") + + +urlpatterns = [ + path("", include(router.urls)), + path("auth/token/", jwt_views.TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("auth/token/verify/", jwt_views.TokenVerifyView.as_view(), name="token_verify"), + path( + "auth/token/refresh/", + jwt_views.TokenRefreshView.as_view(), + name="token_refresh", + ), +] diff --git a/core/apps/accounts/views/__init__.py b/core/apps/accounts/views/__init__.py new file mode 100644 index 0000000..1e88b4e --- /dev/null +++ b/core/apps/accounts/views/__init__.py @@ -0,0 +1 @@ +from .auth import * # noqa diff --git a/core/apps/accounts/views/auth.py b/core/apps/accounts/views/auth.py new file mode 100644 index 0000000..750dfc5 --- /dev/null +++ b/core/apps/accounts/views/auth.py @@ -0,0 +1,209 @@ +import uuid +from typing import Type + +from core.services import UserService, SmsService +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ +from django_core import exceptions +from drf_spectacular.utils import extend_schema +from rest_framework import status, throttling, request +from rest_framework.response import Response +from rest_framework.exceptions import PermissionDenied +from rest_framework.viewsets import GenericViewSet +from django_core.mixins import BaseViewSetMixin +from rest_framework.decorators import action +from ..serializers import ( + RegisterSerializer, + ConfirmSerializer, + ResendSerializer, + ResetPasswordSerializer, + ResetConfirmationSerializer, + SetPasswordSerializer, + UserSerializer, + UserUpdateSerializer, +) +from rest_framework.permissions import AllowAny +from django.contrib.auth.hashers import make_password +from drf_spectacular.utils import OpenApiResponse +from rest_framework.permissions import IsAuthenticated +from ..serializers import ChangePasswordSerializer + +from .. import models + + +@extend_schema(tags=["register"]) +class RegisterView(BaseViewSetMixin, GenericViewSet, UserService): + throttle_classes = [throttling.UserRateThrottle] + permission_classes = [AllowAny] + + def get_serializer_class(self): + match self.action: + case "register": + return RegisterSerializer + case "confirm": + return ConfirmSerializer + case "resend": + return ResendSerializer + case _: + return RegisterSerializer + + @action(methods=["POST"], detail=False, url_path="register") + def register(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.data + phone = data.get("phone") + # Create pending user + self.create_user(phone, data.get("first_name"), data.get("last_name"), data.get("password")) + self.send_confirmation(phone) # Send confirmation code for sms eskiz.uz + return Response( + {"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}}, + status=status.HTTP_202_ACCEPTED, + ) + + @extend_schema(summary="Auth confirm.", description="Auth confirm user.") + @action(methods=["POST"], detail=False, url_path="confirm") + def confirm(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.data + phone, code = data.get("phone"), data.get("code") + try: + if SmsService.check_confirm(phone, code=code): + token = self.validate_user(get_user_model().objects.filter(phone=phone).first()) + return Response( + data={ + "detail": _("Tasdiqlash ko'di qabul qilindi"), + "token": token, + }, + status=status.HTTP_202_ACCEPTED, + ) + except exceptions.SmsException as e: + raise PermissionDenied(e) # Response exception for APIException + except Exception as e: + raise PermissionDenied(e) # Api exception for APIException + + @action(methods=["POST"], detail=False, url_path="resend") + def resend(self, rq: Type[request.Request]): + ser = self.get_serializer(data=rq.data) + ser.is_valid(raise_exception=True) + phone = ser.data.get("phone") + self.send_confirmation(phone) + return Response({"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}}) + + +@extend_schema(tags=["reset-password"]) +class ResetPasswordView(BaseViewSetMixin, GenericViewSet, UserService): + permission_classes = [AllowAny] + + def get_serializer_class(self): + match self.action: + case "reset_password": + return ResetPasswordSerializer + case "reset_confirm": + return ResetConfirmationSerializer + case "reset_password_set": + return SetPasswordSerializer + case _: + return None + + @action(methods=["POST"], detail=False, url_path="reset-password") + def reset_password(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + phone = ser.data.get("phone") + self.send_confirmation(phone) + return Response({"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}}) + + @action(methods=["POST"], detail=False, url_path="reset-password-confirm") + def reset_confirm(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + + data = ser.data + code, phone = data.get("code"), data.get("phone") + try: + SmsService.check_confirm(phone, code) + token = models.ResetToken.objects.create( + user=get_user_model().objects.filter(phone=phone).first(), + token=str(uuid.uuid4()), + ) + return Response( + data={ + "token": token.token, + "created_at": token.created_at, + "updated_at": token.updated_at, + }, + status=status.HTTP_200_OK, + ) + except exceptions.SmsException as e: + raise PermissionDenied(str(e)) + except Exception as e: + raise PermissionDenied(str(e)) + + @action(methods=["POST"], detail=False, url_path="reset-password-set") + def reset_password_set(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.data + token = data.get("token") + password = data.get("password") + token = models.ResetToken.objects.filter(token=token) + if not token.exists(): + raise PermissionDenied(_("Invalid token")) + phone = token.first().user.phone + token.delete() + self.change_password(phone, password) + return Response({"detail": _("password updated")}, status=status.HTTP_200_OK) + + +@extend_schema(tags=["me"]) +class MeView(BaseViewSetMixin, GenericViewSet, UserService): + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + match self.action: + case "me": + return UserSerializer + case "user_update": + return UserUpdateSerializer + case _: + return None + + @action(methods=["GET", "OPTIONS"], detail=False, url_path="me") + def me(self, request): + return Response(self.get_serializer(request.user).data) + + @action(methods=["PATCH", "PUT"], detail=False, url_path="user-update") + def user_update(self, request): + ser = self.get_serializer(instance=request.user, data=request.data, partial=True) + ser.is_valid(raise_exception=True) + ser.save() + return Response({"detail": _("Malumotlar yangilandi")}) + + +@extend_schema(tags=["change-password"], description="Parolni o'zgartirish uchun") +class ChangePasswordView(BaseViewSetMixin, GenericViewSet): + serializer_class = ChangePasswordSerializer + permission_classes = (IsAuthenticated,) + + @extend_schema( + request=serializer_class, + responses={200: OpenApiResponse(ChangePasswordSerializer)}, + summary="Change user password.", + description="Change password of the authenticated user.", + ) + @action(methods=["POST"], detail=False, url_path="change-password") + def change_password(self, request, *args, **kwargs): + user = self.request.user + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + if user.check_password(request.data["old_password"]): + user.password = make_password(request.data["new_password"]) + user.save() + return Response( + data={"detail": "password changed successfully"}, + status=status.HTTP_200_OK, + ) + raise PermissionDenied(_("invalida password")) diff --git a/core/apps/logs/.gitignore b/core/apps/logs/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/core/apps/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/core/apps/shared/__init__.py b/core/apps/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/shared/admin/__init__.py b/core/apps/shared/admin/__init__.py new file mode 100644 index 0000000..134e613 --- /dev/null +++ b/core/apps/shared/admin/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/admin/settings.py b/core/apps/shared/admin/settings.py new file mode 100644 index 0000000..e07e246 --- /dev/null +++ b/core/apps/shared/admin/settings.py @@ -0,0 +1,20 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin, StackedInline +from core.apps.shared.models import SettingsModel, OptionsModel +from unfold.contrib.forms.widgets import ArrayWidget +from django.contrib.postgres.fields import ArrayField + + +class OptionsInline(StackedInline): + model = OptionsModel + extra = 1 + formfield_overrides = { + ArrayField: {"widget": ArrayWidget}, + } + + +@admin.register(SettingsModel) +class SettingsAdmin(ModelAdmin): + list_display = ["id", "key"] + inlines = [OptionsInline] + diff --git a/core/apps/shared/apps.py b/core/apps/shared/apps.py new file mode 100644 index 0000000..534230a --- /dev/null +++ b/core/apps/shared/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.shared" diff --git a/core/apps/shared/enums/__init__.py b/core/apps/shared/enums/__init__.py new file mode 100644 index 0000000..7e6f430 --- /dev/null +++ b/core/apps/shared/enums/__init__.py @@ -0,0 +1,17 @@ +from enum import Enum + + +class BaseEnum(Enum): + + def choices(self): + return [(x.name, x.value) for x in self] + + +class GenderEnum(BaseEnum): + MALE = "male" + FEMALE = "female" + + +class RoleEnum(BaseEnum): + ADMIN = "admin" + USER = "user" diff --git a/core/apps/shared/migrations/0001_initial.py b/core/apps/shared/migrations/0001_initial.py new file mode 100644 index 0000000..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..271eee1 --- /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..ae156da --- /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..4dc80cc --- /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.expired_time = datetime.now() + timedelta(seconds=models.SmsConfirm.SMS_EXPIRY_SECONDS) # noqa + sms_confirm.resend_unlock_time = datetime.now() + timedelta( + seconds=models.SmsConfirm.SMS_EXPIRY_SECONDS + ) # noqa + sms_confirm.save() + + SendConfirm.delay(phone, code) + return True + + @staticmethod + def check_confirm(phone, code): + """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..766b7bd --- /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..4074af2 --- /dev/null +++ b/core/utils/__init__.py @@ -0,0 +1,3 @@ +from .cache import * # noqa +from .console import * # noqa +from .core import * # noqa diff --git a/core/utils/cache.py b/core/utils/cache.py new file mode 100644 index 0000000..41a8b14 --- /dev/null +++ b/core/utils/cache.py @@ -0,0 +1,18 @@ +import hashlib + +from django.core.cache import cache + +from config.env import env + + +class Cache: + def remember(self, func, key: str, timeout=None, *args, **kwargs): + cache_enabled = env.bool("CACHE_ENABLED") + key = hashlib.md5(key.encode("utf-8")).hexdigest() + response = cache.get(key) + if not cache_enabled: + return func(*args, **kwargs) + elif response is None: + response = func(*args, **kwargs) + cache.set(key, response, env.int("CACHE_TIME") if timeout is None else timeout) + return response diff --git a/core/utils/console.py b/core/utils/console.py new file mode 100644 index 0000000..97a22af --- /dev/null +++ b/core/utils/console.py @@ -0,0 +1,78 @@ +import logging +import os +from typing import Any, Union + +from django.conf import settings +from django.core import management + + +class Console(management.BaseCommand): + """ + Console logging class + """ + + def get_stdout(self): + base_command = management.BaseCommand() + return base_command.stdout + + def get_style(self): + base_command = management.BaseCommand() + return base_command.style + + def success(self, message): + logging.debug(message) + self.get_stdout().write(self.get_style().SUCCESS(message)) + + def error(self, message): + self.get_stdout().write(self.get_style().ERROR(message)) + + def log(self, message): + self.get_stdout().write( + self.get_style().ERROR( + "\n{line}\n{message}\n{line}\n".format( + message=message, line="=" * len(message) + ) + ) + ) + + +class BaseMake(management.BaseCommand): + path: str + + def __init__(self, *args, **options): + super().__init__(*args, **options) + self.console = Console() + + def add_arguments(self, parser): + parser.add_argument("name") + + def handle(self, *args, **options): + name = options.get("name") + if name is None: + name = "" + + stub = open(os.path.join(settings.BASE_DIR, f"resources/stub/{self.path}.stub")) + data: Union[Any] = stub.read() + stub.close() + + stub = data.replace("{{name}}", name or "") + + + core_http_path = os.path.join(settings.BASE_DIR, "core/http") + if os.path.exists( + os.path.join(core_http_path, f"{self.path}/{name.lower()}.py") + ): # noqa + self.console.error(f"{self.name} already exists") + return + + if not os.path.exists(os.path.join(core_http_path, self.path)): + os.makedirs(os.path.join(core_http_path, self.path)) + + file = open( + os.path.join(core_http_path, f"{self.path}/{name.lower()}.py"), + "w+", + ) + file.write(stub) # type: ignore + file.close() + + self.console.success(f"{self.name} created") diff --git a/core/utils/core.py b/core/utils/core.py new file mode 100644 index 0000000..8614847 --- /dev/null +++ b/core/utils/core.py @@ -0,0 +1,6 @@ +class Helper: + """ + Helper class to handle index + """ + + pass diff --git a/core/utils/storage.py b/core/utils/storage.py new file mode 100644 index 0000000..50e6d33 --- /dev/null +++ b/core/utils/storage.py @@ -0,0 +1,33 @@ +from typing import Optional, Union + +from config.env import env + + +class Storage: + + storages = ["AWS", "MINIO", "FILE", "STATIC"] + + def __init__(self, storage: Union[str], storage_type: Union[str] = "default") -> None: + self.storage = storage + self.sorage_type = storage_type + if storage not in self.storages: + raise ValueError(f"Invalid storage type: {storage}") + + def get_backend(self) -> Optional[str]: + match self.storage: + case "AWS" | "MINIO": + return "storages.backends.s3boto3.S3Boto3Storage" + case "FILE": + return "django.core.files.storage.FileSystemStorage" + case "STATIC": + return "django.contrib.staticfiles.storage.StaticFilesStorage" + + def get_options(self) -> Optional[str]: + match self.storage: + case "AWS" | "MINIO": + if self.sorage_type == "default": + return {"bucket_name": env.str("STORAGE_BUCKET_MEDIA")} + elif self.sorage_type == "static": + return {"bucket_name": env.str("STORAGE_BUCKET_STATIC")} + case _: + return {} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..07dff31 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,61 @@ +networks: + uzxarid: + driver: bridge + +volumes: + pg_data: null + pycache: null + media: null + static: null + +services: + nginx: + networks: + - uzxarid + 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: + - uzxarid + 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: + - uzxarid + 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: + - uzxarid + restart: always + + image: redis diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..9f84974 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,46 @@ +networks: + uzxarid: + driver: bridge + +volumes: + pg_data: null + pycache: null + +services: + web: + env_file: + - .env + networks: + - uzxarid + 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: + - uzxarid + 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: + - uzxarid + restart: always + + image: redis diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ce01250 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +networks: + uzxarid: + driver: bridge + +volumes: + pg_data: null + pycache: null + +services: + nginx: + env_file: + - .env + networks: + - uzxarid + ports: + - ${PORT:-8001}:80 + volumes: + - ./resources/layout/nginx.conf:/etc/nginx/nginx.conf + - ./resources/:/usr/share/nginx/html/resources/ + build: + context: . + dockerfile: ./docker/Dockerfile.nginx + depends_on: + - web + web: + networks: + - uzxarid + 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: + - uzxarid + 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: + - uzxarid + restart: always + + image: redis diff --git a/docker/Dockerfile.nginx b/docker/Dockerfile.nginx new file mode 100644 index 0000000..22a1599 --- /dev/null +++ b/docker/Dockerfile.nginx @@ -0,0 +1,3 @@ +FROM nginx:alpine + +COPY ./resources/layout/nginx.conf /etc/nginx/nginx.conf diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web new file mode 100644 index 0000000..0b550e2 --- /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..fd0d5a4 --- /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..2b41186 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[tool.black] +line-length = 120 + +[tool.isort] +profile = "black" +line_length = 120 + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.local" +python_files = "tests.py test_*.py *_tests.py" +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", + "ignore::ResourceWarning", + "ignore::Warning" # This line will ignore all warnings +] + + +[tool.flake8] +max-line-length = 120 +ignore = ["E701", "E704", "W503"] + +[tool.pyright] +typeCheckingMode = "basic" +reportMissingImports = false +reportMissingTypeStubs = false +pythonVersion = "3.12" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..11a8dbd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,48 @@ +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 + + + +django-modeltranslation~=0.19.11 +django-ckeditor-5==0.2.15 +channels==4.2.0 + +django-cacheops~=7.1 +django-silk + +# !NOTE: on-server +# gunicorn + + +django-storages +boto3 + + +# !NOTE: on-websocket +# websockets +# channels-redis diff --git a/resources/.gitignore b/resources/.gitignore new file mode 100644 index 0000000..7627088 --- /dev/null +++ b/resources/.gitignore @@ -0,0 +1 @@ +staticfiles/ \ No newline at end of file diff --git a/resources/docs/github-actions-deploy.md b/resources/docs/github-actions-deploy.md new file mode 100644 index 0000000..8c2f6d2 --- /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: uzxarid # 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..95f57f4 --- /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..64df68a --- /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/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..59f248a --- /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..0238322 --- /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..8b0b539 --- /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..899c846 --- /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..c96a04f --- /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..6f76212 --- /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..b5c61c9 --- /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..1805a3a --- /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..101cf65 --- /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 `