commit 256e80cc232bcc5c6a0a1063d1fb7713e116ebcb Author: A'zamov Samandar Date: Fri Nov 21 14:41:16 2025 +0500 first commit 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 0000000..57ac67e Binary files /dev/null and b/resources/static/images/logo.png differ diff --git a/resources/static/js/alpine.js b/resources/static/js/alpine.js new file mode 100644 index 0000000..8ec2a90 --- /dev/null +++ b/resources/static/js/alpine.js @@ -0,0 +1,9 @@ +import Alpine from 'alpinejs' +import counter from "./counter"; + +window.Alpine = Alpine + +Alpine.data("vars", counter) + +Alpine.start() + diff --git a/resources/static/js/app.js b/resources/static/js/app.js new file mode 100644 index 0000000..0b7e57d --- /dev/null +++ b/resources/static/js/app.js @@ -0,0 +1 @@ +import "./alpine" diff --git a/resources/static/js/counter.js b/resources/static/js/counter.js new file mode 100644 index 0000000..293649a --- /dev/null +++ b/resources/static/js/counter.js @@ -0,0 +1,3 @@ +export default () => ({ + 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 `