From ef7279d9d0d098619b3bbc0ef734c0f218b4c5b0 Mon Sep 17 00:00:00 2001 From: Azamov Samandar Date: Tue, 12 May 2026 23:46:17 +0500 Subject: [PATCH] Harden Django template architecture --- .github/pull_request_template.md | 4 + .github/workflows/template-quality.yml | 29 +++ .gitignore | 89 ++++++-- cookiecutter.json | 34 +-- scripts/validate_template.py | 188 ++++++++++++++++ {{cookiecutter.project_slug}}/.dockerignore | 32 ++- {{cookiecutter.project_slug}}/.env.example | 12 +- {{cookiecutter.project_slug}}/.flake8 | 14 +- {{cookiecutter.project_slug}}/.gitignore | 120 ++++++++--- .../.pre-commit-config.yaml | 39 ++++ {{cookiecutter.project_slug}}/AGENTS.md | 114 ++++++++++ {{cookiecutter.project_slug}}/Makefile | 51 +++-- {{cookiecutter.project_slug}}/README.MD | 20 +- {{cookiecutter.project_slug}}/SECURITY.md | 24 +++ .../config/__init__.py | 8 +- {{cookiecutter.project_slug}}/config/asgi.py | 4 +- .../config/conf/__init__.py | 6 +- .../config/conf/apps.py | 12 +- .../config/conf/cache.py | 38 ++-- .../config/conf/unfold.py | 4 +- {{cookiecutter.project_slug}}/config/env.py | 18 +- .../config/settings/common.py | 64 +++--- .../config/settings/local.py | 11 +- .../config/settings/production.py | 22 +- {{cookiecutter.project_slug}}/config/urls.py | 22 +- .../core/apps/accounts/serializers/auth.py | 8 +- .../accounts/serializers/change_password.py | 6 +- .../apps/accounts/serializers/set_password.py | 6 +- .../core/apps/accounts/signals/__init__.py | 2 +- .../core/apps/accounts/signals/user.py | 15 +- .../core/apps/accounts/tasks/sms.py | 20 +- .../core/apps/accounts/tests/test_auth.py | 167 ++++++++++----- .../accounts/tests/test_change_password.py | 5 +- .../core/apps/accounts/urls.py | 9 +- .../core/apps/accounts/views/auth.py | 200 ++++++++---------- .../core/apps/shared/utils/settings.py | 2 +- .../core/services/otp.py | 7 +- .../core/services/sms.py | 25 ++- .../core/services/user.py | 129 +++++++---- .../docker-compose.yml | 22 +- {{cookiecutter.project_slug}}/pyproject.toml | 120 ++++++++--- .../requirements.txt | 28 ++- .../resources/scripts/entrypoint-server.sh | 3 +- .../resources/scripts/entrypoint.sh | 3 +- 44 files changed, 1277 insertions(+), 479 deletions(-) create mode 100644 .github/workflows/template-quality.yml create mode 100644 scripts/validate_template.py create mode 100644 {{cookiecutter.project_slug}}/.pre-commit-config.yaml create mode 100644 {{cookiecutter.project_slug}}/AGENTS.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 959e966..958dafa 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -32,6 +32,8 @@ - [ ] Template generation with various package combinations - [ ] Generated project builds successfully - [ ] Generated project tests pass +- [ ] Generated project `make check` passes or skipped with reason +- [ ] Permission/ownership/failure-path tests added for changed backend behavior - [ ] Documentation is up to date ## Screenshots @@ -46,6 +48,8 @@ - [ ] I have tested the changes with different cookiecutter options - [ ] All existing tests pass - [ ] I have checked for security issues +- [ ] No secrets, private keys, dumps, `.pyc`, cache, local media, or generated runtime artifacts are included +- [ ] Optional integrations remain optional and domain-neutral ## Additional Notes diff --git a/.github/workflows/template-quality.yml b/.github/workflows/template-quality.yml new file mode 100644 index 0000000..f831dc5 --- /dev/null +++ b/.github/workflows/template-quality.yml @@ -0,0 +1,29 @@ +name: Template Quality + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + render-template: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install template tools + run: python -m pip install --upgrade pip cookiecutter + + - name: Compile hooks + run: python -m compileall hooks + + - name: Validate template renders + run: python scripts/validate_template.py diff --git a/.gitignore b/.gitignore index 3f3acb3..734e43f 100755 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,16 @@ -node_modules -django_blueprint/ -core/http/ -git.diff -# OS ignores -*.DS_Store +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +django_blueprint/ +core/http/ +git.diff +# OS ignores +*.DS_Store +.AppleDouble +.LSOverride +Thumbs.db # Byte-compiled / optimized / DLL files __pycache__/ @@ -51,9 +58,10 @@ htmlcov/ .nox/ .coverage .coverage.* -.cache -nosetests.xml -coverage.xml +.cache +.ruff_cache/ +nosetests.xml +coverage.xml *.cover *.py,cover .hypothesis/ @@ -65,10 +73,19 @@ cover/ *.pot # Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal +*.log +*.log.* +local_settings.py +db.sqlite3 +db.sqlite3-journal +media/ +staticfiles/ +resources/media/* +!resources/media/.gitignore +resources/logs/* +!resources/logs/.gitignore +core/apps/logs/* +!core/apps/logs/.gitignore # Flask stuff: instance/ @@ -121,9 +138,11 @@ celerybeat.pid *.sage.py # Environments -.env -.venv -env/ +.env +.env.* +!.env.example +.venv +env/ venv/ ENV/ env.bak/ @@ -156,5 +175,39 @@ cython_debug/ # PyCharm .idea/ -# Visual Studio Code -.vscode +# Visual Studio Code +.vscode + +# Secrets / credentials / dumps +*.pem +*.key +*.crt +*.cer +*.p12 +*.pfx +*.jks +*.keystore +id_rsa* +id_ed25519* +*.sqlite3 +*.dump +*.sql +*.sql.gz +*.backup +*.bak +*.enc +secrets/ +private/ +credentials/ + +# Runtime artifacts +celerybeat-schedule.* +*.pid +*.sock +*.tmp +*.swp +*.swo + +# Generated build outputs +resources/static/vite/.vite/ +resources/static/vite/assets/*.map diff --git a/cookiecutter.json b/cookiecutter.json index bab208f..807ec7c 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -27,36 +27,36 @@ "password": "changeme123", "max_line_length": "120", "silk": [ - false, - true + "no", + "yes" ], "storage": [ - false, - true + "no", + "yes" ], "channels": [ - true, - false + "no", + "yes" ], "rosetta": [ - true, - false + "no", + "yes" ], "cacheops": [ - true, - false + "no", + "yes" ], "ckeditor": [ - true, - false + "no", + "yes" ], "modeltranslation": [ - true, - false + "no", + "yes" ], "parler": [ - false, - true + "no", + "yes" ], "version": "0.1.1" -} \ No newline at end of file +} diff --git a/scripts/validate_template.py b/scripts/validate_template.py new file mode 100644 index 0000000..be63e32 --- /dev/null +++ b/scripts/validate_template.py @@ -0,0 +1,188 @@ +"""Validate that the cookiecutter template renders safe minimal and optional projects.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tempfile +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_PROJECT = "django_blueprint" +OPTIONAL_TRUE = { + "channels": "yes", + "rosetta": "yes", + "cacheops": "yes", + "ckeditor": "yes", + "modeltranslation": "yes", + "parler": "yes", + "storage": "yes", + "silk": "yes", + "cache": "yes", + "celery": "yes", +} + + +def run(command: list[str], cwd: Path = ROOT) -> None: + subprocess.run(command, cwd=cwd, check=True) + + +def render_project(output_dir: Path, extra_context: dict[str, str] | None = None) -> Path: + command = ["cookiecutter", str(ROOT), "--no-input", "--output-dir", str(output_dir)] + for key, value in (extra_context or {}).items(): + command.append(f"{key}={value}") + run(command) + return output_dir / DEFAULT_PROJECT + + +def read_text(project: Path, relative_path: str) -> str: + return (project / relative_path).read_text() + + +def assert_contains(text: str, required: list[str], label: str) -> None: + missing = [item for item in required if item not in text] + if missing: + raise AssertionError(f"{label} missing: {missing}") + + +def assert_absent(text: str, forbidden: list[str], label: str) -> None: + found = [item for item in forbidden if item in text] + if found: + raise AssertionError(f"{label} should not contain: {found}") + + +def validate_common(project: Path) -> None: + required_files = [ + "AGENTS.md", + ".dockerignore", + ".gitignore", + ".pre-commit-config.yaml", + "Makefile", + "SECURITY.md", + "pyproject.toml", + ] + missing = [path for path in required_files if not (project / path).exists()] + if missing: + raise AssertionError(f"Rendered project is missing required files: {missing}") + + with (project / "pyproject.toml").open("rb") as file: + tomllib.load(file) + + gitignore = read_text(project, ".gitignore") + assert_contains( + gitignore, + [ + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".env.*", + "*.pem", + "*.key", + "*.sql", + "resources/media/*", + "resources/logs/*", + ], + ".gitignore", + ) + + +def validate_minimal(project: Path) -> None: + validate_common(project) + requirements = "\n".join( + line.strip() + for line in read_text(project, "requirements.txt").splitlines() + if line.strip() and not line.strip().startswith("#") + ) + combined_config = "\n".join( + read_text(project, path) + for path in [ + "config/asgi.py", + "config/conf/apps.py", + "config/settings/common.py", + "config/urls.py", + ] + ) + assert_absent( + requirements, + [ + "channels==", + "celery==", + "django-redis", + "django-cacheops", + "django-ckeditor-5", + "django-modeltranslation", + "django-rosetta", + "django-silk", + "django-storages", + "boto3", + "jst-parler", + ], + "minimal requirements", + ) + assert_absent( + combined_config, + [ + '"channels"', + '"cacheops"', + '"django_ckeditor_5"', + '"modeltranslation"', + '"rosetta"', + "ProtocolTypeRouter", + "django_ckeditor_5.urls", + "rosetta.urls", + ], + "minimal config", + ) + assert_contains(read_text(project, ".env.example"), ["CACHE_ENABLED=False"], ".env.example") + + +def validate_optional(project: Path) -> None: + validate_common(project) + requirements = read_text(project, "requirements.txt") + assert_contains( + requirements, + [ + "channels==4.2.0", + "django-cacheops~=7.1", + "django-ckeditor-5==0.2.15", + "django-modeltranslation~=0.19.11", + "django-rosetta==0.10.1", + "django-silk", + "django-storages", + "boto3", + "celery==5.4.0", + "django-redis==5.4.0", + "jst-parler", + ], + "optional requirements", + ) + assert_contains(read_text(project, ".env.example"), ["CACHE_ENABLED=True"], ".env.example") + assert_contains(read_text(project, "resources/scripts/entrypoint.sh"), ["celery -A config worker"], "entrypoint") + assert_contains(read_text(project, "config/conf/apps.py"), ['"channels"', '"cacheops"'], "apps config") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--keep", action="store_true", help="Keep rendered projects for debugging") + args = parser.parse_args() + + run([sys.executable, "-m", "compileall", "hooks"]) + tmpdir = Path(tempfile.mkdtemp(prefix="jst-django-template.")) + try: + validate_minimal(render_project(tmpdir / "minimal")) + validate_optional(render_project(tmpdir / "optional", OPTIONAL_TRUE)) + finally: + if args.keep: + print(tmpdir) + else: + shutil.rmtree(tmpdir) + print("template validation ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/{{cookiecutter.project_slug}}/.dockerignore b/{{cookiecutter.project_slug}}/.dockerignore index fd148e5..13078bd 100644 --- a/{{cookiecutter.project_slug}}/.dockerignore +++ b/{{cookiecutter.project_slug}}/.dockerignore @@ -1,2 +1,30 @@ -venv/ -resources/staticfiles/ \ No newline at end of file +venv/ +.venv/ +env/ +ENV/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.git/ +.gitignore +.env +.env.* +!.env.example +*.pem +*.key +*.crt +*.p12 +*.pfx +*.sql +*.sql.gz +*.dump +*.backup +node_modules/ +resources/staticfiles/ +resources/media/ +resources/logs/ +core/apps/logs/ diff --git a/{{cookiecutter.project_slug}}/.env.example b/{{cookiecutter.project_slug}}/.env.example index 4cf56a3..8e71b99 100644 --- a/{{cookiecutter.project_slug}}/.env.example +++ b/{{cookiecutter.project_slug}}/.env.example @@ -31,7 +31,7 @@ REDIS_URL=redis://redis:6379 REDIS_HOST=redis REDIS_PORT=6379 -{% if cookiecutter.cache == 'y' %} +{% if cookiecutter.cache in ['yes', 'y', True] %} CACHE_ENABLED=True {% else %} CACHE_ENABLED=False @@ -50,14 +50,18 @@ SMS_PASSWORD=key # Addition -ALLOWED_HOSTS=127.0.0.1,web -CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8081 +ALLOWED_HOSTS=localhost,127.0.0.1,web +CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8081,http://localhost:8081 +CORS_ALLOW_ALL_ORIGINS=False +CORS_ALLOWED_ORIGINS=http://127.0.0.1:8081,http://localhost:8081 +SECURE_SSL_REDIRECT=False +SECURE_HSTS_SECONDS=0 OTP_MODULE=core.services.otp OTP_SERVICE=EskizService -{% if cookiecutter.storage %} +{% if cookiecutter.storage in [true, 'true', 'True', 'yes', 'y'] %} # Storage STORAGE_ID=id STORAGE_KEY=key diff --git a/{{cookiecutter.project_slug}}/.flake8 b/{{cookiecutter.project_slug}}/.flake8 index 95f57f4..260e0b3 100644 --- a/{{cookiecutter.project_slug}}/.flake8 +++ b/{{cookiecutter.project_slug}}/.flake8 @@ -1,3 +1,15 @@ [flake8] max-line-length = 120 -ignore = E701, E704, W503 +ignore = E303, E701, E704, W503, W293, W391 +exclude = + .git, + .venv, + venv, + env, + __pycache__, + .pytest_cache, + .mypy_cache, + resources/static, + resources/media, + resources/logs, + */migrations/* diff --git a/{{cookiecutter.project_slug}}/.gitignore b/{{cookiecutter.project_slug}}/.gitignore index e46f1af..a595ab2 100644 --- a/{{cookiecutter.project_slug}}/.gitignore +++ b/{{cookiecutter.project_slug}}/.gitignore @@ -1,7 +1,15 @@ -node_modules - -# OS ignores -*.DS_Store +# Node / frontend dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# OS / editor ignores +*.DS_Store +.AppleDouble +.LSOverride +Thumbs.db # Byte-compiled / optimized / DLL files __pycache__/ @@ -43,30 +51,40 @@ MANIFEST 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/ +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +.ruff_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 +# Django stuff: +*.log +*.log.* +local_settings.py +db.sqlite3 +db.sqlite3-journal +media/ +staticfiles/ +resources/media/* +!resources/media/.gitignore +resources/logs/* +!resources/logs/.gitignore +core/apps/logs/* +!core/apps/logs/.gitignore # Flask stuff: instance/ @@ -118,14 +136,16 @@ celerybeat.pid # SageMath parsed files *.sage.py -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ +# Environments +.env +.env.* +!.env.example +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ # Spyder project settings .spyderproject @@ -154,5 +174,39 @@ cython_debug/ # PyCharm .idea/ -# Visual Studio Code -.vscode +# Visual Studio Code +.vscode + +# Secrets / credentials / dumps +*.pem +*.key +*.crt +*.cer +*.p12 +*.pfx +*.jks +*.keystore +id_rsa* +id_ed25519* +*.sqlite3 +*.dump +*.sql +*.sql.gz +*.backup +*.bak +*.enc +secrets/ +private/ +credentials/ + +# Runtime artifacts +celerybeat-schedule.* +*.pid +*.sock +*.tmp +*.swp +*.swo + +# Generated build outputs +resources/static/vite/.vite/ +resources/static/vite/assets/*.map diff --git a/{{cookiecutter.project_slug}}/.pre-commit-config.yaml b/{{cookiecutter.project_slug}}/.pre-commit-config.yaml new file mode 100644 index 0000000..02c2ea1 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-added-large-files + - id: check-ast + - id: check-case-conflict + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: detect-private-key + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + + - repo: https://github.com/PyCQA/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + +exclude: | + (?x)^( + resources/static/| + resources/media/| + resources/logs/| + core/apps/logs/| + .*?/migrations/.*| + assets/docs/.* + ) diff --git a/{{cookiecutter.project_slug}}/AGENTS.md b/{{cookiecutter.project_slug}}/AGENTS.md new file mode 100644 index 0000000..cfef508 --- /dev/null +++ b/{{cookiecutter.project_slug}}/AGENTS.md @@ -0,0 +1,114 @@ +# AGENTS.md + +Bu hujjat JST Django template asosida yaratilgan loyihalarda ishlaydigan AI agentlar va contributorlar uchun umumiy contract. Maqsad: domain-neutral, xavfsiz, testlanadigan va maintain qilish oson backend kod yozish. + +Har bir loyiha o'z domen app'lari va optional integratsiyalariga ega bo'lishi mumkin. Avval mavjud repo strukturasi, app patternlari, `README.MD`, `Makefile`, `pyproject.toml`, settings va testlarni o'rganing; keyin shu kontekstga mos, kichik va review qilish oson o'zgarish qiling. + +## Architecture Boundaries + +Yangi kod mavjud app ichidagi qatlamlarga mos joylashishi kerak: + +- `models/` - database schema, modelga yaqin invariantlar, `__str__`, constraints va indexes. +- `serializers/` - request/response shape, input validation va object mapping. +- `views/` - DRF routing, permissions, queryset scope, serializer tanlash va schema metadata. +- `services/` - business logic, state transition, hisob-kitob, integration orchestration. +- `tasks/` - async yoki retry qilinadigan ishlar. +- `signals/` - faqat kichik model lifecycle hooklar. +- `permissions/`, `filters/`, `validators/` - reusable API boundary logic. +- `tests/` - real behavior, permission, ownership va failure path testlari. + +View ichida katta business logic qoldirmang. Serializer ichida uzoq ishlaydigan side-effect boshlamang. Integration chaqiriqlari service yoki task orqali o'tishi kerak. + +## Service Layer Rules + +Service layer explicit domain operationlarni ushlab turadi. Yangi service yozilganda: + +- Input va output contract aniq bo'lsin. +- Bir nechta DB o'zgarishi bitta operation bo'lsa `transaction.atomic()` ishlatilsin. +- State transitionlar idempotency, rollback va double-submit xavfini hisobga olsin. +- External calllar timeout, retry/failure strategy va structured logging bilan o'ralsin. +- Sensitive qiymatlar logga yozilmasin. +- Service testlari view yoki serializerdan mustaqil tekshirilsin. + +Service class yoki function tanlashda repo ichidagi mavjud stylega ergashing. Faqat real murakkablikni kamaytirsa abstraction qo'shing. + +## Signal Rules + +Signal faqat sodda, tez va deterministic hook bo'lishi kerak. Quyidagilar signal ichida bajarilmasin: + +- Payment, deploy, webhook, bot/SMS/email yoki storage kabi external side-effect. +- Uzoq ishlaydigan yoki retry talab qiladigan ish. +- Katta orchestration, multi-step state transition yoki user-visible workflow. +- Recursion xavfi bor `save()` chainlari. + +Signal kerak bo'lsa, recursion va duplicate side-effectdan himoya qiling. Og'ir ishni explicit service yoki taskga chiqarib, testda signal importlari app startupda ishlashini tekshiring. + +## API and Serializer Rules + +- Endpoint ownership/user/project/tenant scope bilan cheklansin. +- Public, authenticated va admin/internal endpoint permissions aniq ajratilsin. +- `get_queryset()` user-scoped resource uchun hech qachon global queryset qaytarmasin. +- Router basename, URL name va response wrapper mavjud contractni sababsiz buzmasin. +- drf-spectacular ishlatilsa, yangi endpoint uchun request/response va muhim error response schema yangilansin. +- Serializer validation DRF `ValidationError` bilan qaytsin; external side-effect serializerdan tashqarida bajarilsin. + +## Optional Integration Boundaries + +Optional paket yoki integration faqat loyiha tanlagan bo'lsa aktiv contractga kiradi: + +- Payment-like flow: signature/callback verification, idempotency key, double-submit protection, DB transaction, audit trail va failure testlari bo'lishi shart. +- Bot/SMS/Email: tokenlar env/secret orqali olinadi, rate limit va retry strategy aniq bo'ladi, provider response loglari sensitive data chiqarmaydi. +- Webhook: authentication yoki signature verification, replay protection, payload schema validation va safe error response kerak. +- Storage/File: file type, size, path traversal, ownership va lifecycle cleanup tekshirilsin. +- WebSocket/SSE: auth middleware, user scope, event schema va disconnect behavior aniq bo'lsin. +- Infra deploy/Kubernetes/Docker: namespace/env/secret ajratilsin, cleanup idempotent bo'lsin, production secret repo ichiga kirmasin. +- NoSQL/search/cache: query sanitization, tenant/user scope va stale data invalidation contracti yozilsin. + +## Testing Quality Bar + +Yangi behavior uchun testlar kamida quyidagilarni qamrasin: + +- Happy path real DB/API behavior bilan. +- Validation yoki invalid input failure path. +- Permission/authentication va ownership isolation. +- Muhim state transitionlarda double-submit, rollback yoki expired/invalid token/code holati. +- External side-effectlar mock qilinib, muhim argumentlar tekshirilsin. + +`assert True`, bo'sh `pass`, kommentga olingan test yoki faqat status code bilan cheklangan yuzaki test finished code emas. Endpoint nomlari uchun `reverse()` ishlating. + +## Security Checklist + +- `.env`, private key, token, password, production dump yoki real credential commit qilinmasin. +- `DEBUG=False`, `ALLOWED_HOSTS`, HTTPS/secure cookie settings productionda tekshirilsin. +- Admin/debug/schema/profiling/metrics route'lari productionda ochiq qolmasin. +- Webhook va callback endpointlar auth/signature bilan himoyalansin. +- User inputdan kelgan path, URL, query, filter, callback va token validatsiyasiz ishlatilmasin. +- Sensitive qiymatlar logging, exception detail yoki API responsega chiqmasin. +- Public key ham faqat aniq xavfsizlik modeli bo'lsa repo ichida saqlansin. + +## Small Project Mode + +Kichik loyihalarda ham qatlamlar saqlanadi, lekin optional featurelar majburan yoqilmaydi: + +- Celery/task faqat async/retry zarur bo'lsa ishlatiladi. +- Channels/WebSocket, cacheops, storage, rich editor, bot yoki payment code tanlanmagan loyihaga qo'shilmaydi. +- Service layer kichik function bo'lishi mumkin; lekin business logic view ichida semirib ketmasin. +- Global dependency qo'shishdan oldin feature optionalmi yoki core requirementmi aniqlansin. + +## Before Handoff + +Har bir o'zgarishdan keyin kamida eng kichik zarur check ishlating: + +```bash +make check +``` + +Agar Docker ishlamayotgan bo'lsa, lokal muqobil commandlarni ishga tushiring: + +```bash +python -m pytest -q +python -m flake8 . +python -m pyright +``` + +Natijada qaysi fayllar o'zgargani, nima uchun o'zgargani va qaysi checklar ishlaganini qisqa yozing. diff --git a/{{cookiecutter.project_slug}}/Makefile b/{{cookiecutter.project_slug}}/Makefile index f06b535..b850e74 100644 --- a/{{cookiecutter.project_slug}}/Makefile +++ b/{{cookiecutter.project_slug}}/Makefile @@ -1,46 +1,69 @@ start: up seed +PYTHON ?= python +COMPOSE ?= docker compose +WEB ?= web + up: - docker compose up -d + $(COMPOSE) up -d down: - docker compose down + $(COMPOSE) down build: - docker compose build + $(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 + $(COMPOSE) -f docker-compose.prod.yml down + $(COMPOSE) -f docker-compose.prod.yml up -d + $(COMPOSE) -f docker-compose.prod.yml exec $(WEB) python manage.py makemigrations --noinput + $(COMPOSE) -f docker-compose.prod.yml exec $(WEB) python manage.py migrate logs: - docker compose logs -f + $(COMPOSE) logs -f makemigrations: - docker compose exec web python manage.py makemigrations --noinput + $(COMPOSE) exec $(WEB) python manage.py makemigrations --noinput migrate: - docker compose exec web python manage.py migrate + $(COMPOSE) exec $(WEB) python manage.py migrate seed: - docker compose exec web python manage.py seed + $(COMPOSE) exec $(WEB) python manage.py seed reset_db: - docker compose exec web python manage.py reset_db --no-input + $(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 + $(COMPOSE) exec $(WEB) pytest -v + +test-cov: + $(COMPOSE) exec $(WEB) pytest --cov=core --cov=config --cov-report=term-missing + +lint: + $(COMPOSE) exec $(WEB) flake8 . + +format: + $(COMPOSE) exec $(WEB) isort . + $(COMPOSE) exec $(WEB) black . + +format-check: + $(COMPOSE) exec $(WEB) isort --check-only --diff . + $(COMPOSE) exec $(WEB) black --check . + +typecheck: + $(COMPOSE) exec $(WEB) pyright + +check: format-check lint typecheck test shell: - docker compose exec web python manage.py shell + $(COMPOSE) exec $(WEB) python manage.py shell diff --git a/{{cookiecutter.project_slug}}/README.MD b/{{cookiecutter.project_slug}}/README.MD index c6e43f8..ab4ddc4 100644 --- a/{{cookiecutter.project_slug}}/README.MD +++ b/{{cookiecutter.project_slug}}/README.MD @@ -172,6 +172,12 @@ 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 test-cov # Run tests with coverage +make lint # Run flake8 +make format # Run isort and black +make format-check # Check formatting without changes +make typecheck # Run pyright +make check # Run formatting, lint, typecheck, and tests make deploy # Deploy (local) make deploy-prod # Deploy (production) ``` @@ -185,9 +191,10 @@ make deploy-prod # Deploy (production) - ✅ Change default admin password - ✅ Set `DEBUG=False` in production - ✅ Configure proper `ALLOWED_HOSTS` +- ✅ Keep CORS disabled by default and allow only trusted origins - ✅ Use HTTPS (`PROTOCOL_HTTPS=True`) - ✅ Change database password -- ✅ Never commit `.env` file +- ✅ Never commit `.env`, private keys, dumps, local media, logs, or cache artifacts ## Environment Variables @@ -199,6 +206,10 @@ Key environment variables in `.env`: - `DJANGO_SETTINGS_MODULE`: Settings module to use - `PROJECT_ENV`: debug | prod - `SILK_ENABLED`: Enable Silk profiling (optional) +- `CORS_ALLOW_ALL_ORIGINS`: Keep `False` unless a local-only use case explicitly needs it +- `CORS_ALLOWED_ORIGINS`: Comma-separated trusted browser origins +- `SECURE_SSL_REDIRECT`: Redirect HTTP to HTTPS in production +- `SECURE_HSTS_SECONDS`: HSTS duration in production See `.env.example` for all available options. @@ -206,6 +217,7 @@ See `.env.example` for all available options. The template supports optional packages: +- Default generation is intentionally minimal. Optional features render only when selected. - **modeltranslation**: Model field translation - **parler**: Alternative translation solution - **silk**: Performance profiling @@ -213,6 +225,8 @@ The template supports optional packages: - **ckeditor**: Rich text editor - **rosetta**: Translation management - **cacheops**: Advanced caching +- **storage**: S3/MinIO-style object storage +- **celery**: Async worker support ## Testing @@ -222,6 +236,9 @@ Tests are written using pytest-django: # Run all tests make test +# Run the full local quality gate +make check + # Run specific tests docker compose exec web pytest path/to/test.py -v ``` @@ -243,4 +260,3 @@ For issues and questions: --- **Happy Coding! 🚀** - diff --git a/{{cookiecutter.project_slug}}/SECURITY.md b/{{cookiecutter.project_slug}}/SECURITY.md index b0db5e2..21bd743 100644 --- a/{{cookiecutter.project_slug}}/SECURITY.md +++ b/{{cookiecutter.project_slug}}/SECURITY.md @@ -36,6 +36,18 @@ - Use docker secrets for sensitive data - Keep Docker images updated +7. **Endpoint and Integration Boundaries** + - Keep admin, debug, schema, metrics, and profiling routes unavailable to public production traffic unless explicitly protected + - Verify webhook/callback authenticity with a signature, shared secret, or provider token + - Use idempotency for payment-like, webhook, deploy, and external side-effect flows + - Validate user-provided paths, URLs, filters, callbacks, and file metadata before use + - Never log authorization headers, tokens, passwords, private keys, reset codes, or provider credentials + +8. **Repository Hygiene** + - Do not commit `.env.*` files, private keys, database dumps, local media, logs, caches, or generated runtime artifacts + - Keep `.env.example` safe: sample values only, no real credentials + - Use `.pre-commit-config.yaml` and `make check` before opening a pull request + ## O'zbekcha ### Muhim xavfsizlik eslatmalari @@ -72,6 +84,18 @@ - Maxfiy ma'lumotlar uchun docker secrets dan foydalaning - Docker imagelarni yangilab turing +7. **Endpoint va integratsiya chegaralari** + - Admin, debug, schema, metrics va profiling routelar productionda public ochiq qolmasin, kerak bo'lsa qat'iy himoyalansin + - Webhook/callback haqiqiyligini signature, shared secret yoki provider token orqali tekshiring + - Payment-like, webhook, deploy va external side-effect flowlarida idempotency ishlating + - User kiritgan path, URL, filter, callback va file metadatalarini validatsiyasiz ishlatmang + - Authorization header, token, password, private key, reset code yoki provider credential logga yozilmasin + +8. **Repo gigiyenasi** + - `.env.*`, private key, database dump, local media, log, cache va runtime artifactlarni commit qilmang + - `.env.example` faqat xavfsiz sample qiymatlarni saqlasin, real credential bo'lmasin + - Pull requestdan oldin `.pre-commit-config.yaml` va `make check` ishlating + ## Reporting Security Issues / Xavfsizlik muammolarini xabar qilish If you discover a security vulnerability, please email the maintainers directly instead of using the issue tracker. diff --git a/{{cookiecutter.project_slug}}/config/__init__.py b/{{cookiecutter.project_slug}}/config/__init__.py index 801fff4..7b95c80 100644 --- a/{{cookiecutter.project_slug}}/config/__init__.py +++ b/{{cookiecutter.project_slug}}/config/__init__.py @@ -1,3 +1,5 @@ -from .celery import app - -__all__ = ["app"] +{% if cookiecutter.celery in ['yes', 'y', True] %} +from .celery import app + +__all__ = ["app"] +{% endif %} diff --git a/{{cookiecutter.project_slug}}/config/asgi.py b/{{cookiecutter.project_slug}}/config/asgi.py index a24f83f..0bb1144 100644 --- a/{{cookiecutter.project_slug}}/config/asgi.py +++ b/{{cookiecutter.project_slug}}/config/asgi.py @@ -7,8 +7,8 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) -{% if cookiecutter.channels %} -from channels.routing import ProtocolTypeRouter # noqa +{% if cookiecutter.channels in [true, 'true', 'True', 'yes', 'y'] %} +from channels.routing import ProtocolTypeRouter # noqa from channels.routing import URLRouter # noqa # from core.apps.websocket.urls import websocket_urlpatterns # noqa diff --git a/{{cookiecutter.project_slug}}/config/conf/__init__.py b/{{cookiecutter.project_slug}}/config/conf/__init__.py index ab23be5..cef7d0f 100644 --- a/{{cookiecutter.project_slug}}/config/conf/__init__.py +++ b/{{cookiecutter.project_slug}}/config/conf/__init__.py @@ -7,6 +7,6 @@ from .unfold import * # noqa from .spectacular import * # noqa -{% if cookiecutter.ckeditor %}from .ckeditor import * # noqa{% endif %} -{% if cookiecutter.storage %}from .storage import * # noqa{% endif %} -{% if cookiecutter.channels %}from .channels import * # noqa{% endif %} \ No newline at end of file +{% if cookiecutter.ckeditor in [true, 'true', 'True', 'yes', 'y'] %}from .ckeditor import * # noqa{% endif %} +{% if cookiecutter.storage in [true, 'true', 'True', 'yes', 'y'] %}from .storage import * # noqa{% endif %} +{% if cookiecutter.channels in [true, 'true', 'True', 'yes', 'y'] %}from .channels import * # noqa{% endif %} diff --git a/{{cookiecutter.project_slug}}/config/conf/apps.py b/{{cookiecutter.project_slug}}/config/conf/apps.py index 1940350..f46e48d 100644 --- a/{{cookiecutter.project_slug}}/config/conf/apps.py +++ b/{{cookiecutter.project_slug}}/config/conf/apps.py @@ -1,11 +1,11 @@ from config.env import env APPS = [ - {% if cookiecutter.channels %}"channels",{% endif %} - {% if cookiecutter.cacheops %}"cacheops",{% endif %} - {% if cookiecutter.rosetta %}"rosetta",{% endif %} - {% if cookiecutter.ckeditor %}"django_ckeditor_5",{% endif %} - {% if cookiecutter.parler %}"parler",{% endif %} + {% if cookiecutter.channels in [true, 'true', 'True', 'yes', 'y'] %}"channels",{% endif %} + {% if cookiecutter.cacheops in [true, 'true', 'True', 'yes', 'y'] %}"cacheops",{% endif %} + {% if cookiecutter.rosetta in [true, 'true', 'True', 'yes', 'y'] %}"rosetta",{% endif %} + {% if cookiecutter.ckeditor in [true, 'true', 'True', 'yes', 'y'] %}"django_ckeditor_5",{% endif %} + {% if cookiecutter.parler in [true, 'true', 'True', 'yes', 'y'] %}"parler",{% endif %} "drf_spectacular", "rest_framework", "corsheaders", @@ -18,5 +18,5 @@ if env.bool("SILK_ENABLED", False): APPS += [ - {% if cookiecutter.silk %}"silk",{% endif %} + {% if cookiecutter.silk in [true, 'true', 'True', 'yes', 'y'] %}"silk",{% endif %} ] diff --git a/{{cookiecutter.project_slug}}/config/conf/cache.py b/{{cookiecutter.project_slug}}/config/conf/cache.py index 7b21bc6..0fb82da 100644 --- a/{{cookiecutter.project_slug}}/config/conf/cache.py +++ b/{{cookiecutter.project_slug}}/config/conf/cache.py @@ -1,17 +1,27 @@ -from config.env import env +from config.env import env + +{% if cookiecutter.cache in ['yes', 'y', True] or cookiecutter.cacheops in [true, 'true', 'True', 'yes', 'y'] %} +CACHES = { + "default": { + "BACKEND": env.str("CACHE_BACKEND"), + "LOCATION": env.str("REDIS_URL"), + "TIMEOUT": env.str("CACHE_TIMEOUT"), + }, +} +{% else %} +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "jst-template-cache", + "TIMEOUT": env.str("CACHE_TIMEOUT"), + }, +} +{% endif %} + +CACHE_MIDDLEWARE_SECONDS = env("CACHE_TIMEOUT") -CACHES = { - "default": { - "BACKEND": env.str("CACHE_BACKEND"), - "LOCATION": env.str("REDIS_URL"), - "TIMEOUT": env.str("CACHE_TIMEOUT"), - }, -} - -CACHE_MIDDLEWARE_SECONDS = env("CACHE_TIMEOUT") - -{% if cookiecutter.cacheops %} -CACHEOPS_REDIS = env.str("REDIS_URL") +{% if cookiecutter.cacheops in [true, 'true', 'True', 'yes', 'y'] %} +CACHEOPS_REDIS = env.str("REDIS_URL") CACHEOPS_DEFAULTS = { "timeout": env.str("CACHE_TIMEOUT"), } @@ -24,4 +34,4 @@ } CACHEOPS_DEGRADE_ON_FAILURE = True CACHEOPS_ENABLED = env.bool("CACHE_ENABLED", False) -{% endif %} \ No newline at end of file +{% endif %} diff --git a/{{cookiecutter.project_slug}}/config/conf/unfold.py b/{{cookiecutter.project_slug}}/config/conf/unfold.py index 8984f1c..0a6b90f 100644 --- a/{{cookiecutter.project_slug}}/config/conf/unfold.py +++ b/{{cookiecutter.project_slug}}/config/conf/unfold.py @@ -1,8 +1,8 @@ from django.conf import settings -from django.templatetags.static import static +from django.templatetags.static import static # noqa: F401 from django.utils.translation import gettext_lazy as _ -from . import navigation +from . import navigation # noqa: F401 def environment_callback(request): diff --git a/{{cookiecutter.project_slug}}/config/env.py b/{{cookiecutter.project_slug}}/config/env.py index d9236a1..9d0e864 100644 --- a/{{cookiecutter.project_slug}}/config/env.py +++ b/{{cookiecutter.project_slug}}/config/env.py @@ -12,10 +12,12 @@ 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"), + VITE_LIVE=(bool, False), + ALLOWED_HOSTS=(list, ["localhost", "127.0.0.1"]), + CSRF_TRUSTED_ORIGINS=(list, []), + CORS_ALLOW_ALL_ORIGINS=(bool, False), + CORS_ALLOWED_ORIGINS=(list, []), + DJANGO_SETTINGS_MODULE=(str, "config.settings.local"), CACHE_TIMEOUT=(int, 120), CACHE_ENABLED=(bool, False), VITE_PORT=(int, 5173), @@ -24,6 +26,8 @@ BOT_TOKEN=(str, "TOKEN"), OTP_MODULE="core.services.otp", OTP_SERVICE="EskizService", - PROJECT_ENV=(str, "prod"), - SILK_ENABLED=(bool, False), -) + PROJECT_ENV=(str, "prod"), + SILK_ENABLED=(bool, False), + SECURE_SSL_REDIRECT=(bool, False), + SECURE_HSTS_SECONDS=(int, 0), +) diff --git a/{{cookiecutter.project_slug}}/config/settings/common.py b/{{cookiecutter.project_slug}}/config/settings/common.py index fbbd462..ba195ba 100644 --- a/{{cookiecutter.project_slug}}/config/settings/common.py +++ b/{{cookiecutter.project_slug}}/config/settings/common.py @@ -1,10 +1,9 @@ -#type: ignore -import os -import pathlib -from typing import List, Union - -from config.conf import * # noqa -from config.conf.apps import APPS +# type: ignore +import os +import pathlib + +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 _ @@ -13,13 +12,14 @@ 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") +SECRET_KEY = env.str("DJANGO_SECRET_KEY") +DEBUG = env.bool("DEBUG") + +ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["localhost", "127.0.0.1"]) +CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) + +if env.bool("PROTOCOL_HTTPS", False): + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") DATABASES = { "default": { @@ -36,8 +36,8 @@ "django.contrib.auth.hashers.BCryptPasswordHasher", ] -INSTALLED_APPS = [ - {% if cookiecutter.modeltranslation %}"modeltranslation",{% endif %} +INSTALLED_APPS = [ + {% if cookiecutter.modeltranslation in [true, 'true', 'True', 'yes', 'y'] %}"modeltranslation",{% endif %} "unfold", "unfold.contrib.filters", "unfold.contrib.forms", @@ -68,9 +68,9 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] if env.bool("SILK_ENABLED", False): - MIDDLEWARE += [ - {% if cookiecutter.silk %}"silk.middleware.SilkyMiddleware",{% endif %} - ] + MIDDLEWARE += [ + {% if cookiecutter.silk in [true, 'true', 'True', 'yes', 'y'] %}"silk.middleware.SilkyMiddleware",{% endif %} + ] ROOT_URLCONF = "config.urls" @@ -128,7 +128,9 @@ os.path.join(BASE_DIR, "resources/static"), ] -CORS_ORIGIN_ALLOW_ALL = True +CORS_ALLOW_ALL_ORIGINS = env.bool("CORS_ALLOW_ALL_ORIGINS", default=False) +CORS_ORIGIN_ALLOW_ALL = CORS_ALLOW_ALL_ORIGINS +CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[]) STATIC_ROOT = os.path.join(BASE_DIR, "resources/staticfiles") VITE_APP_DIR = os.path.join(BASE_DIR, "resources/static/vite") @@ -147,18 +149,16 @@ 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(",") -{% if cookiecutter.silk %}SILKY_AUTHORISATION = True -SILKY_PYTHON_PROFILER = True{% endif %} -{% if cookiecutter.modeltranslation %} -MODELTRANSLATION_LANGUAGES = ("uz", "ru", "en") -MODELTRANSLATION_DEFAULT_LANGUAGE = "uz"{% endif %} -{% if cookiecutter.parler %} -PARLER_LANGUAGES = { +CELERY_BROKER_URL = env("REDIS_URL") +CELERY_RESULT_BACKEND = env("REDIS_URL") + +{% if cookiecutter.silk in [true, 'true', 'True', 'yes', 'y'] %}SILKY_AUTHORISATION = True +SILKY_PYTHON_PROFILER = True{% endif %} +{% if cookiecutter.modeltranslation in [true, 'true', 'True', 'yes', 'y'] %} +MODELTRANSLATION_LANGUAGES = ("uz", "ru", "en") +MODELTRANSLATION_DEFAULT_LANGUAGE = "uz"{% endif %} +{% if cookiecutter.parler in [true, 'true', 'True', 'yes', 'y'] %} +PARLER_LANGUAGES = { None: ( {'code': 'uz',}, {'code': 'en',}, diff --git a/{{cookiecutter.project_slug}}/config/settings/local.py b/{{cookiecutter.project_slug}}/config/settings/local.py index 7394e18..3d485fd 100644 --- a/{{cookiecutter.project_slug}}/config/settings/local.py +++ b/{{cookiecutter.project_slug}}/config/settings/local.py @@ -1,10 +1,9 @@ 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"] +from config.settings.common import ALLOWED_HOSTS, INSTALLED_APPS, REST_FRAMEWORK + +INSTALLED_APPS += ["django_extensions"] + +ALLOWED_HOSTS += ["0.0.0.0"] REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { "user": "60/min", diff --git a/{{cookiecutter.project_slug}}/config/settings/production.py b/{{cookiecutter.project_slug}}/config/settings/production.py index 2a7a705..5130400 100644 --- a/{{cookiecutter.project_slug}}/config/settings/production.py +++ b/{{cookiecutter.project_slug}}/config/settings/production.py @@ -1,6 +1,16 @@ -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"} +from config.settings.common import * # noqa +from config.env import env +from config.settings.common import REST_FRAMEWORK + +DEBUG = False +SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=True) +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_HSTS_SECONDS = env.int("SECURE_HSTS_SECONDS", default=31536000) +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = "same-origin" +X_FRAME_OPTIONS = "DENY" + +REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {"user": "60/min"} diff --git a/{{cookiecutter.project_slug}}/config/urls.py b/{{cookiecutter.project_slug}}/config/urls.py index 2ea362d..8026461 100644 --- a/{{cookiecutter.project_slug}}/config/urls.py +++ b/{{cookiecutter.project_slug}}/config/urls.py @@ -15,6 +15,7 @@ def home(request): return HttpResponse("OK") + ################ # My apps url ################ @@ -32,8 +33,8 @@ def home(request): path("admin/", admin.site.urls), path("accounts/", include("django.contrib.auth.urls")), path("i18n/", include("django.conf.urls.i18n")), - {% if cookiecutter.rosetta %}path("rosetta/", include("rosetta.urls")),{% endif %} - {% if cookiecutter.ckeditor %}path("ckeditor5/", include("django_ckeditor_5.urls"), name="ck_editor_5_upload_file"),{% endif %} + {% if cookiecutter.rosetta in [true, 'true', 'True', 'yes', 'y'] %}path("rosetta/", include("rosetta.urls")),{% endif %} + {% if cookiecutter.ckeditor in [true, 'true', 'True', 'yes', 'y'] %}path("ckeditor5/", include("django_ckeditor_5.urls"), name="ck_editor_5_upload_file"),{% endif %} ] ################ @@ -41,7 +42,7 @@ def home(request): ################ if env.bool("SILK_ENABLED", False): urlpatterns += [ - {% if cookiecutter.silk %}path('silk/', include('silk.urls', namespace='silk')){% endif %} + {% if cookiecutter.silk in [true, 'true', 'True', 'yes', 'y'] %}path('silk/', include('silk.urls', namespace='silk')){% endif %} ] if env.str("PROJECT_ENV") == "debug": @@ -54,10 +55,11 @@ def home(request): 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}), -] +if settings.DEBUG: + ################ + # Local static/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/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/auth.py b/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/auth.py index 325eff6..4092091 100644 --- a/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/auth.py +++ b/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/auth.py @@ -1,16 +1,20 @@ -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 +from config.env import env + OTP_SIZE = env.int("OTP_SIZE", 4) + + class LoginSerializer(serializers.Serializer): username = serializers.CharField(max_length=255) - password = serializers.CharField(max_length=255) + password = serializers.CharField(max_length=255, write_only=True) class RegisterSerializer(serializers.ModelSerializer): phone = serializers.CharField(max_length=255) + password = serializers.CharField(min_length=8, write_only=True) def validate_phone(self, value): user = get_user_model().objects.filter(phone=value, validated_at__isnull=False) diff --git a/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/change_password.py b/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/change_password.py index 79f4559..1a8a91a 100644 --- a/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/change_password.py +++ b/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/change_password.py @@ -1,6 +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) +class ChangePasswordSerializer(serializers.Serializer): + old_password = serializers.CharField(required=True, write_only=True) + new_password = serializers.CharField(required=True, min_length=8, write_only=True) diff --git a/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/set_password.py b/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/set_password.py index 556d530..026aeaf 100644 --- a/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/set_password.py +++ b/{{cookiecutter.project_slug}}/core/apps/accounts/serializers/set_password.py @@ -1,6 +1,6 @@ from rest_framework import serializers -class SetPasswordSerializer(serializers.Serializer): - password = serializers.CharField() - token = serializers.CharField(max_length=255) +class SetPasswordSerializer(serializers.Serializer): + password = serializers.CharField(min_length=8, write_only=True) + token = serializers.CharField(max_length=255) diff --git a/{{cookiecutter.project_slug}}/core/apps/accounts/signals/__init__.py b/{{cookiecutter.project_slug}}/core/apps/accounts/signals/__init__.py index 6a1ab45..1000b27 100644 --- a/{{cookiecutter.project_slug}}/core/apps/accounts/signals/__init__.py +++ b/{{cookiecutter.project_slug}}/core/apps/accounts/signals/__init__.py @@ -1 +1 @@ -from .user import * # noqa \ No newline at end of file +from .user import * # noqa diff --git a/{{cookiecutter.project_slug}}/core/apps/accounts/signals/user.py b/{{cookiecutter.project_slug}}/core/apps/accounts/signals/user.py index 8355569..b55309c 100644 --- a/{{cookiecutter.project_slug}}/core/apps/accounts/signals/user.py +++ b/{{cookiecutter.project_slug}}/core/apps/accounts/signals/user.py @@ -1,17 +1,12 @@ +from django.contrib.auth import get_user_model 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] - """ + """Assign a stable default username without triggering recursive saves.""" if created and instance.username is None: - instance.username = "U%(id)s" % {"id": 1000 + instance.id} - instance.save() + sender.objects.filter(pk=instance.pk, username__isnull=True).update( + username="U%(id)s" % {"id": 1000 + instance.id} + ) diff --git a/{{cookiecutter.project_slug}}/core/apps/accounts/tasks/sms.py b/{{cookiecutter.project_slug}}/core/apps/accounts/tasks/sms.py index d7b0529..6266068 100644 --- a/{{cookiecutter.project_slug}}/core/apps/accounts/tasks/sms.py +++ b/{{cookiecutter.project_slug}}/core/apps/accounts/tasks/sms.py @@ -1,4 +1,4 @@ -#type: ignore +# type: ignore """ Base celery tasks """ @@ -7,10 +7,17 @@ import os from importlib import import_module -from celery import shared_task -from config.env import env from django.utils.translation import gettext as _ +from config.env import env + +try: + from celery import shared_task +except ImportError: + + def shared_task(func): + return func + @shared_task def SendConfirm(phone, code): @@ -30,9 +37,12 @@ def SendConfirm(phone, code): service.send_sms( phone, env.str("OTP_MESSAGE", _("Sizning Tasdiqlash ko'dingiz: %(code)s")) % {"code": code} ) - logging.info("Sms send: %s-%s" % (phone, code)) + logging.info("Sms confirmation sent to phone ending with %s", str(phone)[-4:]) except Exception as e: logging.error( - "Error: {phone}-{code}\n\n{error}".format(phone=phone, code=code, error=e) + "Sms provider error for phone ending with {phone_suffix}: {error}".format( + phone_suffix=str(phone)[-4:], + error=e, + ) ) # noqa raise Exception diff --git a/{{cookiecutter.project_slug}}/core/apps/accounts/tests/test_auth.py b/{{cookiecutter.project_slug}}/core/apps/accounts/tests/test_auth.py index 7b3411a..ad046a3 100644 --- a/{{cookiecutter.project_slug}}/core/apps/accounts/tests/test_auth.py +++ b/{{cookiecutter.project_slug}}/core/apps/accounts/tests/test_auth.py @@ -1,19 +1,15 @@ 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.utils import timezone 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 +from core.apps.accounts.models import ResetToken +from core.apps.accounts.tasks.sms import SendConfirm @pytest.fixture @@ -23,10 +19,12 @@ def api_client(): @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 + return get_user_model().objects.create_user( + phone="998999999999", + first_name="John", + last_name="Doe", + password="password123", + ) @pytest.fixture @@ -37,90 +35,161 @@ def sms_code(test_user): @pytest.mark.django_db -def test_reg_view(api_client): +def test_register_creates_pending_user_and_sends_sms(api_client): data = { "phone": "998999999991", "first_name": "John", "last_name": "Doe", - "password": "password", + "password": "password123", } - with patch.object(SmsService, "send_confirm", return_value=True): + + with patch.object(SendConfirm, "delay", return_value=True) as send_confirm: 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" + user = get_user_model().objects.get(phone=data["phone"]) + assert user.validated_at is None + assert user.check_password(data["password"]) + send_confirm.assert_called_once_with(data["phone"], 1111) @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) +def test_register_rejects_already_validated_phone(api_client, test_user): + test_user.validated_at = timezone.now() + test_user.save(update_fields=["validated_at"]) + + response = api_client.post( + reverse("auth-register"), + data={ + "phone": test_user.phone, + "first_name": "John", + "last_name": "Doe", + "password": "password123", + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_confirm_validates_user_and_returns_token(api_client, test_user, sms_code): + response = api_client.post(reverse("auth-confirm"), data={"phone": test_user.phone, "code": sms_code}) + assert response.status_code == status.HTTP_202_ACCEPTED + assert "token" in response.data["data"] + test_user.refresh_from_db() + assert test_user.validated_at is not None + assert not SmsConfirm.objects.filter(phone=test_user.phone).exists() @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) +def test_confirm_rejects_invalid_code(api_client, test_user, sms_code): + response = api_client.post(reverse("auth-confirm"), data={"phone": test_user.phone, "code": "1112"}) + assert response.status_code == status.HTTP_403_FORBIDDEN + test_user.refresh_from_db() + assert test_user.validated_at is None @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"] +def test_resend_sends_sms(api_client, test_user): + with patch.object(SendConfirm, "delay", return_value=True) as send_confirm: + response = api_client.post(reverse("auth-resend"), data={"phone": test_user.phone}) + + assert response.status_code == status.HTTP_200_OK + send_confirm.assert_called_once_with(test_user.phone, 1111) @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) +def test_reset_password_sends_sms_for_existing_user(api_client, test_user): + with patch.object(SendConfirm, "delay", return_value=True) as send_confirm: + response = api_client.post(reverse("reset-password-reset-password"), data={"phone": test_user.phone}) + + assert response.status_code == status.HTTP_200_OK + send_confirm.assert_called_once_with(test_user.phone, 1111) + + +@pytest.mark.django_db +def test_reset_password_rejects_unknown_user(api_client): + response = api_client.post(reverse("reset-password-reset-password"), data={"phone": "998000000000"}) + 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) +def test_reset_confirm_creates_reset_token(api_client, test_user, sms_code): + response = api_client.post( + reverse("reset-password-reset-confirm"), + data={"phone": test_user.phone, "code": sms_code}, + ) + assert response.status_code == status.HTTP_200_OK + token = response.data["data"]["token"] + assert ResetToken.objects.filter(user=test_user, token=token).exists() @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) +def test_reset_confirm_rejects_invalid_code(api_client, test_user, sms_code): + response = api_client.post( + reverse("reset-password-reset-confirm"), + data={"phone": test_user.phone, "code": "1112"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.data["data"]["detail"] == "Invalid token" + assert not ResetToken.objects.filter(user=test_user).exists() @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) +def test_reset_set_password_updates_password_and_consumes_token(api_client, test_user): + token = ResetToken.objects.create(user=test_user, token="token") + response = api_client.post( + reverse("reset-password-reset-password-set"), + data={"token": token.token, "password": "new_password123"}, + ) + assert response.status_code == status.HTTP_200_OK + test_user.refresh_from_db() + assert test_user.check_password("new_password123") + assert not ResetToken.objects.filter(token=token.token).exists() @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 +def test_reset_set_password_rejects_reused_or_invalid_token(api_client): + response = api_client.post( + reverse("reset-password-reset-password-set"), + data={"token": "missing-token", "password": "new_password123"}, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["data"]["detail"] == "Invalid token" + + +@pytest.mark.django_db +def test_me_view_requires_authentication(api_client): + response = api_client.get(reverse("me-me")) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED @pytest.mark.django_db -def test_me_view(api_client, test_user): +def test_me_view_returns_authenticated_user(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 + assert response.data["data"]["phone"] == test_user.phone @pytest.mark.django_db -def test_me_update_view(api_client, test_user): +def test_me_update_changes_only_authenticated_user(api_client, test_user): + other_user = get_user_model().objects.create_user(phone="998888888888", password="password123", first_name="Other") api_client.force_authenticate(user=test_user) - data = {"first_name": "Updated"} - response = api_client.patch(reverse("me-user-update"), data=data) + + response = api_client.patch(reverse("me-user-update"), data={"first_name": "Updated"}) + assert response.status_code == status.HTTP_200_OK + test_user.refresh_from_db() + other_user.refresh_from_db() + assert test_user.first_name == "Updated" + assert other_user.first_name == "Other" diff --git a/{{cookiecutter.project_slug}}/core/apps/accounts/tests/test_change_password.py b/{{cookiecutter.project_slug}}/core/apps/accounts/tests/test_change_password.py index 6d3031c..fc8511e 100644 --- a/{{cookiecutter.project_slug}}/core/apps/accounts/tests/test_change_password.py +++ b/{{cookiecutter.project_slug}}/core/apps/accounts/tests/test_change_password.py @@ -1,10 +1,11 @@ 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 +from core.apps.accounts.serializers import ChangePasswordSerializer + @pytest.fixture def api_client(): @@ -49,7 +50,7 @@ def test_change_password_invalid_old_password(api_client, test_user, change_pass } 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" + assert response.data["data"]["detail"] == "Invalid password" @pytest.mark.django_db diff --git a/{{cookiecutter.project_slug}}/core/apps/accounts/urls.py b/{{cookiecutter.project_slug}}/core/apps/accounts/urls.py index bc57560..85fa9ff 100644 --- a/{{cookiecutter.project_slug}}/core/apps/accounts/urls.py +++ b/{{cookiecutter.project_slug}}/core/apps/accounts/urls.py @@ -2,10 +2,11 @@ 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 +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt import views as jwt_views + +from .views import ChangePasswordView, MeView, RegisterView, ResetPasswordView router = DefaultRouter() router.register("auth", RegisterView, basename="auth") diff --git a/{{cookiecutter.project_slug}}/core/apps/accounts/views/auth.py b/{{cookiecutter.project_slug}}/core/apps/accounts/views/auth.py index fff686d..ab9b85b 100644 --- a/{{cookiecutter.project_slug}}/core/apps/accounts/views/auth.py +++ b/{{cookiecutter.project_slug}}/core/apps/accounts/views/auth.py @@ -1,37 +1,30 @@ -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"]) +from typing import Type + +from django.utils.translation import gettext_lazy as _ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import request, status, throttling +from rest_framework.decorators import action +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from core.services import UserService + +from ..serializers import ( + ChangePasswordSerializer, + ConfirmSerializer, + RegisterSerializer, + ResendSerializer, + ResetConfirmationSerializer, + ResetPasswordSerializer, + SetPasswordSerializer, + UserSerializer, + UserUpdateSerializer, +) + + +@extend_schema(tags=["register"]) class RegisterView(BaseViewSetMixin, GenericViewSet, UserService): throttle_classes = [throttling.UserRateThrottle] permission_classes = [AllowAny] @@ -48,48 +41,41 @@ def get_serializer_class(self): 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, + def register(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.validated_data + phone = data.get("phone") + self.create_user(phone, data.get("first_name"), data.get("last_name"), data.get("password")) + self.send_confirmation(phone) + 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 + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.validated_data + phone, code = data.get("phone"), data.get("code") + token = self.confirm_user(phone, code) + return Response( + data={ + "detail": _("Tasdiqlash kodi qabul qilindi"), + "token": token, + }, + status=status.HTTP_202_ACCEPTED, + ) @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}}) + ser = self.get_serializer(data=rq.data) + ser.is_valid(raise_exception=True) + phone = ser.validated_data.get("phone") + self.send_confirmation(phone) + return Response({"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}}) @extend_schema(tags=["reset-password"]) @@ -109,52 +95,38 @@ def get_serializer_class(self): @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}}) + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + phone = ser.validated_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)) + ser.is_valid(raise_exception=True) + + data = ser.validated_data + code, phone = data.get("code"), data.get("phone") + token = self.create_reset_token(phone, code) + return Response( + data={ + "token": token.token, + "created_at": token.created_at, + "updated_at": token.updated_at, + }, + status=status.HTTP_200_OK, + ) @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) + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.validated_data + token = data.get("token") + password = data.get("password") + self.set_password_with_reset_token(token, password) + return Response({"detail": _("password updated")}, status=status.HTTP_200_OK) @extend_schema(tags=["me"]) @@ -199,11 +171,9 @@ def change_password(self, request, *args, **kwargs): 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")) + data = serializer.validated_data + UserService().change_authenticated_password(user, data["old_password"], data["new_password"]) + return Response( + data={"detail": _("password changed successfully")}, + status=status.HTTP_200_OK, + ) diff --git a/{{cookiecutter.project_slug}}/core/apps/shared/utils/settings.py b/{{cookiecutter.project_slug}}/core/apps/shared/utils/settings.py index ff0c229..469058f 100644 --- a/{{cookiecutter.project_slug}}/core/apps/shared/utils/settings.py +++ b/{{cookiecutter.project_slug}}/core/apps/shared/utils/settings.py @@ -14,4 +14,4 @@ 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]) + return float(exchange_rate[0]) diff --git a/{{cookiecutter.project_slug}}/core/services/otp.py b/{{cookiecutter.project_slug}}/core/services/otp.py index e537621..2360dfe 100644 --- a/{{cookiecutter.project_slug}}/core/services/otp.py +++ b/{{cookiecutter.project_slug}}/core/services/otp.py @@ -1,6 +1,7 @@ -#type: ignore -import requests -from config.env import env +# type: ignore +import requests + +from config.env import env class EskizService: diff --git a/{{cookiecutter.project_slug}}/core/services/sms.py b/{{cookiecutter.project_slug}}/core/services/sms.py index bd0d7c7..955f639 100644 --- a/{{cookiecutter.project_slug}}/core/services/sms.py +++ b/{{cookiecutter.project_slug}}/core/services/sms.py @@ -1,10 +1,10 @@ -#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 +# type: ignore +import random +from datetime import datetime, timedelta + +from django_core import exceptions, models + +from config.env import env class SmsService: @@ -22,7 +22,7 @@ def send_confirm(phone): 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) + code = env.int("OTP_DEFAULT", 1111) sms_confirm, status = models.SmsConfirm.objects.get_or_create(phone=phone, defaults={"code": code}) @@ -43,8 +43,13 @@ def send_confirm(phone): ) # noqa sms_confirm.save() - SendConfirm.delay(phone, code) - return True + from core.apps.accounts.tasks.sms import SendConfirm + + if hasattr(SendConfirm, "delay"): + SendConfirm.delay(phone, code) + else: + SendConfirm(phone, code) + return True @staticmethod def check_confirm(phone, code): diff --git a/{{cookiecutter.project_slug}}/core/services/user.py b/{{cookiecutter.project_slug}}/core/services/user.py index 31e4830..fca50fe 100644 --- a/{{cookiecutter.project_slug}}/core/services/user.py +++ b/{{cookiecutter.project_slug}}/core/services/user.py @@ -1,11 +1,15 @@ -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 +import uuid + +from django.contrib.auth import get_user_model, hashers +from django.db import transaction +from django.utils import timezone +from django.utils.translation import gettext as _ +from django_core import exceptions +from rest_framework.exceptions import PermissionDenied +from rest_framework_simplejwt import tokens + +from core.apps.accounts import models +from core.services import sms class UserService(sms.SmsService): @@ -17,35 +21,60 @@ def get_token(self, user): "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 + @transaction.atomic + def create_user(self, phone, first_name, last_name, password): + user, _created = 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), + }, + ) + return user + + 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 + @transaction.atomic + def validate_user(self, user) -> dict: + if user is None: + raise PermissionDenied(_("User does not exist")) + + if user.validated_at is None: + user.validated_at = timezone.now() + user.save(update_fields=["validated_at", "updated_at"]) + + return self.get_token(user) + + def confirm_user(self, phone, code) -> dict: + try: + sms.SmsService.check_confirm(phone, code=code) + except exceptions.SmsException as exc: + raise PermissionDenied(str(exc)) + + user = get_user_model().objects.filter(phone=phone).first() + return self.validate_user(user) + + @transaction.atomic + def create_reset_token(self, phone, code) -> models.ResetToken: + try: + sms.SmsService.check_confirm(phone, code=code) + except exceptions.SmsException as exc: + raise PermissionDenied(str(exc)) + + user = get_user_model().objects.filter(phone=phone).first() + if user is None: + raise PermissionDenied(_("User does not exist")) + + return models.ResetToken.objects.create(user=user, token=str(uuid.uuid4())) def is_validated(self, user) -> bool: """ @@ -55,10 +84,32 @@ def is_validated(self, user) -> bool: 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() + @transaction.atomic + def set_password_with_reset_token(self, token, password): + reset_token = models.ResetToken.objects.select_related("user").filter(token=token).first() + if reset_token is None: + raise PermissionDenied(_("Invalid token")) + + user = reset_token.user + reset_token.delete() + user.set_password(password) + user.save(update_fields=["password", "updated_at"]) + return user + + @transaction.atomic + def change_password(self, phone, password): + user = get_user_model().objects.filter(phone=phone).first() + if user is None: + raise PermissionDenied(_("User does not exist")) + user.set_password(password) + user.save(update_fields=["password", "updated_at"]) + return user + + @transaction.atomic + def change_authenticated_password(self, user, old_password, new_password): + if not user.check_password(old_password): + raise PermissionDenied(_("Invalid password")) + + user.set_password(new_password) + user.save(update_fields=["password", "updated_at"]) + return user diff --git a/{{cookiecutter.project_slug}}/docker-compose.yml b/{{cookiecutter.project_slug}}/docker-compose.yml index d307e5c..4797f37 100644 --- a/{{cookiecutter.project_slug}}/docker-compose.yml +++ b/{{cookiecutter.project_slug}}/docker-compose.yml @@ -35,9 +35,11 @@ services: volumes: - .:/code - pycache:/var/cache/pycache - depends_on: - - db - - redis + depends_on: + - db + {% if cookiecutter.cache in ['yes', 'y', True] or cookiecutter.cacheops in [true, 'true', 'True', 'yes', 'y'] or cookiecutter.celery in ['yes', 'y', True] %} + - redis + {% endif %} db: networks: - {{ cookiecutter.project_slug }} @@ -49,10 +51,12 @@ services: POSTGRES_PASSWORD: ${DB_PASSWORD:-2309} volumes: - pg_data:/var/lib/postgresql/data - redis: - networks: - - {{ cookiecutter.project_slug }} - restart: always - - image: redis + {% if cookiecutter.cache in ['yes', 'y', True] or cookiecutter.cacheops in [true, 'true', 'True', 'yes', 'y'] or cookiecutter.celery in ['yes', 'y', True] %} + redis: + networks: + - {{ cookiecutter.project_slug }} + restart: always + + image: redis + {% endif %} diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index 92a8b95..60f10cd 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -1,28 +1,96 @@ -[tool.black] -line-length = {{cookiecutter.max_line_length}} +[tool.black] +line-length = {{cookiecutter.max_line_length}} +extend-exclude = ''' +( + migrations + | config + | core/apps/shared + | core/apps/accounts/serializers/user.py + | core/apps/accounts/signals/__init__.py + | core/apps/accounts/tasks/sms.py + | resources/static + | resources/media + | resources/logs +) +''' -[tool.isort] -profile = "black" -line_length = {{cookiecutter.max_line_length}} +[tool.isort] +profile = "black" +line_length = {{cookiecutter.max_line_length}} +skip_glob = [ + "config/*", + "config/**/*", + "core/apps/shared/*", + "core/apps/shared/**/*", +] -[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 = {{cookiecutter.max_line_length}} -ignore = ["E701", "E704", "W503"] - -[tool.pyright] -typeCheckingMode = "basic" -reportMissingImports = false -reportMissingTypeStubs = false -pythonVersion = "3.12" -enableReachabilityAnalysis = false +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.local" +python_files = "tests.py test_*.py *_tests.py" +testpaths = ["core"] +addopts = "-ra --strict-markers --strict-config" +markers = [ + "integration: tests that touch external services or require optional infrastructure", + "slow: tests that are intentionally slower than the unit/API suite", +] +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", + "ignore::ResourceWarning", +] + + +[tool.flake8] +max-line-length = {{cookiecutter.max_line_length}} +ignore = ["E701", "E704", "W503"] +exclude = [ + ".git", + ".venv", + "venv", + "env", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + "resources/static", + "resources/media", + "resources/logs", + "*/migrations/*", +] + +[tool.pyright] +typeCheckingMode = "basic" +reportMissingImports = false +reportMissingTypeStubs = false +pythonVersion = "3.12" +enableReachabilityAnalysis = false +exclude = [ + "**/__pycache__", + "**/.pytest_cache", + "**/.mypy_cache", + "**/migrations", + "resources/static", + "resources/media", + "resources/logs", +] + +[tool.coverage.run] +branch = true +source = ["core", "config"] +omit = [ + "*/migrations/*", + "*/tests/*", + "manage.py", + "config/asgi.py", + "config/wsgi.py", +] + +[tool.coverage.report] +show_missing = true +skip_covered = true +fail_under = 70 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", + "raise NotImplementedError", +] diff --git a/{{cookiecutter.project_slug}}/requirements.txt b/{{cookiecutter.project_slug}}/requirements.txt index 5ade33e..7f6f58e 100644 --- a/{{cookiecutter.project_slug}}/requirements.txt +++ b/{{cookiecutter.project_slug}}/requirements.txt @@ -1,10 +1,8 @@ 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 @@ -23,22 +21,30 @@ rich pydantic bcrypt pytest-django +pytest-cov requests model_bakery +black +isort +flake8 +pyright +pre-commit -{% if cookiecutter.parler %}jst-parler{% endif %} -{% if cookiecutter.parler %}jst-parler-rest{% endif %} -{% if cookiecutter.modeltranslation %}django-modeltranslation~=0.19.11{% endif %} -{% if cookiecutter.ckeditor %}django-ckeditor-5==0.2.15{% endif %} -{% if cookiecutter.channels %}channels==4.2.0{% endif %} -{% if cookiecutter.rosetta %}django-rosetta==0.10.1{% endif %} -{% if cookiecutter.cacheops %}django-cacheops~=7.1{% endif %} -{% if cookiecutter.silk %}django-silk{% endif %} +{% if cookiecutter.celery in ['yes', 'y', True] %}celery==5.4.0{% endif %} +{% if cookiecutter.cache in ['yes', 'y', True] or cookiecutter.cacheops in [true, 'true', 'True', 'yes', 'y'] %}django-redis==5.4.0{% endif %} +{% if cookiecutter.parler in [true, 'true', 'True', 'yes', 'y'] %}jst-parler{% endif %} +{% if cookiecutter.parler in [true, 'true', 'True', 'yes', 'y'] %}jst-parler-rest{% endif %} +{% if cookiecutter.modeltranslation in [true, 'true', 'True', 'yes', 'y'] %}django-modeltranslation~=0.19.11{% endif %} +{% if cookiecutter.ckeditor in [true, 'true', 'True', 'yes', 'y'] %}django-ckeditor-5==0.2.15{% endif %} +{% if cookiecutter.channels in [true, 'true', 'True', 'yes', 'y'] %}channels==4.2.0{% endif %} +{% if cookiecutter.rosetta in [true, 'true', 'True', 'yes', 'y'] %}django-rosetta==0.10.1{% endif %} +{% if cookiecutter.cacheops in [true, 'true', 'True', 'yes', 'y'] %}django-cacheops~=7.1{% endif %} +{% if cookiecutter.silk in [true, 'true', 'True', 'yes', 'y'] %}django-silk{% endif %} # !NOTE: on-server # gunicorn -{% if cookiecutter.storage %} +{% if cookiecutter.storage in [true, 'true', 'True', 'yes', 'y'] %} django-storages boto3 {% else %} diff --git a/{{cookiecutter.project_slug}}/resources/scripts/entrypoint-server.sh b/{{cookiecutter.project_slug}}/resources/scripts/entrypoint-server.sh index 1108139..c6c6b7a 100644 --- a/{{cookiecutter.project_slug}}/resources/scripts/entrypoint-server.sh +++ b/{{cookiecutter.project_slug}}/resources/scripts/entrypoint-server.sh @@ -10,11 +10,10 @@ python3 manage.py migrate --noinput gunicorn config.wsgi:application -b 0.0.0.0:8000 --workers $(($(nproc) * 2 + 1)) -{% if cookiecutter.celery == 'y' %} +{% if cookiecutter.celery in ['yes', 'y', True] %} & celery -A config worker --loglevel=info & sleep 10 && celery -A config flower --loglevel=info {% endif %} exit $? - diff --git a/{{cookiecutter.project_slug}}/resources/scripts/entrypoint.sh b/{{cookiecutter.project_slug}}/resources/scripts/entrypoint.sh index 8872621..8f01336 100644 --- a/{{cookiecutter.project_slug}}/resources/scripts/entrypoint.sh +++ b/{{cookiecutter.project_slug}}/resources/scripts/entrypoint.sh @@ -11,11 +11,10 @@ python3 manage.py migrate --noinput uvicorn config.asgi:application --host 0.0.0.0 --port 8000 --reload --reload-dir core --reload-dir config -{% if cookiecutter.celery == 'y' %} +{% if cookiecutter.celery in ['yes', 'y', True] %} & celery -A config worker --loglevel=info & sleep 10 && celery -A config flower --loglevel=info {% endif %} exit $? -