From f74cd5baa88e8c3a569c2520549be776fc7c5d51 Mon Sep 17 00:00:00 2001 From: Erik Belak Date: Tue, 23 Jun 2026 16:41:49 +0200 Subject: [PATCH 1/7] Add initial project scaffolding - Setup layered application structure under `apps/` (e.g., `api`, `core`, `tests`). - Include templates for administration (`admin.py`), testing (`base.py`, `fixtures.py`), proposals (`.template.md`), and documentation (`mkdocs` with `docs/`). - Configure project guidelines: linting (flake8), formatting (Black), type-checking (mypy), and proposal-first workflow. - Introduce base test helpers (e.g., `Base` class with signed request logic). - Add Makefile for streamlined development commands (`test`, `migrate`, `format`, etc.). - Populate project with necessary configuration files for MkDocs, Flake8, and initial `.env` setup. --- CHANGELOG.md | 28 +++ README.md | 27 +- hooks/post_gen_project.py | 37 +++ .../.claude/skills/ip/SKILL.md | 98 ++++++++ {{cookiecutter.directory_name}}/.env.example | 19 +- {{cookiecutter.directory_name}}/.flake8 | 17 ++ {{cookiecutter.directory_name}}/CLAUDE.md | 184 ++++++++++++++ {{cookiecutter.directory_name}}/Dockerfile | 4 +- {{cookiecutter.directory_name}}/Makefile | 31 +++ .../apps/__init__.py | 0 .../apps/api/encoders.py | 24 +- .../apps/api/filters/user.py | 2 +- .../apps/api/response.py | 40 ++- .../apps/api/urls.py | 7 +- .../apps/api/views/base.py | 10 +- .../apps/api/views/user.py | 12 +- .../apps/core/admin.py | 99 ++++++++ .../apps/core/auth.py | 6 +- .../apps/core/checkers/user.py | 8 +- .../apps/core/managers/base.py | 3 +- .../apps/core/managers/token.py | 12 + .../apps/core/managers/user.py | 2 + .../apps/core/models/api_key.py | 3 +- .../apps/core/models/base.py | 4 + .../apps/core/models/token.py | 11 +- .../apps/core/models/user.py | 9 +- .../apps/core/services/email_notification.py | 2 +- .../apps/tests/README.md | 69 +++++ .../apps/tests/__init__.py | 0 .../apps/tests/api/__init__.py | 0 .../apps/tests/api/auth/__init__.py | 0 .../tests/api/auth/test_authentication.py | 63 +++++ .../apps/tests/api/auth/test_logout.py | 44 ++++ .../apps/tests/api/recovery/__init__.py | 0 .../tests/api/recovery/test_recovery_code.py | 63 +++++ .../apps/tests/api/status/__init__.py | 0 .../apps/tests/api/status/test_status.py | 23 ++ .../apps/tests/api/user/__init__.py | 0 .../tests/api/user/test_change_password.py | 48 ++++ .../apps/tests/api/user/test_user.py | 139 ++++++++++ .../apps/tests/base.py | 105 ++++++++ .../apps/tests/db/__init__.py | 0 .../apps/tests/db/test_ordering.py | 48 ++++ .../apps/tests/db/test_soft_delete.py | 46 ++++ .../apps/tests/db/test_token.py | 32 +++ .../apps/tests/db/test_user_manager.py | 30 +++ .../apps/tests/fixtures.py | 44 ++++ .../apps/tests/services/__init__.py | 0 .../tests/services/test_email_notification.py | 55 ++++ .../conf/entrypoint.sh | 4 +- .../conf/supervisor.conf | 2 +- .../docker-compose.yml | 35 +-- .../docs/.authors.yml | 9 + {{cookiecutter.directory_name}}/docs/index.md | 16 ++ .../docs/proposals/.template.md | 237 ++++++++++++++++++ .../docs/proposals/index.md | 52 ++++ .../docs/proposals/posts/.gitkeep | 0 {{cookiecutter.directory_name}}/mkdocs.yml | 99 ++++++++ .../pyproject.toml | 31 ++- .../{{cookiecutter.project_name}}/asgi.py | 4 +- .../settings/base.py | 76 ++++-- .../settings/development.py | 2 +- .../settings/production.py | 21 +- .../settings/test.py | 37 +++ .../{{cookiecutter.project_name}}/urls.py | 6 +- .../{{cookiecutter.project_name}}/wsgi.py | 4 +- 66 files changed, 2015 insertions(+), 128 deletions(-) create mode 100644 hooks/post_gen_project.py create mode 100644 {{cookiecutter.directory_name}}/.claude/skills/ip/SKILL.md create mode 100644 {{cookiecutter.directory_name}}/.flake8 create mode 100644 {{cookiecutter.directory_name}}/CLAUDE.md create mode 100644 {{cookiecutter.directory_name}}/Makefile create mode 100644 {{cookiecutter.directory_name}}/apps/__init__.py create mode 100644 {{cookiecutter.directory_name}}/apps/core/admin.py create mode 100644 {{cookiecutter.directory_name}}/apps/core/managers/token.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/README.md create mode 100644 {{cookiecutter.directory_name}}/apps/tests/__init__.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/api/__init__.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/api/auth/__init__.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/api/auth/test_authentication.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/api/auth/test_logout.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/api/recovery/__init__.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/api/recovery/test_recovery_code.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/api/status/__init__.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/api/status/test_status.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/api/user/__init__.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/api/user/test_change_password.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/api/user/test_user.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/base.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/db/__init__.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/db/test_ordering.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/db/test_soft_delete.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/db/test_token.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/fixtures.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/services/__init__.py create mode 100644 {{cookiecutter.directory_name}}/apps/tests/services/test_email_notification.py create mode 100644 {{cookiecutter.directory_name}}/docs/.authors.yml create mode 100644 {{cookiecutter.directory_name}}/docs/index.md create mode 100644 {{cookiecutter.directory_name}}/docs/proposals/.template.md create mode 100644 {{cookiecutter.directory_name}}/docs/proposals/index.md create mode 100644 {{cookiecutter.directory_name}}/docs/proposals/posts/.gitkeep create mode 100644 {{cookiecutter.directory_name}}/mkdocs.yml create mode 100644 {{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fed553..9dace53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.6.0 : 2026-06-23 + +### Upgrades +- Django 6 and Python 3.14 +- Dependency bumps (django-filter, argon2-cffi, black 25) +- Standard PostgreSQL `PG*` environment variables (`PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`) + +### Security & bug fixes +- Fixed `BasicBackend` (base64 decode of credentials) +- Added authorization to `UserDetail` (self-or-permission) and corrected the `UserChecker` logic +- `default_permissions` now include `view`/`change` so `core.view_user` exists +- `order_by` allow-list (`Model.ORDERING_FIELDS`) to prevent ordering injection +- Token expiry computed at creation via `TokenManager` +- Added CSRF middleware (API views are `csrf_exempt`) and production security settings (HSTS, secure cookies, SSL redirect, `ALLOWED_HOSTS` from env) +- `SECRET_KEY` now required (raises `ImproperlyConfigured` if missing) + +### Deployment fixes +- `supervisor.conf` uses the project's WSGI module (was hard-coded) +- Aligned `supervisord` config path between Dockerfile and entrypoint +- Fixed `docker-compose.yml` database credentials, volumes and healthcheck + +### Documentation +- mkdocs-material documentation site with the proposal (IP) system and `.authors.yml` +- `/ip` skill for quick proposal capture + +### Testing +- Django `unittest` test suite covering API views, services and database behaviour (soft-delete, token expiry, managers, ordering) + ## 0.5.0 : 2024-14-10 - Remove request from exceptions diff --git a/README.md b/README.md index 1b1674c..d1c8c39 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,13 @@ Simple quickstart for [Django](https://www.djangoproject.com/)-based projects cr - Dependency management using [poetry](https://python-poetry.org/) - Multi-environment settings - E-mail testing using [django-imap-backend](https://github.com/Sibyx/django-imap-backend) in `development` environment -- CRON job management ### Bundled dependencies - [django_api_forms](https://github.com/Sibyx/django_api_forms): Request validation - [python-dotenv](https://github.com/theskumar/python-dotenv): `.env` handling -- [porcupine-python](https://github.com/zurek11/porcupine-python): Response serialisation +- [pydantic](https://github.com/pydantic/pydantic): Response serialisation - [django-imap-backend](https://github.com/Sibyx/django-imap-backend): Custom e-mail backend for simplified testing -- [django-celery-beat](https://github.com/celery/django-celery-beat): CRON jobs ## Usage @@ -40,13 +38,18 @@ cookiecutter gh:backbonesk/django-project-template ## Next steps 1. Check `pyproject.toml` and change the `authors` list -2. `cd {{ directory_name }}` -3. `python -m venv venv` -4. `poetry install && poetry update` -5. Remove stuff you don't need (template is feature rich on purpose, it's easier to delete than create) -6. Call `python manage.py makemigrations` and then `python manage.py migrate` -7. You are supposed to create superuser using `python manage.py createsuperuser` -8. When the project is set up, you can call `{baseurl}/status` to check if everything is up and running -9. Take a coffee and celebrate life, you saved a plenty of time! +2. `cd ` +3. `poetry install && poetry update` +4. Remove stuff you don't need (template is feature rich on purpose, it's easier to delete than create) +5. Copy `.env.example` to `.env` and fill it in — in particular set a `SECRET_KEY` + (generate one at ) and the `PG*` database variables. + **`SECRET_KEY` is required: every `manage.py` command fails without it.** +6. Create the PostgreSQL database matching `PGDATABASE` (e.g. `createdb `) +7. Call `python manage.py makemigrations && python manage.py migrate` +8. Create a superuser: `python manage.py createsuperuser` (prompts for email, name, surname, password) +9. Start the server with `make run` (or `python manage.py runserver 0.0.0.0:8000`), then call + `curl http://localhost:8000/api/v1/status` to check everything is up and running +10. (optional) Run the test suite with `make test` +11. Take a coffee and celebrate life, you saved a plenty of time! --- -Made with ❤️ and ☕️ BACKBONE s.r.o. (c) 2024 +Made with ❤️ and ☕️ BACKBONE s.r.o. (c) 2026 diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py new file mode 100644 index 0000000..f48f74a --- /dev/null +++ b/hooks/post_gen_project.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +""" +Cookiecutter post-generation hook. + +Runs in the generated project directory after rendering. It creates a ``.env`` +from ``.env.example`` with a freshly generated ``SECRET_KEY`` so the project is +runnable immediately (every ``manage.py`` command requires ``SECRET_KEY``). +""" +import secrets +from pathlib import Path + +# Avoid characters that are awkward in .env values: quotes, '#', '$', whitespace. +SECRET_KEY_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@%^&*(-_=+)" + + +def generate_secret_key(length: int = 50) -> str: + return "".join(secrets.choice(SECRET_KEY_CHARS) for _ in range(length)) + + +def main() -> None: + example = Path(".env.example") + env = Path(".env") + + if not example.exists() or env.exists(): + return + + content = example.read_text() + # Replace the bare ``SECRET_KEY=`` line with a generated value. + content = content.replace("SECRET_KEY=\n", f"SECRET_KEY={generate_secret_key()}\n", 1) + env.write_text(content) + + print("Created .env with a generated SECRET_KEY.") + print("Next: configure the PG* database variables in .env, then run `make migrations`.") + + +if __name__ == "__main__": + main() diff --git a/{{cookiecutter.directory_name}}/.claude/skills/ip/SKILL.md b/{{cookiecutter.directory_name}}/.claude/skills/ip/SKILL.md new file mode 100644 index 0000000..f13e900 --- /dev/null +++ b/{{cookiecutter.directory_name}}/.claude/skills/ip/SKILL.md @@ -0,0 +1,98 @@ +--- +name: ip +description: Use when quickly capturing an intellectual property idea - searches for related proposals, extends existing or creates new +user-invocable: true +argument-hint: "[NNN] " +--- + +# ip — Quick proposal capture + +Quickly save an Intellectual Property (IP) proposal to `docs/proposals/posts/` following the +project's proposal system (see `CLAUDE.md` for the full guidelines). + +## Argument Parsing + +Args format: `[NNN] ` + +- **`/ip 002 description...`** — NNN provided → go directly to that proposal number (extend if exists, create with that number if not) +- **`/ip description...`** — no number → search by keywords, then extend or create new + +Parse logic: +1. Split args on first space +2. If first token matches `^\d{3}$` → `target_number = first token`, `description = rest` +3. Otherwise → `target_number = nil`, `description = all args` + +## Paths + +All paths are relative to the **current working directory** (the project root). Do not hardcode absolute paths. + +- Proposals dir: `docs/proposals/posts/` +- Proposal file: `docs/proposals/posts/IP-{NNN}-{slug}.md` (one file per proposal) +- Template: `docs/proposals/.template.md` +- Index: `docs/proposals/index.md` + +## Author + +Two distinct author fields, do not confuse them: + +- **Changelog column** (`{author}`): derive from git at runtime; do not hardcode a username: + ```sh + git config user.name || git config user.email || echo "author" + ``` +- **Frontmatter `authors:` list**: must contain **keys defined in `docs/.authors.yml`** + (the mkdocs-material blog plugin validates this, and each entry requires an `avatar`). Default + to `author`. If the contributor is not yet listed, add an entry to `docs/.authors.yml` first, + then reference its key here. + +## Workflow + +### Step 1: Resolve Target + +**If `target_number` provided:** +- Run: `ls docs/proposals/posts/ | grep -iE "^IP-0*{target_number}-"` to find the matching file +- If found → go to **2A (Extend)** +- If not found → go to **2B (Create)** using `target_number` as the IP number + +**If no `target_number`:** +- Check description for explicit "create new" / "new proposal" intent → skip search, go directly to **2B (Create)** +- Otherwise: run `ls docs/proposals/posts/` and grep filenames + file content for keywords from description +- If matches found → pick the best match and go to **2A (Extend)** automatically (no confirmation needed) +- If no matches → go to **2B (Create)** + +### 2A. Extend Existing Proposal + +1. Read the matched `IP-{NNN}-{slug}.md` file +2. Locate and update relevant sections: + - Add to **Implementation Plan** (append new phase/steps as checkboxes) + - Add supporting details to **Problem Statement**, **Proposed Solution**, or other relevant sections +3. Update **Changelog**: `| {today} | {author} | [brief change description] |` +4. Update **Status** if appropriate +5. Use the Edit tool to modify the file +6. Update `docs/proposals/index.md` tracking table if status changed +7. Confirm: `✓ Extended IP-{NNN}: [title]` + +### 2B. Create New Proposal + +1. Determine IP number: + - If `target_number` provided → use it + - Otherwise: `ls docs/proposals/posts/ | grep -oiE '^IP-[0-9]+' | grep -oE '[0-9]+' | sort -n | tail -1` → increment by 1 (start at 1 if none) +2. Format as zero-padded 3-digit: e.g. `002` +3. Derive slug from description (lowercase, hyphens, max 40 chars) +4. Read template: `docs/proposals/.template.md` +5. Create `docs/proposals/posts/IP-{NNN}-{slug}.md` with: + - Updated frontmatter (`date: {today}`, `authors: [author]` — a key from `.authors.yml`, categories, tags) + - A `` excerpt separator after the intro (required by the blog plugin) + - Title: `# IP-{NNN}: [Full Title]` + - All template sections filled in + - **Review Questions section** (required for AI-created proposals) + - **Changelog**: `| {today} | {author} | Initial draft |` +6. Update `docs/proposals/index.md` tracking table (add row: IP number linking to the file, title, status, last updated) +7. Confirm: `✓ Created IP-{NNN}: [title]` + +## Key Rules + +- No time estimates in proposals +- AI-created proposals MUST include a Review Questions section +- Always update the Changelog with `YYYY-MM-DD`, author, change description +- Always update `docs/proposals/index.md` +- Derive the author from git config (see **Author** above) diff --git a/{{cookiecutter.directory_name}}/.env.example b/{{cookiecutter.directory_name}}/.env.example index ff7a6e8..d388dda 100644 --- a/{{cookiecutter.directory_name}}/.env.example +++ b/{{cookiecutter.directory_name}}/.env.example @@ -1,13 +1,21 @@ -DATABASE_HOST=localhost -DATABASE_PORT=5432 -DATABASE_NAME={{cookiecutter.project_name}} -DATABASE_USER=postgres -DATABASE_PASSWORD=admin +PGHOST=localhost +PGPORT=5432 +PGDATABASE={{cookiecutter.project_name}} +PGUSER=postgres +PGPASSWORD=admin + +REDIS_HOST=localhost + +LOG_LEVEL=INFO SENTRY_DSN='' INSTANCE_NAME={{cookiecutter.project_name}} +# Comma-separated hostnames served in production, e.g. api.example.com,www.example.com +ALLOWED_HOSTS='' + +# SECURITY WARNING: keep this secret and use a unique value per environment. # Generate using: https://djecrety.ir/ SECRET_KEY= @@ -15,4 +23,5 @@ EMAIL_IMAP_HOST='' EMAIL_IMAP_USER='' EMAIL_IMAP_PASSWORD='' EMAIL_IMAP_MAILBOX='' +EMAIL_IMAP_SSL='true' EMAIL_SENDER_NAME='' diff --git a/{{cookiecutter.directory_name}}/.flake8 b/{{cookiecutter.directory_name}}/.flake8 new file mode 100644 index 0000000..cc23ccd --- /dev/null +++ b/{{cookiecutter.directory_name}}/.flake8 @@ -0,0 +1,17 @@ +[flake8] +max-line-length = 119 +# E203 / W503 conflict with Black's formatting. +extend-ignore = E203, W503 +# Re-export packages legitimately import names they don't use locally. +per-file-ignores = + __init__.py:F401 +exclude = + .git, + __pycache__, + migrations, + venv, + .venv, + static, + media, + private, + conf diff --git a/{{cookiecutter.directory_name}}/CLAUDE.md b/{{cookiecutter.directory_name}}/CLAUDE.md new file mode 100644 index 0000000..cd8a17c --- /dev/null +++ b/{{cookiecutter.directory_name}}/CLAUDE.md @@ -0,0 +1,184 @@ +# CLAUDE.md + +## Project Structure + +Layered architecture under `apps/`: + +**`apps/api/`** — HTTP layer (request → response) +- `views/` — class-based views; secured endpoints extend `SecuredView` + (enforces `X-Apikey` + `X-Signature` + Bearer/Basic auth) +- `forms/` — request validation with `django_api_forms` (our `Form` subclass); + one form per operation, `Form.Create` / `Form.Update` nesting +- `filters/` — `django_filter` FilterSets for list endpoints +- `response.py`, `errors.py`, `encoders.py` — response objects + (`SingleResponse`, `PaginationResponse`), `ProblemDetailException`, JSON encoder +- `middleware/`, `decorators.py`, `urls.py` + +**`apps/core/`** — domain & business logic +- `models/` — soft-delete `BaseModel`, custom `User`, `Token`, `ApiKey`, `RecoveryCode` +- `managers/`, `querysets/` — soft delete, token expiry, etc. +- `serializers/` — **pydantic** response serializers (not DRF) +- `services/` — business logic / side effects (e.g. `NotificationEmailService`) +- `checkers/` — object-level permissions (`django-object-checker`, ABAC) +- `auth.py` — authentication backends (Bearer, Basic) + +**`apps/tests/`** — test suite mirroring the above (`api/`, `services/`, `db/`). + +**Request flow:** `urls → SecuredView (api key + signature + auth) → Form (validate) +→ service / model (logic) → pydantic serializer → SingleResponse / PaginationResponse`. + +## Code Style + +Format with **Black** (line length 119), lint with **flake8**, and type-check with **mypy**: + +```bash +make format # black . +make lint # flake8 apps/ +make typecheck # mypy apps/ +``` + +## Testing + +Tests use Django's built-in `unittest` framework and live under `apps/tests/` +(see `apps/tests/README.md`). They run against the `test` settings and the `PG*` +environment variables; the test database is created automatically as +`test_`. + +Always run the suite with the **`apps.tests`** label (or `make test`). Bare +`manage.py test` discovers nothing (the suite is nested under the `apps/` +package), and `manage.py test apps` re-imports the models as `core.*` and fails +with a "Conflicting models" error. + +```bash +# Once, if migrations have not been generated yet: +make migrations # == python manage.py makemigrations + +# Run the whole suite: +make test # == python manage.py test apps.tests --settings={{cookiecutter.project_name}}.settings.test + +# Run a subset (e.g. only the API view tests): +python manage.py test apps.tests.api --settings={{cookiecutter.project_name}}.settings.test + +# Run a single test, keeping the test DB between runs for faster iteration: +python manage.py test apps.tests.api.auth.test_authentication --keepdb \ + --settings={{cookiecutter.project_name}}.settings.test +``` + +When adding a feature, add tests under the matching `apps/tests/` subpackage +(`api/`, `services/`, `db/`). API tests should extend `apps.tests.base.Base`, +which signs every request (`X-Apikey` + `X-Signature`) and provides +`self.authenticate(user)` for Bearer-authenticated calls. + +## Proposal System + +All feature development follows the **proposal-first methodology**: + +1. Create proposal in `docs/proposals/posts/IP-XXX-feature-name.md` (use the `/ip` skill to scaffold it) +2. Follow template: Status, Problem Statement, Proposed Solution, Implementation Plan, Alternatives, Trade-offs +3. Proposals use mkdocs-material blog format with metadata (draft, date, authors, categories, tags) +4. Accepted proposals become implementation specifications + +**Proposal Template Structure** (see `docs/proposals/.template.md`): +- Status, Problem Statement, Proposed Solution, Implementation Plan (phases with checkboxes) +- Technical Details, Alternatives Considered, Trade-offs and Risks, Open Questions, Success Criteria +- Future Considerations, References, Review Questions, Changelog + +**Writing Proposals - Important Guidelines**: + +1. **No Time Estimates Required**: Do NOT include implementation time estimates or effort calculations. Focus on what needs to be done, not how long it will take. Users will decide scheduling. + +2. **Always Update Changelog**: When making ANY changes to a proposal (including initial creation), update the Changelog table at the bottom with: + - Date (YYYY-MM-DD format) + - Author (username) + - Brief description of changes + + Example: + ```markdown + ## Changelog + + | Date | Author | Changes | + |------|--------|---------| + | 2026-01-11 | author | Initial draft | + | 2026-01-12 | author | Refined implementation plan after review | + ``` + +3. **Implementation Plan**: Focus on concrete steps and phases, not timelines. Break work into actionable checkboxes without "this will take X hours" estimates. + +4. **Update Proposals Index**: When creating or changing a proposal's status, update `docs/proposals/index.md` to reflect the current state in the proposals tracking table. This index provides a quick overview of all proposals and their states. + +5. **Review Questions (AI-Created Proposals)**: When an AI agent creates a proposal draft, it MUST include a "Review Questions" section before the Changelog. This section identifies potential inconsistencies, edge cases, and unresolved technical decisions that require human input before implementation. + + **Workflow**: + - Step 1: Create complete proposal draft with all standard sections + - Step 2: Read back the created file to verify completeness + - Step 3: Review the proposal for inconsistencies, contradictions, edge cases, and open questions + - Step 4: Add "Review Questions" section with identified issues + - Step 5: Update Changelog noting "Added Review Questions section" + + **Review Questions Format** (see `docs/proposals/.template.md` for the canonical structure): + ```markdown + ## Review Questions + + **Status**: ⏳ Awaiting Answers + **Review Date**: YYYY-MM-DD + **Reviewer**: Claude AI + + The following questions must be answered before implementation: + + --- + + ### Q1: [Question Title] + + **Issue**: [Description of the problem/inconsistency with line numbers] + + **Context**: [Why this matters] + + **Question**: [The specific question to answer] + + **Options**: + - [ ] **A**: [Option description] (recommended if applicable) + - [ ] **B**: [Option description] + - [ ] **C**: [Option description] + + **Answer**: + ``` + [User fills this in] + ``` + + **Resolution**: + ``` + [User describes how proposal will be updated] + ``` + + --- + ``` + + **What to Review For**: + - Schema/migration inconsistencies (e.g., nullable vs required fields) + - Contradictions between sections (e.g., "optional" in schema, "required" in discussion) + - Edge cases not handled (e.g., empty collections, NULL values) + - Missing implementation details (e.g., "validation needed" without specifying logic) + - Ambiguous statements (e.g., "inherited or set directly" without HOW) + - Incomplete migration logic (e.g., data transformation missing steps) + - Unresolved dependencies (e.g., references to other proposals) + - Metadata inconsistencies (e.g., date conflicts) + + **Critical vs. Non-Critical Questions**: + - Mark as 🔴 **Critical** if it blocks implementation or causes data loss + - Mark as ⚠️ **Medium** if it affects user experience or performance + - Mark as ℹ️ **Low** if it's a documentation/clarity issue + +**Proposal Status Values**: +- **Draft**: Initial proposal, work in progress +- **Under Review**: Proposal complete, awaiting feedback/approval +- **Accepted**: Approved for implementation +- **Implemented**: Implementation complete +- **Rejected**: Proposal declined (with rationale in proposal) +- **Superseded**: Replaced by another proposal (reference new proposal) + +## Release Process + +1. Update `pyproject.toml` version (Semantic Versioning) +2. Update `CHANGELOG.md` with changes (change "TBD" to release date) +3. Get QA approval +4. Merge to `master` diff --git a/{{cookiecutter.directory_name}}/Dockerfile b/{{cookiecutter.directory_name}}/Dockerfile index 22ea133..e4cc4ea 100644 --- a/{{cookiecutter.directory_name}}/Dockerfile +++ b/{{cookiecutter.directory_name}}/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim as builder +FROM python:3.14-slim AS builder # System setup RUN apt update -y @@ -17,7 +17,7 @@ ENV PYTHONUNBUFFERED 1 RUN pip3 install --user gunicorn wheel --no-cache-dir RUN pip3 install --user -r requirements.txt --no-cache-dir -FROM python:3.12-slim +FROM python:3.14-slim RUN apt update -y RUN apt install -y supervisor curl postgresql-client argon2 tzdata cron diff --git a/{{cookiecutter.directory_name}}/Makefile b/{{cookiecutter.directory_name}}/Makefile new file mode 100644 index 0000000..ac6cd51 --- /dev/null +++ b/{{cookiecutter.directory_name}}/Makefile @@ -0,0 +1,31 @@ +SETTINGS = {{cookiecutter.project_name}}.settings.test +DEV_SETTINGS = {{cookiecutter.project_name}}.settings.development + +.PHONY: run test test-keepdb migrations migrate format lint typecheck docs + +run: ## Start the development server on port 8000 + python manage.py runserver 0.0.0.0:8000 --settings=$(DEV_SETTINGS) + +test: ## Run the test suite + python manage.py test apps.tests --settings=$(SETTINGS) + +test-keepdb: ## Run the test suite, keeping the test database between runs + python manage.py test apps.tests --keepdb --settings=$(SETTINGS) + +migrations: ## Generate migrations (required once before the first test run) + python manage.py makemigrations + +migrate: ## Apply migrations to the development database + python manage.py migrate --settings=$(DEV_SETTINGS) + +format: ## Format code with Black + black . + +lint: ## Lint with flake8 + flake8 apps/ + +typecheck: ## Type-check with mypy + mypy apps/ + +docs: ## Serve the documentation site locally + mkdocs serve \ No newline at end of file diff --git a/{{cookiecutter.directory_name}}/apps/__init__.py b/{{cookiecutter.directory_name}}/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.directory_name}}/apps/api/encoders.py b/{{cookiecutter.directory_name}}/apps/api/encoders.py index 61bb42a..c839b1f 100644 --- a/{{cookiecutter.directory_name}}/apps/api/encoders.py +++ b/{{cookiecutter.directory_name}}/apps/api/encoders.py @@ -11,27 +11,19 @@ class ApiJSONEncoder(DjangoJSONEncoder): - def __init__(self, **kwargs): - self._serializer = None - self._request = None - - if 'serializer' in kwargs: - self._serializer = kwargs.get('serializer') - del kwargs['serializer'] - if 'request' in kwargs: - self._request = kwargs.get('request') - del kwargs['request'] - - super().__init__(**kwargs) + """ + JSON encoder for the few raw ``dict`` payloads we serialise directly (e.g. the status + endpoint). Model instances are serialised through pydantic serializers in + ``apps.api.response`` and must not be passed here. + """ def default(self, o): if isinstance(o, decimal.Decimal): return float(o) if isinstance(o, models.Model): - if self._serializer: - return self._serializer(o, request=self._request if self._request else None).dict() - else: - raise RuntimeError(_('Serializer non specified.')) + raise RuntimeError( + _('Model instances must be serialised through a pydantic serializer, not ApiJSONEncoder.') + ) if isinstance(o, UUID): return str(o) if isinstance(o, Page): diff --git a/{{cookiecutter.directory_name}}/apps/api/filters/user.py b/{{cookiecutter.directory_name}}/apps/api/filters/user.py index 262e7d8..56e4df1 100644 --- a/{{cookiecutter.directory_name}}/apps/api/filters/user.py +++ b/{{cookiecutter.directory_name}}/apps/api/filters/user.py @@ -13,7 +13,7 @@ class UserFilter(django_filters.FilterSet): class Meta: model = User - fields = [] + fields: list[str] = [] @staticmethod def filter_query(qs, name, value): diff --git a/{{cookiecutter.directory_name}}/apps/api/response.py b/{{cookiecutter.directory_name}}/apps/api/response.py index f0a1784..b153032 100644 --- a/{{cookiecutter.directory_name}}/apps/api/response.py +++ b/{{cookiecutter.directory_name}}/apps/api/response.py @@ -37,21 +37,37 @@ class Ordering: columns: List[str] @classmethod - def create_from_request(cls, request, aliases: dict = None) -> 'Ordering': + def create_from_request(cls, request, aliases: dict = None, allowed=None) -> 'Ordering': + """ + Build an ``Ordering`` from the ``order_by`` query parameter. + + ``aliases`` maps a public column name to the underlying ORM field. ``allowed`` is an + iterable of ORM fields clients are permitted to sort by; when provided, any other column + is rejected with HTTP 400 to prevent ``order_by`` injection / information disclosure. + """ columns = [] aliases = aliases or {} + allowed = set(allowed) if allowed is not None else None for column in request.GET.getlist('order_by', ['created_at']): - column_name = column[1:] if column.startswith('-') else column - if column_name in aliases.keys(): - columns.append( - f'-{aliases[column_name]}' if column.startswith('-') else aliases[column_name] + descending = column.startswith('-') + column_name = column[1:] if descending else column + resolved = aliases.get(column_name, column_name) + + if allowed is not None and resolved not in allowed: + raise ProblemDetailException( + title=_('Invalid ordering column'), + status=HTTPStatus.BAD_REQUEST, + detail_type=DetailType.OUT_OF_RANGE, + detail=_('Cannot order by "%(column)s". Allowed columns: %(allowed)s') % { + 'column': column_name, + 'allowed': ', '.join(sorted(allowed)), + }, ) - else: - columns.append(column) - result = Ordering(columns) - return result + columns.append(f'-{resolved}' if descending else resolved) + + return Ordering(columns) def __str__(self): return ','.join(self.columns) @@ -130,8 +146,10 @@ class PaginationResponse(GeneralResponse): def __init__(self, request, qs, serializer: Type[Serializer], ordering: Ordering = None, **kwargs): kwargs.setdefault('content_type', 'application/json') - # Ordering - ordering = ordering if ordering else Ordering.create_from_request(request) + # Ordering (restricted to the model's whitelisted sortable columns) + if not ordering: + allowed = getattr(qs.model, 'ORDERING_FIELDS', None) + ordering = Ordering.create_from_request(request, allowed=allowed) qs = qs.order_by(*ordering.columns) # Pagination diff --git a/{{cookiecutter.directory_name}}/apps/api/urls.py b/{{cookiecutter.directory_name}}/apps/api/urls.py index c08e270..9e7d0d7 100644 --- a/{{cookiecutter.directory_name}}/apps/api/urls.py +++ b/{{cookiecutter.directory_name}}/apps/api/urls.py @@ -7,9 +7,10 @@ path('token', token.TokenManagement.as_view(), name='token'), # User - path('users', user.UserManagement.as_view()), - path('users/', user.UserDetail.as_view()), - path('users/me', user.UserMe.as_view()), + path('users', user.UserManagement.as_view(), name='user-management'), + path('users/me', user.UserMe.as_view(), name='user-me'), + path('users/', user.UserDetail.as_view(), name='user-detail'), + path('users//password', user.ChangePasswordDetail.as_view(), name='change-password'), # Recovery Code path('recovery_code', recovery_code.RecoveryCodeManagement.as_view(), name='recovery-code'), diff --git a/{{cookiecutter.directory_name}}/apps/api/views/base.py b/{{cookiecutter.directory_name}}/apps/api/views/base.py index af30c5d..eee2fd8 100644 --- a/{{cookiecutter.directory_name}}/apps/api/views/base.py +++ b/{{cookiecutter.directory_name}}/apps/api/views/base.py @@ -9,8 +9,10 @@ from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.models import AnonymousUser from django.http import HttpRequest +from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ from django.views import View +from django.views.decorators.csrf import csrf_exempt from sentry_sdk import set_tag from apps.api.errors import ProblemDetailException, DetailType @@ -19,9 +21,9 @@ class SecuredView(View): - EXEMPT_AUTH = [] - EXEMPT_API_KEY = [] - REQUIRE_SUPERUSER = [] + EXEMPT_AUTH: list[str] = [] + EXEMPT_API_KEY: list[str] = [] + REQUIRE_SUPERUSER: list[str] = [] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -107,12 +109,14 @@ def _check_signature(request: HttpRequest, api_key: ApiKey): 'received': signature, 'expected': signature_check, 'message': message, + 'path': request.path, }, detail_type=DetailType.INVALID_SIGNATURE ) return None + @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): if request.method not in self.EXEMPT_API_KEY: self._check_api_key(request) diff --git a/{{cookiecutter.directory_name}}/apps/api/views/user.py b/{{cookiecutter.directory_name}}/apps/api/views/user.py index cef738e..1d23985 100644 --- a/{{cookiecutter.directory_name}}/apps/api/views/user.py +++ b/{{cookiecutter.directory_name}}/apps/api/views/user.py @@ -57,16 +57,20 @@ def get(self, request): class UserDetail(SecuredView): @staticmethod - def _get_user(request, user_id: UUID) -> User: + def _get_user(request, user_id: UUID, perm: str) -> User: try: user = User.objects.get(pk=user_id) except User.DoesNotExist as e: raise ProblemDetailException(_('User not found.'), status=HTTPStatus.NOT_FOUND, previous=e) + # A user may always act on their own record; otherwise the matching model permission is required. + if request.user != user and not request.user.has_perm(perm): + raise ProblemDetailException(_('Permission denied.'), status=HTTPStatus.FORBIDDEN) + return user def get(self, request, user_id: UUID): - user = self._get_user(request, user_id) + user = self._get_user(request, user_id, 'core.view_user') return SingleResponse(request, data=user, serializer=UserSerializer.Detail) @@ -77,7 +81,7 @@ def put(self, request, user_id: UUID): if not form.is_valid(): raise ValidationException(form) - user = self._get_user(request, user_id) + user = self._get_user(request, user_id, 'core.change_user') if User.objects.filter(email=form.cleaned_data['email']).exclude(pk=user.id).exists(): raise ProblemDetailException( @@ -91,7 +95,7 @@ def put(self, request, user_id: UUID): @transaction.atomic def delete(self, request, user_id: UUID): - user = self._get_user(request, user_id) + user = self._get_user(request, user_id, 'core.delete_user') user.is_active = False user.delete() diff --git a/{{cookiecutter.directory_name}}/apps/core/admin.py b/{{cookiecutter.directory_name}}/apps/core/admin.py new file mode 100644 index 0000000..932b459 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/core/admin.py @@ -0,0 +1,99 @@ +""" +Django admin registration. + +The custom ``User`` extends ``AbstractBaseUser`` (not ``AbstractUser``), so it +needs its own creation/change forms — the built-in auth forms are coupled to the +``username`` field. +""" +from django import forms +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.forms import ReadOnlyPasswordHashField +from django.utils.translation import gettext_lazy as _ + +from apps.core.models import ApiKey, RecoveryCode, Token, User + + +class UserCreationForm(forms.ModelForm): + password1 = forms.CharField(label=_('Password'), widget=forms.PasswordInput) + password2 = forms.CharField(label=_('Password confirmation'), widget=forms.PasswordInput) + + class Meta: + model = User + fields = ('email', 'name', 'surname') + + def clean_password2(self): + password1 = self.cleaned_data.get('password1') + password2 = self.cleaned_data.get('password2') + if password1 and password2 and password1 != password2: + raise forms.ValidationError(_("Passwords don't match")) + return password2 + + def save(self, commit=True): + user = super().save(commit=False) + user.set_password(self.cleaned_data['password1']) + if commit: + user.save() + return user + + +class UserChangeForm(forms.ModelForm): + password = ReadOnlyPasswordHashField( + label=_('Password'), + help_text=_('Raw passwords are not stored. Use the "change password" form to set a new one.'), + ) + + class Meta: + model = User + fields = ( + 'email', 'name', 'surname', 'password', + 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions', + ) + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + form = UserChangeForm + add_form = UserCreationForm + + ordering = ('email',) + list_display = ('email', 'name', 'surname', 'is_active', 'is_staff', 'is_superuser') + list_filter = ('is_active', 'is_staff', 'is_superuser') + search_fields = ('email', 'name', 'surname') + readonly_fields = ('last_login', 'created_at', 'updated_at') + filter_horizontal = ('groups', 'user_permissions') + fieldsets = ( + (None, {'fields': ('email', 'password')}), + (_('Personal info'), {'fields': ('name', 'surname')}), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'), + }), + (_('Dates'), {'fields': ('last_login', 'created_at', 'updated_at')}), + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'name', 'surname', 'password1', 'password2'), + }), + ) + + +@admin.register(ApiKey) +class ApiKeyAdmin(admin.ModelAdmin): + list_display = ('id', 'name', 'platform', 'is_active', 'created_at') + list_filter = ('platform', 'is_active') + search_fields = ('name', 'id') + + +@admin.register(Token) +class TokenAdmin(admin.ModelAdmin): + list_display = ('id', 'user', 'expires_at', 'created_at') + search_fields = ('user__email',) + raw_id_fields = ('user',) + + +@admin.register(RecoveryCode) +class RecoveryCodeAdmin(admin.ModelAdmin): + list_display = ('id', 'user', 'created_at') + search_fields = ('user__email',) + raw_id_fields = ('user',) diff --git a/{{cookiecutter.directory_name}}/apps/core/auth.py b/{{cookiecutter.directory_name}}/apps/core/auth.py index 2c41c89..4a332cd 100644 --- a/{{cookiecutter.directory_name}}/apps/core/auth.py +++ b/{{cookiecutter.directory_name}}/apps/core/auth.py @@ -7,13 +7,13 @@ from django.utils.translation import gettext as _ from apps.core.models.token import Token -from apps.api.errors import ProblemDetailException, UnauthorizedException +from apps.api.errors import UnauthorizedException User = get_user_model() class BearerBackend(ModelBackend): - def authenticate(self, request, **kwargs) -> User: + def authenticate(self, request, **kwargs): try: token = Token.objects.get(pk=kwargs['bearer'], expires_at__gte=timezone.now()) except (Token.DoesNotExist, ValidationError): @@ -23,7 +23,7 @@ def authenticate(self, request, **kwargs) -> User: raise UnauthorizedException(_('Inactive user.'), status=HTTPStatus.FORBIDDEN) token.user.last_login = timezone.now() - token.user.save() + token.user.save(update_fields=['last_login']) request.token = token diff --git a/{{cookiecutter.directory_name}}/apps/core/checkers/user.py b/{{cookiecutter.directory_name}}/apps/core/checkers/user.py index 50178a8..ab73511 100644 --- a/{{cookiecutter.directory_name}}/apps/core/checkers/user.py +++ b/{{cookiecutter.directory_name}}/apps/core/checkers/user.py @@ -5,8 +5,6 @@ class UserChecker(AbacChecker): @staticmethod - def check_user_get(request_user: User, user: User, ): - if not request_user != user: - return False - - return True + def check_user_get(request_user: User, user: User) -> bool: + # A user may always act on their own record; superusers may act on anyone. + return request_user == user or request_user.is_superuser diff --git a/{{cookiecutter.directory_name}}/apps/core/managers/base.py b/{{cookiecutter.directory_name}}/apps/core/managers/base.py index ae204b1..e20cf67 100644 --- a/{{cookiecutter.directory_name}}/apps/core/managers/base.py +++ b/{{cookiecutter.directory_name}}/apps/core/managers/base.py @@ -1,4 +1,3 @@ -import random from django.db import models from apps.core.querysets.base import BaseQuerySet @@ -13,7 +12,7 @@ def hard_delete(self): return self.get_queryset().hard_delete() def random(self): - return random.choice(self.all()) + return self.get_queryset().order_by('?').first() def get_queryset(self): if self._alive_only: diff --git a/{{cookiecutter.directory_name}}/apps/core/managers/token.py b/{{cookiecutter.directory_name}}/apps/core/managers/token.py new file mode 100644 index 0000000..f743c03 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/core/managers/token.py @@ -0,0 +1,12 @@ +from django.conf import settings +from django.utils import timezone + +from apps.core.managers.base import BaseManager + + +class TokenManager(BaseManager): + def create(self, **kwargs): + # Compute the expiration at creation time so changes to settings.TOKEN_EXPIRATION + # take effect immediately without requiring a new migration. + kwargs.setdefault('expires_at', timezone.now() + settings.TOKEN_EXPIRATION) + return super().create(**kwargs) diff --git a/{{cookiecutter.directory_name}}/apps/core/managers/user.py b/{{cookiecutter.directory_name}}/apps/core/managers/user.py index 882ec30..2a16068 100644 --- a/{{cookiecutter.directory_name}}/apps/core/managers/user.py +++ b/{{cookiecutter.directory_name}}/apps/core/managers/user.py @@ -20,11 +20,13 @@ def _create_user(self, email, name, surname, password): def create_user(self, email, name, surname, password): user = self._create_user(email, name, surname, password) user.is_superuser = False + user.is_staff = False user.save(using=self._db) return user def create_superuser(self, email, name, surname, password): user = self._create_user(email, name, surname, password) user.is_superuser = True + user.is_staff = True user.save(using=self._db) return user diff --git a/{{cookiecutter.directory_name}}/apps/core/models/api_key.py b/{{cookiecutter.directory_name}}/apps/core/models/api_key.py index 85ef68a..5946c86 100644 --- a/{{cookiecutter.directory_name}}/apps/core/models/api_key.py +++ b/{{cookiecutter.directory_name}}/apps/core/models/api_key.py @@ -24,10 +24,11 @@ class DevicePlatform(models.TextChoices): null=False, choices=DevicePlatform.choices, default=DevicePlatform.DEBUG, + db_default=DevicePlatform.DEBUG, verbose_name=_('apikey_platform') ) secret = models.CharField(max_length=30, null=False, verbose_name=_('apikey_secret')) - is_active = models.BooleanField(default=False, verbose_name=_('apikey_is_active')) + is_active = models.BooleanField(default=False, db_default=False, verbose_name=_('apikey_is_active')) __all__ = [ diff --git a/{{cookiecutter.directory_name}}/apps/core/models/base.py b/{{cookiecutter.directory_name}}/apps/core/models/base.py index 3e28f6f..a5de784 100644 --- a/{{cookiecutter.directory_name}}/apps/core/models/base.py +++ b/{{cookiecutter.directory_name}}/apps/core/models/base.py @@ -14,6 +14,10 @@ class BaseModel(models.Model): class Meta: abstract = True + # Columns clients may sort by via ``?order_by=`` (see apps.api.response.Ordering). + # Override on subclasses to expose additional sortable fields. + ORDERING_FIELDS: tuple[str, ...] = ('created_at', 'updated_at') + id = models.UUIDField(primary_key=True, default=uuid.uuid4) created_at = models.DateTimeField(db_default=Now()) updated_at = models.DateTimeField(auto_now=True) diff --git a/{{cookiecutter.directory_name}}/apps/core/models/token.py b/{{cookiecutter.directory_name}}/apps/core/models/token.py index 29d3277..3146c7c 100644 --- a/{{cookiecutter.directory_name}}/apps/core/models/token.py +++ b/{{cookiecutter.directory_name}}/apps/core/models/token.py @@ -1,8 +1,7 @@ -from django.conf import settings from django.db import models -from django.db.models.functions import Now, TruncDay from django.utils.translation import gettext_lazy as _ +from apps.core.managers.token import TokenManager from apps.core.models.user import User from apps.core.models.base import BaseModel @@ -14,10 +13,10 @@ class Meta: default_permissions = () user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tokens', verbose_name=_('token_user')) - expires_at = models.DateTimeField( - db_default=TruncDay(Now() + settings.TOKEN_EXPIRATION, output_field=models.DateTimeField()), - verbose_name=_('token_expires_at') - ) + expires_at = models.DateTimeField(verbose_name=_('token_expires_at')) + + objects = TokenManager() + all_objects = TokenManager(alive_only=False) __all__ = [ diff --git a/{{cookiecutter.directory_name}}/apps/core/models/user.py b/{{cookiecutter.directory_name}}/apps/core/models/user.py index 85ed3aa..12682f7 100644 --- a/{{cookiecutter.directory_name}}/apps/core/models/user.py +++ b/{{cookiecutter.directory_name}}/apps/core/models/user.py @@ -12,13 +12,18 @@ class User(BaseModel, AbstractBaseUser, PermissionsMixin): class Meta: app_label = 'core' db_table = 'users' - default_permissions = ('add', 'delete') + default_permissions = ('add', 'change', 'delete', 'view') + + # Whitelist of columns clients may sort by via ``?order_by=`` (see apps.api.response.Ordering) + ORDERING_FIELDS = ('created_at', 'updated_at', 'email', 'name', 'surname', 'last_login') # Basic info email = models.EmailField(null=False, unique=True, verbose_name=_('user_email')) name = models.CharField(null=False, max_length=30, verbose_name=_('user_name')) surname = models.CharField(null=False, max_length=150, verbose_name=_('user_surname')) - is_active = models.BooleanField(null=False, default=True, verbose_name=_('user_is_active')) + is_active = models.BooleanField(null=False, default=True, db_default=True, verbose_name=_('user_is_active')) + # Grants access to the Django admin (see apps/core/admin.py). + is_staff = models.BooleanField(null=False, default=False, db_default=False, verbose_name=_('user_is_staff')) objects = UserManager() all_objects = UserManager(alive_only=False) diff --git a/{{cookiecutter.directory_name}}/apps/core/services/email_notification.py b/{{cookiecutter.directory_name}}/apps/core/services/email_notification.py index 02937e0..fc513be 100644 --- a/{{cookiecutter.directory_name}}/apps/core/services/email_notification.py +++ b/{{cookiecutter.directory_name}}/apps/core/services/email_notification.py @@ -25,7 +25,7 @@ def __init__( self._template = template self._reply = reply self._files = files - self._static_files = [] + self._static_files: list = [] @classmethod def create( diff --git a/{{cookiecutter.directory_name}}/apps/tests/README.md b/{{cookiecutter.directory_name}}/apps/tests/README.md new file mode 100644 index 0000000..b28e1f6 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/README.md @@ -0,0 +1,69 @@ +# {{cookiecutter.project_name}} Tests + +End-to-end and unit tests for the API, written with Django's built-in `unittest` +framework (`django.test.TestCase`). No external test dependencies. + +## Structure + +``` +apps/tests/ +├── base.py # Base test case: signed HTTP helpers + auth +├── fixtures.py # UserFixture factory helpers +├── api/ +│ ├── auth/ # token login / logout +│ ├── user/ # registration, list, detail, me, change password +│ ├── recovery/ # recovery code request + reset +│ └── status/ # public status endpoint +├── services/ # NotificationEmailService +└── db/ # soft-delete, token expiry, managers, ordering +``` + +## Running + +The suite uses the test settings and the `PG*` environment variables (see +`.env.example`). The test database is created automatically as `test_`. + +Always pass the **`apps.tests`** label (or use `make test`). Bare `manage.py test` +discovers nothing, and `manage.py test apps` fails with a "Conflicting models" error. + +```bash +# Once, if you haven't generated migrations yet: +make migrations # == python manage.py makemigrations + +# Run everything: +make test # == python manage.py test apps.tests --settings={{cookiecutter.project_name}}.settings.test + +# A subset / single test, keep the DB between runs while iterating: +python manage.py test apps.tests.api.auth --keepdb --settings={{cookiecutter.project_name}}.settings.test +``` + +## Authentication in tests + +Every endpoint extends `SecuredView`, which requires an `X-Apikey` header plus a +matching `X-Signature` (HMAC-SHA256 of `":"`). `Base` provisions a +test API key and signs every request automatically, so tests just call +`self.get/post/put/patch/delete`. + +For endpoints that also need a logged-in user, create one and pass the Bearer +header from `self.authenticate(user)`: + +```python +from apps.tests.base import Base +from apps.tests.fixtures import UserFixture + + +class TestExample(Base): + def test_me(self): + user = UserFixture.create_user(email="someone@example.com") + response = self.get("/api/v1/users/me", headers=self.authenticate(user)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_dict["response"]["email"], user.email) +``` + +`response.content_dict` holds the parsed JSON body. + +## Notes + +- E-mails are captured in `django.core.mail.outbox` (locmem backend). +- Tests run with `DEBUG = False` (forced by the test runner), so the API-key + signature path is exercised exactly as in production. diff --git a/{{cookiecutter.directory_name}}/apps/tests/__init__.py b/{{cookiecutter.directory_name}}/apps/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/__init__.py b/{{cookiecutter.directory_name}}/apps/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/auth/__init__.py b/{{cookiecutter.directory_name}}/apps/tests/api/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/auth/test_authentication.py b/{{cookiecutter.directory_name}}/apps/tests/api/auth/test_authentication.py new file mode 100644 index 0000000..2493985 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/api/auth/test_authentication.py @@ -0,0 +1,63 @@ +""" +Tests for the token endpoint (POST /api/v1/token) — login / token creation. +""" +from http import HTTPStatus + +from django.urls import reverse + +from apps.core.models import Token +from apps.tests.base import Base +from apps.tests.fixtures import UserFixture + + +class TestAuthentication(Base): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls._token_url = reverse('token') + + def setUp(self): + super().setUp() + self.user = UserFixture.create_user(email='auth_test@example.com', password='SecurePassword123!') + + def test_successful_token_creation(self): + """Valid credentials return 201 with a token and persist it.""" + response = self.post(self._token_url, {'email': self.user.email, 'password': self.user.plain_password}) + + self.assertEqual(response.status_code, HTTPStatus.CREATED) + self.assertIn('token', response.content_dict['response']) + token_id = response.content_dict['response']['token'] + self.assertTrue(Token.objects.filter(pk=token_id, user=self.user).exists()) + + def test_fails_with_incorrect_password(self): + response = self.post(self._token_url, {'email': self.user.email, 'password': 'WrongPassword123!'}) + + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + self.assertFalse(Token.objects.filter(user=self.user).exists()) + + def test_fails_with_nonexistent_email(self): + response = self.post(self._token_url, {'email': 'nobody@example.com', 'password': 'SecurePassword123!'}) + + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + + def test_fails_when_user_inactive(self): + inactive = UserFixture.create_inactive_user() + response = self.post(self._token_url, {'email': inactive.email, 'password': inactive.plain_password}) + + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + + def test_fails_with_missing_fields(self): + response = self.post(self._token_url, {'email': self.user.email}) + + self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY) + + def test_fails_without_api_key(self): + """Even the login endpoint requires a valid X-Apikey header.""" + response = self.client.post( + self._token_url, + data='{}', + content_type='application/json', + headers={'Accept': 'application/json'}, + ) + + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/auth/test_logout.py b/{{cookiecutter.directory_name}}/apps/tests/api/auth/test_logout.py new file mode 100644 index 0000000..26b4eb9 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/api/auth/test_logout.py @@ -0,0 +1,44 @@ +""" +Tests for the token endpoint (DELETE /api/v1/token) — logout / token deletion. +""" +from http import HTTPStatus + +from django.urls import reverse + +from apps.core.models import Token +from apps.tests.base import Base +from apps.tests.fixtures import UserFixture + + +class TestLogout(Base): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls._token_url = reverse('token') + + def setUp(self): + super().setUp() + self.user = UserFixture.create_user(email='logout_test@example.com') + + def test_successful_logout(self): + """A valid Bearer token is deleted and the endpoint returns 204.""" + auth = self.authenticate(self.user) + token_id = auth['Authorization'].split(' ')[1] + + response = self.delete(self._token_url, headers=auth) + + self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT) + self.assertFalse(Token.objects.filter(pk=token_id).exists()) + + def test_logout_without_token(self): + response = self.delete(self._token_url) + + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + + def test_logout_with_invalid_token(self): + response = self.delete( + self._token_url, + headers={'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000'}, + ) + + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/recovery/__init__.py b/{{cookiecutter.directory_name}}/apps/tests/api/recovery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/recovery/test_recovery_code.py b/{{cookiecutter.directory_name}}/apps/tests/api/recovery/test_recovery_code.py new file mode 100644 index 0000000..f25edf4 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/api/recovery/test_recovery_code.py @@ -0,0 +1,63 @@ +""" +Tests for the recovery-code endpoints: + POST /api/v1/recovery_code (request a recovery code by e-mail) + POST /api/v1/recovery_code/ (set a new password using a recovery code) +""" +from http import HTTPStatus + +from django.core import mail +from django.urls import reverse + +from apps.core.models import RecoveryCode +from apps.tests.base import Base +from apps.tests.fixtures import UserFixture + + +class TestRecoveryCodeRequest(Base): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls._url = reverse('recovery-code') + + def test_request_for_existing_user_creates_code_and_emails(self): + user = UserFixture.create_user(email='recover@example.com') + response = self.post(self._url, {'email': user.email}) + + self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT) + self.assertTrue(RecoveryCode.objects.filter(user=user).exists()) + self.assertEqual(len(mail.outbox), 1) + + def test_request_for_unknown_user_is_silent(self): + """Unknown e-mails return the same 204 and send nothing (no enumeration).""" + response = self.post(self._url, {'email': 'ghost@example.com'}) + + self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT) + self.assertEqual(len(mail.outbox), 0) + + +class TestRecoveryCodeReset(Base): + def setUp(self): + super().setUp() + self.user = UserFixture.create_user(email='reset@example.com', is_active=False) + self.recovery_code = RecoveryCode.objects.create(user=self.user) + + def _url(self, recovery_code_id): + return reverse('recovery-code-id', kwargs={'recovery_code_id': recovery_code_id}) + + def test_reset_sets_password_and_activates(self): + response = self.post(self._url(self.recovery_code.pk), {'password': 'FreshPassword123!'}) + + self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT) + self.user.refresh_from_db() + self.assertTrue(self.user.check_password('FreshPassword123!')) + self.assertTrue(self.user.is_active) + # The used recovery code is consumed. + self.assertFalse(RecoveryCode.objects.filter(pk=self.recovery_code.pk).exists()) + + def test_reset_with_unknown_code_returns_404(self): + response = self.post( + self._url('00000000-0000-0000-0000-000000000000'), + {'password': 'FreshPassword123!'}, + ) + + self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/status/__init__.py b/{{cookiecutter.directory_name}}/apps/tests/api/status/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/status/test_status.py b/{{cookiecutter.directory_name}}/apps/tests/api/status/test_status.py new file mode 100644 index 0000000..300b443 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/api/status/test_status.py @@ -0,0 +1,23 @@ +""" +Tests for GET /api/v1/status — public health/status endpoint. + +StatusManagement is a plain view (not a SecuredView), so it needs neither an +API key nor authentication. +""" +from http import HTTPStatus + +from django.urls import reverse +from django.test import TestCase + + +class TestStatusEndpoint(TestCase): + def setUp(self): + self._url = reverse('status') + + def test_status_returns_expected_fields(self): + response = self.client.get(self._url) + + self.assertEqual(response.status_code, HTTPStatus.OK) + body = response.json() + for field in ('timestamp', 'instance', 'build', 'version'): + self.assertIn(field, body) diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/user/__init__.py b/{{cookiecutter.directory_name}}/apps/tests/api/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/user/test_change_password.py b/{{cookiecutter.directory_name}}/apps/tests/api/user/test_change_password.py new file mode 100644 index 0000000..5ae5568 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/api/user/test_change_password.py @@ -0,0 +1,48 @@ +""" +Tests for PATCH /api/v1/users//password — change password. +""" +from http import HTTPStatus + +from django.urls import reverse + +from apps.tests.base import Base +from apps.tests.fixtures import UserFixture + + +class TestChangePassword(Base): + def setUp(self): + super().setUp() + self.user = UserFixture.create_user(email='pwd@example.com', password='OldPassword123!') + + def _url(self, user_id): + return reverse('change-password', kwargs={'user_id': user_id}) + + def test_change_own_password(self): + response = self.patch( + self._url(self.user.pk), + {'old_password': 'OldPassword123!', 'new_password': 'BrandNewPass456!'}, + headers=self.authenticate(self.user), + ) + + self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT) + self.user.refresh_from_db() + self.assertTrue(self.user.check_password('BrandNewPass456!')) + + def test_wrong_old_password_rejected(self): + response = self.patch( + self._url(self.user.pk), + {'old_password': 'WrongOld123!', 'new_password': 'BrandNewPass456!'}, + headers=self.authenticate(self.user), + ) + + self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY) + + def test_cannot_change_another_users_password(self): + other = UserFixture.create_user(email='victim@example.com', password='VictimPass123!') + response = self.patch( + self._url(other.pk), + {'old_password': 'OldPassword123!', 'new_password': 'BrandNewPass456!'}, + headers=self.authenticate(self.user), + ) + + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/user/test_user.py b/{{cookiecutter.directory_name}}/apps/tests/api/user/test_user.py new file mode 100644 index 0000000..d02407c --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/api/user/test_user.py @@ -0,0 +1,139 @@ +""" +Tests for the user endpoints: + POST /api/v1/users (registration, anonymous) + GET /api/v1/users (list, authenticated) + GET /api/v1/users/me (current user) + GET /api/v1/users/ (detail) + PUT /api/v1/users/ (update) + DELETE /api/v1/users/ (soft delete) +""" +from http import HTTPStatus + +from django.core import mail +from django.urls import reverse + +from apps.core.models import User +from apps.tests.base import Base +from apps.tests.fixtures import UserFixture + + +class TestUserRegistration(Base): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls._url = reverse('user-management') + + def test_register_creates_user_and_sends_email(self): + response = self.post(self._url, {'email': 'new@example.com', 'name': 'New', 'surname': 'User'}) + + self.assertEqual(response.status_code, HTTPStatus.CREATED) + self.assertEqual(response.content_dict['response']['email'], 'new@example.com') + + user = User.objects.get(email='new@example.com') + # Registration leaves the account without a usable password (set via recovery code). + self.assertFalse(user.has_usable_password()) + self.assertEqual(len(mail.outbox), 1) + + def test_register_duplicate_email_conflicts(self): + UserFixture.create_user(email='dupe@example.com') + response = self.post(self._url, {'email': 'dupe@example.com', 'name': 'Dupe', 'surname': 'User'}) + + self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY) + + def test_register_missing_fields(self): + response = self.post(self._url, {'email': 'partial@example.com'}) + + self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY) + + +class TestUserList(Base): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls._url = reverse('user-management') + + def setUp(self): + super().setUp() + self.user = UserFixture.create_user(email='lister@example.com') + + def test_list_requires_authentication(self): + response = self.get(self._url) + + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + + def test_list_without_permission_returns_only_self(self): + UserFixture.create_user(email='other@example.com') + response = self.get(self._url, headers=self.authenticate(self.user)) + + self.assertEqual(response.status_code, HTTPStatus.OK) + emails = [item['email'] for item in response.content_dict['items']] + self.assertEqual(emails, [self.user.email]) + + +class TestUserMe(Base): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls._url = reverse('user-me') + + def test_me_returns_current_user(self): + user = UserFixture.create_user(email='me@example.com') + response = self.get(self._url, headers=self.authenticate(user)) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.content_dict['response']['email'], user.email) + + def test_me_requires_authentication(self): + response = self.get(self._url) + + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + + +class TestUserDetail(Base): + def setUp(self): + super().setUp() + self.user = UserFixture.create_user(email='owner@example.com') + + def _url(self, user_id): + return reverse('user-detail', kwargs={'user_id': user_id}) + + def test_get_own_detail(self): + response = self.get(self._url(self.user.pk), headers=self.authenticate(self.user)) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.content_dict['response']['email'], self.user.email) + + def test_get_other_user_forbidden(self): + other = UserFixture.create_user(email='stranger@example.com') + response = self.get(self._url(other.pk), headers=self.authenticate(self.user)) + + self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) + + def test_get_missing_user_returns_404(self): + response = self.get( + self._url('00000000-0000-0000-0000-000000000000'), + headers=self.authenticate(self.user), + ) + + self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) + + def test_update_own_detail(self): + response = self.put( + self._url(self.user.pk), + {'name': 'Renamed', 'surname': 'Person', 'email': self.user.email}, + headers=self.authenticate(self.user), + ) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.user.refresh_from_db() + self.assertEqual(self.user.name, 'Renamed') + + def test_delete_soft_deletes_and_deactivates(self): + response = self.delete(self._url(self.user.pk), headers=self.authenticate(self.user)) + + self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT) + # Soft-deleted rows disappear from the default manager but remain in all_objects. + self.assertFalse(User.objects.filter(pk=self.user.pk).exists()) + archived = User.all_objects.get(pk=self.user.pk) + self.assertFalse(archived.is_active) + self.assertIsNotNone(archived.deleted_at) diff --git a/{{cookiecutter.directory_name}}/apps/tests/base.py b/{{cookiecutter.directory_name}}/apps/tests/base.py new file mode 100644 index 0000000..e276856 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/base.py @@ -0,0 +1,105 @@ +""" +Base test class with HTTP helpers for API tests. + +Every API endpoint extends ``SecuredView`` and therefore requires: + +* a valid ``X-Apikey`` header, and +* a matching ``X-Signature`` HMAC-SHA256 of ``":"`` signed with the + API key secret (Django's test runner forces ``DEBUG = False``, so the signature + check is always active — just like production). + +``Base`` provisions a test API key once per class and signs every request. +Authenticated requests additionally need a Bearer token — use :meth:`authenticate`. +""" +import hashlib +import hmac +import json + +from django.test import TestCase + +from apps.core.models import ApiKey, Token + +API_KEY_SECRET = 'test-secret' + + +class Base(TestCase): + """Base test class with signed HTTP helper methods for API testing.""" + + _content_type = 'application/json' + + @classmethod + def setUpTestData(cls): + cls.api_key = ApiKey.objects.create( + name='test', + platform=ApiKey.DevicePlatform.WEB, + secret=API_KEY_SECRET, + is_active=True, + ) + + def setUp(self): + self.base_headers = { + 'Accept': 'application/json', + 'X-Apikey': str(self.api_key.pk), + } + + # -- helpers --------------------------------------------------------------- + + @staticmethod + def authenticate(user) -> dict: + """Create a Bearer token for ``user`` and return the auth header.""" + token = Token.objects.create(user=user) + return {'Authorization': f'Bearer {token.pk}'} + + @staticmethod + def _sign(body: str, url: str) -> str: + # The view signs over request.path (query string excluded). + path = url.split('?', 1)[0] + message = f'{body}:{path}' + return hmac.new(API_KEY_SECRET.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).hexdigest() + + def _headers(self, body: str, url: str, extra: dict | None) -> dict: + headers = {**self.base_headers, 'X-Signature': self._sign(body, url)} + if extra: + headers.update(extra) + return headers + + @staticmethod + def check_response(response): + """Attach ``response.content_dict`` with the parsed JSON body (if any).""" + if 'application/json' in response.get('Content-Type', '') and response.content: + try: + response.content_dict = json.loads(response.content.decode('utf-8')) + except json.JSONDecodeError: + response.content_dict = {} + else: + response.content_dict = {} + return response + + # -- verbs ----------------------------------------------------------------- + + def get(self, url, headers=None): + return self.check_response(self.client.get(url, headers=self._headers('', url, headers))) + + def post(self, url, data=None, headers=None): + body = json.dumps(data) if isinstance(data, dict) else (data or '') + response = self.client.post( + url, data=body, content_type=self._content_type, headers=self._headers(body, url, headers) + ) + return self.check_response(response) + + def put(self, url, data=None, headers=None): + body = json.dumps(data) if isinstance(data, dict) else (data or '') + response = self.client.put( + url, data=body, content_type=self._content_type, headers=self._headers(body, url, headers) + ) + return self.check_response(response) + + def patch(self, url, data=None, headers=None): + body = json.dumps(data) if isinstance(data, dict) else (data or '') + response = self.client.patch( + url, data=body, content_type=self._content_type, headers=self._headers(body, url, headers) + ) + return self.check_response(response) + + def delete(self, url, headers=None): + return self.check_response(self.client.delete(url, headers=self._headers('', url, headers))) diff --git a/{{cookiecutter.directory_name}}/apps/tests/db/__init__.py b/{{cookiecutter.directory_name}}/apps/tests/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.directory_name}}/apps/tests/db/test_ordering.py b/{{cookiecutter.directory_name}}/apps/tests/db/test_ordering.py new file mode 100644 index 0000000..ba7cc89 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/db/test_ordering.py @@ -0,0 +1,48 @@ +""" +Tests for the ordering allow-list (apps.api.response.Ordering) and its +enforcement through PaginationResponse on the user list endpoint. +""" +from http import HTTPStatus + +from django.test import SimpleTestCase, RequestFactory +from django.urls import reverse + +from apps.api.errors import ProblemDetailException +from apps.api.response import Ordering +from apps.tests.base import Base +from apps.tests.fixtures import UserFixture + + +class TestOrdering(SimpleTestCase): + def _request(self, query=''): + return RequestFactory().get(f'/{query}') + + def test_default_ordering(self): + ordering = Ordering.create_from_request(self._request(), allowed={'created_at'}) + + self.assertEqual(ordering.columns, ['created_at']) + + def test_descending_and_alias(self): + ordering = Ordering.create_from_request( + self._request('?order_by=-name'), + aliases={'name': 'surname'}, + allowed={'surname'}, + ) + + self.assertEqual(ordering.columns, ['-surname']) + + def test_disallowed_column_raises(self): + with self.assertRaises(ProblemDetailException) as ctx: + Ordering.create_from_request(self._request('?order_by=password'), allowed={'created_at'}) + + self.assertEqual(ctx.exception.status, HTTPStatus.BAD_REQUEST) + + +class TestOrderingEndpoint(Base): + def test_invalid_order_by_returns_400(self): + user = UserFixture.create_user(email='order@example.com') + url = reverse('user-management') + + response = self.get(f'{url}?order_by=password', headers=self.authenticate(user)) + + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) diff --git a/{{cookiecutter.directory_name}}/apps/tests/db/test_soft_delete.py b/{{cookiecutter.directory_name}}/apps/tests/db/test_soft_delete.py new file mode 100644 index 0000000..0da2ec8 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/db/test_soft_delete.py @@ -0,0 +1,46 @@ +""" +Tests for the soft-delete behaviour provided by BaseModel / BaseQuerySet / BaseManager. +""" +from django.test import TestCase + +from apps.core.models import User +from apps.tests.fixtures import UserFixture + + +class TestSoftDelete(TestCase): + def test_instance_delete_is_soft(self): + user = UserFixture.create_user(email='soft@example.com') + + user.delete() + + # Hidden from the default manager, still present via all_objects. + self.assertFalse(User.objects.filter(pk=user.pk).exists()) + archived = User.all_objects.get(pk=user.pk) + self.assertIsNotNone(archived.deleted_at) + + def test_hard_delete_removes_row(self): + user = UserFixture.create_user(email='hard@example.com') + + user.hard_delete() + + self.assertFalse(User.all_objects.filter(pk=user.pk).exists()) + + def test_queryset_delete_is_soft(self): + UserFixture.create_user(email='qs1@example.com') + UserFixture.create_user(email='qs2@example.com') + + User.objects.all().delete() + + self.assertEqual(User.objects.count(), 0) + self.assertEqual(User.all_objects.count(), 2) + + def test_alive_and_dead_querysets(self): + alive = UserFixture.create_user(email='alive@example.com') + dead = UserFixture.create_user(email='dead@example.com') + dead.delete() + + alive_pks = set(User.all_objects.all().alive().values_list('pk', flat=True)) + dead_pks = set(User.all_objects.all().dead().values_list('pk', flat=True)) + + self.assertEqual(alive_pks, {alive.pk}) + self.assertEqual(dead_pks, {dead.pk}) diff --git a/{{cookiecutter.directory_name}}/apps/tests/db/test_token.py b/{{cookiecutter.directory_name}}/apps/tests/db/test_token.py new file mode 100644 index 0000000..5eab681 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/db/test_token.py @@ -0,0 +1,32 @@ +""" +Tests for TokenManager.create — expiry is computed at creation time from +settings.TOKEN_EXPIRATION. +""" +from datetime import timedelta + +from django.conf import settings +from django.test import TestCase +from django.utils import timezone + +from apps.core.models import Token +from apps.tests.fixtures import UserFixture + + +class TestTokenManager(TestCase): + def test_create_sets_expiration_from_settings(self): + user = UserFixture.create_user(email='token@example.com') + + before = timezone.now() + token = Token.objects.create(user=user) + after = timezone.now() + + self.assertGreaterEqual(token.expires_at, before + settings.TOKEN_EXPIRATION - timedelta(seconds=5)) + self.assertLessEqual(token.expires_at, after + settings.TOKEN_EXPIRATION + timedelta(seconds=5)) + + def test_explicit_expiration_is_respected(self): + user = UserFixture.create_user(email='token2@example.com') + explicit = timezone.now() + timedelta(days=1) + + token = Token.objects.create(user=user, expires_at=explicit) + + self.assertEqual(token.expires_at, explicit) diff --git a/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py b/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py new file mode 100644 index 0000000..3d0a4f8 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py @@ -0,0 +1,30 @@ +""" +Tests for UserManager (create_user / create_superuser / get_by_natural_key). +""" +from django.test import TestCase + +from apps.core.models import User + + +class TestUserManager(TestCase): + def test_create_user(self): + user = User.objects.create_user( + email='manager@example.com', name='Man', surname='Ager', password='Secret123!' + ) + + self.assertFalse(user.is_superuser) + self.assertTrue(user.check_password('Secret123!')) + + def test_create_superuser(self): + user = User.objects.create_superuser( + email='root@example.com', name='Root', surname='Admin', password='Secret123!' + ) + + self.assertTrue(user.is_superuser) + + def test_get_by_natural_key_is_case_insensitive(self): + user = User.objects.create_user( + email='Mixed@Example.com', name='Mixed', surname='Case', password='Secret123!' + ) + + self.assertEqual(User.objects.get_by_natural_key('mixed@example.com'), user) diff --git a/{{cookiecutter.directory_name}}/apps/tests/fixtures.py b/{{cookiecutter.directory_name}}/apps/tests/fixtures.py new file mode 100644 index 0000000..a0aab0c --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/fixtures.py @@ -0,0 +1,44 @@ +""" +Test fixtures and factory helpers. + +Lightweight factories (no factory_boy dependency) that build valid objects for +this template's models. ``create_user`` stashes the plain password on the +returned instance as ``plain_password`` so login tests can reuse it. +""" +from apps.core.models import User + + +class UserFixture: + """Helper class to create test users with predefined data.""" + + @staticmethod + def create_user( + email='test@example.com', + password='SecurePassword123!', + name='Test', + surname='User', + is_active=True, + is_superuser=False, + **extra_fields, + ) -> User: + """Create and persist a ``User`` and remember the plain password.""" + user = User( + email=email, + name=name, + surname=surname, + is_active=is_active, + is_superuser=is_superuser, + **extra_fields, + ) + user.set_password(password) + user.save() + user.plain_password = password + return user + + @staticmethod + def create_superuser(email='admin@example.com', password='AdminPassword123!', **kwargs) -> User: + return UserFixture.create_user(email=email, password=password, is_superuser=True, **kwargs) + + @staticmethod + def create_inactive_user(email='inactive@example.com', password='InactivePassword123!', **kwargs) -> User: + return UserFixture.create_user(email=email, password=password, is_active=False, **kwargs) diff --git a/{{cookiecutter.directory_name}}/apps/tests/services/__init__.py b/{{cookiecutter.directory_name}}/apps/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.directory_name}}/apps/tests/services/test_email_notification.py b/{{cookiecutter.directory_name}}/apps/tests/services/test_email_notification.py new file mode 100644 index 0000000..d1cf2b5 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/services/test_email_notification.py @@ -0,0 +1,55 @@ +""" +Unit tests for NotificationEmailService. + +Uses the locmem e-mail backend (configured in settings.test), so sent messages +land in ``django.core.mail.outbox`` instead of being delivered. +""" +from django.core import mail +from django.test import TestCase + +from apps.core.services.email_notification import NotificationEmailService + + +class TestNotificationEmailService(TestCase): + def test_returns_false_when_no_recipients(self): + service = NotificationEmailService.create(recipients=[], content={'message': 'Hi'}, subject='Subject') + + self.assertFalse(service.send_email()) + self.assertEqual(len(mail.outbox), 0) + + def test_sends_to_single_recipient(self): + service = NotificationEmailService.create( + recipients='alice@example.com', + content={'message': 'Hello Alice'}, + subject='Greetings', + ) + + self.assertTrue(service.send_email()) + self.assertEqual(len(mail.outbox), 1) + message = mail.outbox[0] + self.assertEqual(message.to, ['alice@example.com']) + self.assertEqual(message.subject, 'Greetings') + self.assertEqual(message.body, 'Hello Alice') + + def test_multiple_recipients_use_bcc(self): + service = NotificationEmailService.create( + recipients=['primary@example.com', 'second@example.com', 'third@example.com'], + content={'message': 'Broadcast'}, + subject='News', + ) + + self.assertTrue(service.send_email()) + message = mail.outbox[0] + self.assertEqual(message.to, ['primary@example.com']) + self.assertEqual(message.bcc, ['second@example.com', 'third@example.com']) + + def test_reply_to_header_is_set(self): + service = NotificationEmailService.create( + recipients='alice@example.com', + content={'message': 'Hi'}, + subject='Subject', + reply='support@example.com', + ) + + self.assertTrue(service.send_email()) + self.assertEqual(mail.outbox[0].extra_headers.get('Reply-To'), 'support@example.com') diff --git a/{{cookiecutter.directory_name}}/conf/entrypoint.sh b/{{cookiecutter.directory_name}}/conf/entrypoint.sh index d83c6d4..34453b3 100644 --- a/{{cookiecutter.directory_name}}/conf/entrypoint.sh +++ b/{{cookiecutter.directory_name}}/conf/entrypoint.sh @@ -1,10 +1,10 @@ #!/bin/sh -until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -c '\q'; do +until psql -h "$PGHOST" -p "${PGPORT:-5432}" -U "$PGUSER" -d "$PGDATABASE" -c '\q'; do >&2 echo "Postgres is unavailable - sleeping" sleep 1 done python3 manage.py migrate -/usr/bin/supervisord -c /etc/supervisor/supervisor.conf +/usr/bin/supervisord -c /etc/supervisord.conf diff --git a/{{cookiecutter.directory_name}}/conf/supervisor.conf b/{{cookiecutter.directory_name}}/conf/supervisor.conf index 81da3b9..04fe658 100644 --- a/{{cookiecutter.directory_name}}/conf/supervisor.conf +++ b/{{cookiecutter.directory_name}}/conf/supervisor.conf @@ -3,7 +3,7 @@ nodaemon=true [program:gunicorn] directory=/usr/src/app -command=/usr/local/bin/gunicorn -b 0.0.0.0:8000 -w 4 veolia_api.wsgi --log-level=debug --log-file=/var/log/gunicorn.log --timeout 240 +command=/usr/local/bin/gunicorn -b 0.0.0.0:8000 -w 4 {{cookiecutter.project_name}}.wsgi --log-level=info --log-file=/var/log/gunicorn.log --timeout 240 autostart=true autorestart=true priority=900 diff --git a/{{cookiecutter.directory_name}}/docker-compose.yml b/{{cookiecutter.directory_name}}/docker-compose.yml index f5f3db5..3d29c18 100644 --- a/{{cookiecutter.directory_name}}/docker-compose.yml +++ b/{{cookiecutter.directory_name}}/docker-compose.yml @@ -1,36 +1,41 @@ -version: "3" - services: db: - image: postgres:14-alpine + image: postgres:18-alpine volumes: - ./data/db:/var/lib/postgresql/data environment: - POSTGRES_DB: DATABASE_NAME - POSTGRES_USER: DATABASE_USER - POSTGRES_PASSWORD: DATABASE_PASSWORD + PGDATABASE: ${PGDATABASE:-{{cookiecutter.project_name}}} + PGUSER: ${PGUSER:-postgres} + PGPASSWORD: ${PGPASSWORD:-postgres} ports: - "5432:5432" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${PGUSER:-postgres} -d ${PGDATABASE:-{{cookiecutter.project_name}}}" ] + interval: 5s + timeout: 5s + retries: 10 django: build: . volumes: - - media_storage:/usr/local/app/media - - private_storage:/usr/local/app/private + - media_storage:/usr/src/app/media + - private_storage:/usr/src/app/private - ./logs:/var/log environment: - DATABASE_HOST: db - DATABASE_NAME: {{cookiecutter.project_name}} - DATABASE_USER: postgres - DATABASE_PASSWORD: postgres - DJANGO_SETTINGS_MODULE: {{cookiecutter.project_name}}.settings.development + PGHOST: db + PGPORT: "5432" + PGUSER: ${PGUSER:-postgres} + PGPASSWORD: ${PGPASSWORD:-postgres} + PGDATABASE: ${PGDATABASE:-{{cookiecutter.project_name}}} + DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-{{cookiecutter.project_name}}.settings.development} REDIS_HOST: redis - SECRET_KEY: 'oqjwvmob^(qwlil^8ub8%a@o5@a!^x0j1*^*1m@y46k%(6+w' + # Development-only key. Generate a fresh one per environment and inject it via secrets in production. + SECRET_KEY: ${SECRET_KEY:-'oqjwvmob^(qwlil^8ub8%a@o5@a!^x0j1*^*1m@y46k%(6+w'} ports: - 8000:8000 depends_on: - db redis: - image: "redis:6-alpine" + image: "redis:7-alpine" command: redis-server ports: - "6379:6379" diff --git a/{{cookiecutter.directory_name}}/docs/.authors.yml b/{{cookiecutter.directory_name}}/docs/.authors.yml new file mode 100644 index 0000000..29a48ba --- /dev/null +++ b/{{cookiecutter.directory_name}}/docs/.authors.yml @@ -0,0 +1,9 @@ +# Authors referenced by proposal frontmatter (`authors:` keys) and the mkdocs-material blog plugin. +# Add an entry per contributor; the key is what you put in a proposal's `authors:` list. +# `avatar` is required by the blog plugin. +authors: + author: + name: BACKBONE + description: BACKBONE s.r.o. + avatar: https://www.gravatar.com/avatar/00000000000000000000000000000000?d=identicon&s=200 + url: mailto:office@backbone.sk diff --git a/{{cookiecutter.directory_name}}/docs/index.md b/{{cookiecutter.directory_name}}/docs/index.md new file mode 100644 index 0000000..d20ddff --- /dev/null +++ b/{{cookiecutter.directory_name}}/docs/index.md @@ -0,0 +1,16 @@ +# {{cookiecutter.directory_name}} + +{{cookiecutter.description}} + +This site is the project's documentation. Technical decisions, feature designs, and +architectural changes are captured as [Proposals](proposals/index.md) before implementation. + +## Building these docs + +```shell +poetry install --with docs +mkdocs serve # live preview at http://127.0.0.1:8000 +mkdocs build # render the static site into ./site +``` + +See `CLAUDE.md` for the proposal workflow, or use the `/ip` skill to scaffold a new proposal. diff --git a/{{cookiecutter.directory_name}}/docs/proposals/.template.md b/{{cookiecutter.directory_name}}/docs/proposals/.template.md new file mode 100644 index 0000000..142f363 --- /dev/null +++ b/{{cookiecutter.directory_name}}/docs/proposals/.template.md @@ -0,0 +1,237 @@ +--- +draft: true +date: YYYY-MM-DD +authors: + - author +categories: + - Architecture | Feature | Infrastructure | Integration +tags: + - tag1 + - tag2 +--- + +# IP-XXX: [Title] + +Brief description of what this proposal addresses (2-3 sentences). + + + +## Status + +**Status**: Draft | Under Review | Accepted | Implemented +**Last Updated**: YYYY-MM-DD +**Implementation**: Not started | In Progress | Complete + +## Problem Statement + +What problem are we trying to solve? What need or challenge does this address? + +- What is the current situation? +- What are the pain points? +- Who is affected? +- What are the consequences of not addressing this? + +## Proposed Solution + +High-level description of the proposed solution. + +### Overview + +Brief overview of the approach. + +### Key Components + +List and describe the main components or aspects of the solution: + +1. **Component 1**: Description +2. **Component 2**: Description +3. **Component 3**: Description + +### Architecture + +```mermaid +graph TD + A[Component A] --> B[Component B] + B --> C[Component C] +``` + +Include architectural diagrams, data models, or flow charts as needed. + +## Implementation Plan + +### Phase 1: [Phase Name] + +- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3 + +### Phase 2: [Phase Name] + +- [ ] Task 1 +- [ ] Task 2 + +### Prerequisites + +What needs to be in place before implementation: + +- Prerequisite 1 +- Prerequisite 2 + +## Technical Details + +### Technology Stack + +What technologies, frameworks, or tools will be used? + +- Technology 1: Why chosen +- Technology 2: Why chosen + +### Data Model Changes + +If applicable, describe database schema changes: + +```sql +-- Example SQL or use Mermaid ER diagram +``` + +### API Changes + +If applicable, describe API changes: + +``` +GET /api/v1/endpoint +POST /api/v1/endpoint +``` + +### Configuration + +Any new configuration needed: + +```yaml +# Example configuration +``` + +## Alternatives Considered + +What other approaches were considered and why were they not chosen? + +### Alternative 1: [Name] + +**Description**: Brief description +**Pros**: +- Pro 1 +- Pro 2 + +**Cons**: +- Con 1 +- Con 2 + +**Why not chosen**: Explanation + +### Alternative 2: [Name] + +Similar format as above. + +## Trade-offs and Risks + +### Trade-offs + +- **Trade-off 1**: Description and justification +- **Trade-off 2**: Description and justification + +### Risks + +| Risk | Impact | Mitigation | +|------|--------|-----------| +| Risk 1 | High/Medium/Low | How to mitigate | +| Risk 2 | High/Medium/Low | How to mitigate | + +## Open Questions + +Questions that need to be answered before or during implementation: + +1. Question 1? +2. Question 2? + +## Success Criteria + +How will we know this solution is successful? + +- [ ] Criterion 1 +- [ ] Criterion 2 +- [ ] Criterion 3 + +## Future Considerations + +What might we want to consider in the future? + +- Future enhancement 1 +- Future enhancement 2 + +## References + +- [Link 1](url) +- [Link 2](url) +- Related proposals or documentation + + +## Review Questions + +**Note**: This section is REQUIRED for AI-created proposals. Human-authored proposals may include it if needed. + +**Status**: ⏳ Awaiting Answers | ✅ Resolved +**Review Date**: YYYY-MM-DD +**Reviewer**: [Name or "Claude AI"] + +The following questions must be answered before implementation: + +--- + +### Q1: [Question Title] + +**Issue**: [Description of the problem/inconsistency with line numbers] + +**Context**: [Why this matters - impact on implementation, data integrity, etc.] + +**Question**: [The specific question to answer] + +**Options**: +- [ ] **A**: [Option description] (recommended/not recommended) +- [ ] **B**: [Option description] +- [ ] **C**: [Option description] + +**Answer**: +``` +[User fills this in with chosen option and reasoning] +``` + +**Resolution**: +``` +[User describes how proposal will be updated based on answer] +``` + +--- + +### Q2: [Next Question Title] + +[Follow same format as Q1] + +--- + +**Instructions for completing Review Questions**: + +1. For each question, check the box next to your chosen option +2. Fill in the "Answer" section with your reasoning +3. Fill in the "Resolution" section with specific changes to make +4. Update the proposal based on all resolutions. +5. Change Status to "✅ Resolved" when all questions answered. Remove the "Review Questions" after the document is accepted. +6. Add changelog entry: "Resolved review questions and updated proposal accordingly" + +--- + +## Changelog + +| Date | Author | Changes | +|------|--------|---------| +| YYYY-MM-DD | [author] | Initial draft | +| YYYY-MM-DD | [author] | [Description of changes] | diff --git a/{{cookiecutter.directory_name}}/docs/proposals/index.md b/{{cookiecutter.directory_name}}/docs/proposals/index.md new file mode 100644 index 0000000..e60df36 --- /dev/null +++ b/{{cookiecutter.directory_name}}/docs/proposals/index.md @@ -0,0 +1,52 @@ +# Proposals + +Welcome to the proposals archive. This is where all technical decisions, feature designs, and architectural +changes are documented before implementation. + +## Proposals Index + +**IMPORTANT**: When creating or updating a proposal's status, always update this table to reflect the current state. + +| ID | Title | Status | Last Updated | +|----------------------------|-------|--------|--------------| + + +**Status Key**: +- 📝 **Draft**: Initial proposal, work in progress +- 🔍 **Under Review**: Proposal complete, awaiting feedback/approval +- ✅ **Accepted**: Approved for implementation +- ✅ **Implemented**: Implementation complete +- ❌ **Rejected**: Proposal declined +- ⏭️ **Superseded**: Replaced by another proposal + +## What are Proposals? + +Proposals are detailed documents that outline: + +- **Problem**: What challenge or need are we addressing? +- **Solution**: Proposed approach to solve the problem +- **Implementation**: Technical details and plan +- **Alternatives**: Other approaches considered +- **Open Questions**: Unresolved issues or decisions needed + +## Proposal Lifecycle + +1. **Draft**: Initial proposal is written +2. **Under Review**: Team reviews and provides feedback +3. **Accepted**: Proposal is approved for implementation +4. **Implemented**: Solution is built according to proposal + +## Writing a Proposal + +To write a new proposal: + +1. Copy `proposals/.template.md` to `proposals/posts/IP-XXX-title.md` +2. Fill in all sections of the template +3. Add relevant tags and categories +4. **Update this index** with the new proposal entry +5. Submit for review + +**Important**: See `CLAUDE.md` for detailed proposal writing guidelines including: +- No time estimates required +- Always update proposal changelog +- Update this index when changing proposal status diff --git a/{{cookiecutter.directory_name}}/docs/proposals/posts/.gitkeep b/{{cookiecutter.directory_name}}/docs/proposals/posts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.directory_name}}/mkdocs.yml b/{{cookiecutter.directory_name}}/mkdocs.yml new file mode 100644 index 0000000..f0bbf83 --- /dev/null +++ b/{{cookiecutter.directory_name}}/mkdocs.yml @@ -0,0 +1,99 @@ +site_name: {{cookiecutter.directory_name}} +site_description: {{cookiecutter.description}} +site_author: BACKBONE s.r.o. + +theme: + name: material + features: + - navigation.instant + - navigation.tracking + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - toc.integrate + - search.suggest + - search.highlight + - content.tabs.link + - content.code.copy + - content.code.annotate + icon: + repo: fontawesome/brands/github + palette: + - scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + +nav: + - Home: index.md + - Proposals: + - proposals/index.md + +markdown_extensions: + - attr_list + - md_in_html + - def_list + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - admonition + - pymdownx.details + - tables + - toc: + permalink: true + permalink_title: Anchor link to this section + +plugins: + - search + - meta + - blog: + blog_dir: proposals + blog_toc: true + post_date_format: full + post_url_format: "{slug}" + post_slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower + post_excerpt: required + post_excerpt_separator: + archive: true + archive_name: All Proposals + archive_date_format: MMMM yyyy + archive_url_format: "archive/{date}" + categories: true + categories_name: Categories + categories_url_format: "category/{slug}" + categories_slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower + pagination: true + pagination_per_page: 10 + authors: true + authors_file: .authors.yml + +extra: + generator: false diff --git a/{{cookiecutter.directory_name}}/pyproject.toml b/{{cookiecutter.directory_name}}/pyproject.toml index d3b50b3..a59b5df 100644 --- a/{{cookiecutter.directory_name}}/pyproject.toml +++ b/{{cookiecutter.directory_name}}/pyproject.toml @@ -5,26 +5,45 @@ description = "{{cookiecutter.description}}" authors = ["BACKBONE "] [tool.poetry.dependencies] -python = "^3.11" -django = "^5" +python = "^3.14" +django = "^6.0" django_api_forms = "1.0.0rc11" django-imap-backend = "^0" django-object-checker = "^1.0.1" -django-filter = "^24.2" +django-filter = "^25.2" python-dotenv = "^1.0.0" -argon2-cffi = "^23.1.0" +argon2-cffi = "^25.1.0" psycopg = { version = "^3.1", extras = ["c"] } pydantic = "^2.5.3" sentry-sdk = "^2.0" redis = "^5.0.1" [tool.poetry.group.dev.dependencies] -black = "^24.0" +black = "^25.0" +flake8 = "^7.1" +mypy = "^1.13" fabric = "^3.2.2" +[tool.poetry.group.docs.dependencies] +mkdocs-material = "^9.6" + +[tool.mypy] +python_version = "3.14" +implicit_optional = true +ignore_missing_imports = true +check_untyped_defs = true +warn_redundant_casts = true +exclude = ['migrations/', '/tests/'] + +[[tool.mypy.overrides]] +# The response/error layer leans on dynamic pydantic generics and runtime-built +# serializer types that mypy can't statically follow. +module = ["apps.api.response", "apps.api.errors"] +disable_error_code = ["valid-type", "type-var", "attr-defined", "assignment", "return-value"] + [tool.black] line-length = 119 -target-version = ['py312'] +target-version = ['py314'] include = '\.pyi?$' exclude = ''' /( diff --git a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/asgi.py b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/asgi.py index eab838f..bd115ca 100644 --- a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/asgi.py +++ b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/asgi.py @@ -1,10 +1,10 @@ """ -ASGI config for updater_api project. +ASGI config for the {{cookiecutter.project_name}} project. It exposes the ASGI callable as a module-level variable named ``application``. For more information on this file, see -https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ +https://docs.djangoproject.com/en/stable/howto/deployment/asgi/ """ import os diff --git a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/base.py b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/base.py index a6b414b..9439548 100644 --- a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/base.py +++ b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/base.py @@ -1,13 +1,11 @@ """ -Django settings for updater_api project. - -Generated by 'django-admin startproject' using Django 3.1.1. +Django settings for the {{cookiecutter.project_name}} project. For more information on this file, see -https://docs.djangoproject.com/en/3.1/topics/settings/ +https://docs.djangoproject.com/en/stable/topics/settings/ For the full list of settings and their values, see -https://docs.djangoproject.com/en/3.1/ref/settings/ +https://docs.djangoproject.com/en/stable/ref/settings/ """ import os import datetime @@ -15,6 +13,7 @@ from pathlib import Path import sentry_sdk +from django.core.exceptions import ImproperlyConfigured from dotenv import load_dotenv from sentry_sdk.integrations.django import DjangoIntegration @@ -35,16 +34,17 @@ else: BUILD = datetime.datetime.now().isoformat() -with open("pyproject.toml", "rb") as f: +with open(BASE_DIR / "pyproject.toml", "rb") as f: _META = tomllib.load(f) VERSION = _META["tool"]["poetry"]["version"] -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ +# See https://docs.djangoproject.com/en/stable/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.getenv("SECRET_KEY") +if not SECRET_KEY: + raise ImproperlyConfigured("The SECRET_KEY environment variable must be set.") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = False @@ -57,6 +57,7 @@ # Application definition INSTALLED_APPS = [ + 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -74,6 +75,7 @@ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'apps.api.middleware.exceptions.ExceptionMiddleware', @@ -102,16 +104,16 @@ # Database -# https://docs.djangoproject.com/en/3.1/ref/settings/#databases +# https://docs.djangoproject.com/en/stable/ref/settings/#databases DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', - 'HOST': os.getenv('DATABASE_HOST'), - 'PORT': os.getenv('DATABASE_PORT', 5432), - 'NAME': os.getenv('DATABASE_NAME'), - 'USER': os.getenv('DATABASE_USER'), - 'PASSWORD': os.getenv('DATABASE_PASSWORD', None) + 'HOST': os.getenv('PGHOST'), + 'PORT': os.getenv('PGPORT', 5432), + 'NAME': os.getenv('PGDATABASE'), + 'USER': os.getenv('PGUSER'), + 'PASSWORD': os.getenv('PGPASSWORD', None) } } @@ -122,7 +124,7 @@ # Password validation -# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators +# https://docs.djangoproject.com/en/stable/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { @@ -162,7 +164,7 @@ AUTH_USER_MODEL = "core.User" # Internationalization -# https://docs.djangoproject.com/en/3.1/topics/i18n/ +# https://docs.djangoproject.com/en/stable/topics/i18n/ LANGUAGE_CODE = 'en' @@ -176,7 +178,7 @@ # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/3.1/howto/static-files/ +# https://docs.djangoproject.com/en/stable/howto/static-files/ STATIC_URL = '/static/' @@ -189,6 +191,43 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024 * 50 # 50MB + +# Logging +LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {name} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + 'propagate': False, + }, + # Avoid SQL query spam when the root level is DEBUG. + 'django.db.backends': { + 'level': 'WARNING', + 'propagate': False, + }, + }, +} + # Sentry if os.getenv('SENTRY_DSN', False): def before_send(event, hint): @@ -204,7 +243,8 @@ def before_send(event, hint): sentry_sdk.init( integrations=[DjangoIntegration()], attach_stacktrace=True, - send_default_pii=True, + # Avoid shipping request bodies, cookies and user PII to Sentry by default. + # send_default_pii=False, before_send=before_send, ) diff --git a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/development.py b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/development.py index d7cc5ac..67f77c2 100644 --- a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/development.py +++ b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/development.py @@ -15,6 +15,6 @@ 'USER': os.getenv('EMAIL_IMAP_USER'), 'PASSWORD': os.getenv('EMAIL_IMAP_PASSWORD'), 'MAILBOX': os.getenv('EMAIL_IMAP_MAILBOX'), - 'SSL': False + 'SSL': os.getenv('EMAIL_IMAP_SSL', 'true').lower() == 'true' } ] diff --git a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/production.py b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/production.py index c31aa04..4e29fe2 100644 --- a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/production.py +++ b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/production.py @@ -2,8 +2,25 @@ TIME_ZONE = 'Europe/Bratislava' -ALLOWED_HOSTS = [ - '' +# Comma-separated list of hostnames, e.g. ALLOWED_HOSTS="api.example.com,www.example.com" +ALLOWED_HOSTS = [host.strip() for host in os.getenv('ALLOWED_HOSTS', '').split(',') if host.strip()] + +CSRF_TRUSTED_ORIGINS = [ + origin.strip() for origin in os.getenv('CSRF_TRUSTED_ORIGINS', '').split(',') if origin.strip() ] +# HTTPS / proxy SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_SSL_REDIRECT = True + +# Secure cookies +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# HSTS (enable once you are sure every subdomain is served over HTTPS) +SECURE_HSTS_SECONDS = int(os.getenv('SECURE_HSTS_SECONDS', 60 * 60 * 24 * 30)) +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True + +# Misc hardening +SECURE_CONTENT_TYPE_NOSNIFF = True diff --git a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/test.py b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/test.py new file mode 100644 index 0000000..c56422d --- /dev/null +++ b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/test.py @@ -0,0 +1,37 @@ +""" +Test settings for {{cookiecutter.project_name}}. + +Run the suite with: + + python manage.py makemigrations # once, if you haven't generated migrations yet + python manage.py test --settings={{cookiecutter.project_name}}.settings.test +""" +import os + +from .base import * # noqa: F401,F403 + +# NOTE: Django's test runner forces DEBUG = False, so view tests authenticate with +# a real API key + HMAC X-Signature (see apps/tests/base.py), exercising the +# production security path. + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'HOST': os.getenv('PGHOST'), + 'PORT': os.getenv('PGPORT', 5432), + 'NAME': os.getenv('PGDATABASE'), + 'USER': os.getenv('PGUSER'), + 'PASSWORD': os.getenv('PGPASSWORD', None), + 'TEST': { + 'NAME': f"test_{os.getenv('PGDATABASE', 'app')}", + }, + } +} + +# Collect e-mails in django.core.mail.outbox instead of sending them. +EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' + +# Fast, deterministic password hashing for tests. +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] diff --git a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/urls.py b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/urls.py index b1d01f6..f64839c 100644 --- a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/urls.py +++ b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/urls.py @@ -1,7 +1,7 @@ -"""updater_api URL Configuration +"""{{cookiecutter.project_name}} URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/3.1/topics/http/urls/ + https://docs.djangoproject.com/en/stable/topics/http/urls/ Examples: Function views 1. Add an import: from my_app import views @@ -15,6 +15,7 @@ """ from django.conf import settings from django.conf.urls.static import static +from django.contrib import admin from django.urls import path, include from django.views.static import serve @@ -23,6 +24,7 @@ urlpatterns = [] urlpatterns += [ + path('admin/', admin.site.urls), path(r'api/v1/', include(api_urlpatterns)), ] diff --git a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/wsgi.py b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/wsgi.py index 1780b50..9347f2c 100644 --- a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/wsgi.py +++ b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/wsgi.py @@ -1,10 +1,10 @@ """ -WSGI config for updater_api project. +WSGI config for the {{cookiecutter.project_name}} project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see -https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ +https://docs.djangoproject.com/en/stable/howto/deployment/wsgi/ """ import os From cc1c0c26e9e23ebe73c3c76b66640eb54febc6a5 Mon Sep 17 00:00:00 2001 From: Erik Belak Date: Wed, 24 Jun 2026 16:40:50 +0200 Subject: [PATCH 2/7] Remove unused admin module and related configurations - Drop `django.contrib.admin` from installed apps, URLs, and dependencies. - Remove custom admin forms, models, and fieldsets from `apps.core.admin`. - Adjust project settings and templates to align with the updated configuration. - Update documentation and placeholders (`README.md`, `CLAUDE.md`, `.authors.yml`). - Revert Redis image to version 6 for compatibility. --- README.md | 2 +- {{cookiecutter.directory_name}}/.env.example | 7 ++ {{cookiecutter.directory_name}}/CLAUDE.md | 8 +- {{cookiecutter.directory_name}}/README.md | 2 +- .../apps/core/admin.py | 99 ------------------- .../apps/core/models/user.py | 1 - .../docker-compose.yml | 2 +- .../docs/.authors.yml | 2 +- {{cookiecutter.directory_name}}/mkdocs.yml | 2 +- .../settings/base.py | 13 ++- .../settings/production.py | 5 - .../{{cookiecutter.project_name}}/urls.py | 2 - 12 files changed, 29 insertions(+), 116 deletions(-) delete mode 100644 {{cookiecutter.directory_name}}/apps/core/admin.py diff --git a/README.md b/README.md index d1c8c39..5a2d504 100644 --- a/README.md +++ b/README.md @@ -52,4 +52,4 @@ cookiecutter gh:backbonesk/django-project-template 10. (optional) Run the test suite with `make test` 11. Take a coffee and celebrate life, you saved a plenty of time! --- -Made with ❤️ and ☕️ BACKBONE s.r.o. (c) 2026 +Made with ❤️ and ☕️ BACKBONE, s.r.o. (c) 2026 diff --git a/{{cookiecutter.directory_name}}/.env.example b/{{cookiecutter.directory_name}}/.env.example index d388dda..b2e7db0 100644 --- a/{{cookiecutter.directory_name}}/.env.example +++ b/{{cookiecutter.directory_name}}/.env.example @@ -25,3 +25,10 @@ EMAIL_IMAP_PASSWORD='' EMAIL_IMAP_MAILBOX='' EMAIL_IMAP_SSL='true' EMAIL_SENDER_NAME='' + +# SMTP (used by non-development environments) +EMAIL_HOST='' +EMAIL_PORT='' +EMAIL_HOST_USER='' +EMAIL_HOST_PASSWORD='' +EMAIL_USE_TLS='false' diff --git a/{{cookiecutter.directory_name}}/CLAUDE.md b/{{cookiecutter.directory_name}}/CLAUDE.md index cd8a17c..29dc01b 100644 --- a/{{cookiecutter.directory_name}}/CLAUDE.md +++ b/{{cookiecutter.directory_name}}/CLAUDE.md @@ -180,5 +180,9 @@ All feature development follows the **proposal-first methodology**: 1. Update `pyproject.toml` version (Semantic Versioning) 2. Update `CHANGELOG.md` with changes (change "TBD" to release date) -3. Get QA approval -4. Merge to `master` +3. Open a pull request against `develop` +4. Get QA approval and code review on the PR +5. A human merges the approved PR to `develop` + +> **Claude must never commit or push to `develop` or `master` directly.** Always +> work on a branch and open a pull request; merging is a human decision. diff --git a/{{cookiecutter.directory_name}}/README.md b/{{cookiecutter.directory_name}}/README.md index 806adbd..6aee863 100644 --- a/{{cookiecutter.directory_name}}/README.md +++ b/{{cookiecutter.directory_name}}/README.md @@ -126,4 +126,4 @@ Rules generated by [crontab.guru](https://crontab.guru/). | True | `example_job` | `30 * * * *` | Example CRON job | --- -Made with ❤️ and ☕️ BACKBONE s.r.o. (c) {% now 'utc', '%Y' %} +Made with ❤️ and ☕️ BACKBONE, s.r.o. (c) {% now 'utc', '%Y' %} diff --git a/{{cookiecutter.directory_name}}/apps/core/admin.py b/{{cookiecutter.directory_name}}/apps/core/admin.py deleted file mode 100644 index 932b459..0000000 --- a/{{cookiecutter.directory_name}}/apps/core/admin.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -Django admin registration. - -The custom ``User`` extends ``AbstractBaseUser`` (not ``AbstractUser``), so it -needs its own creation/change forms — the built-in auth forms are coupled to the -``username`` field. -""" -from django import forms -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from django.contrib.auth.forms import ReadOnlyPasswordHashField -from django.utils.translation import gettext_lazy as _ - -from apps.core.models import ApiKey, RecoveryCode, Token, User - - -class UserCreationForm(forms.ModelForm): - password1 = forms.CharField(label=_('Password'), widget=forms.PasswordInput) - password2 = forms.CharField(label=_('Password confirmation'), widget=forms.PasswordInput) - - class Meta: - model = User - fields = ('email', 'name', 'surname') - - def clean_password2(self): - password1 = self.cleaned_data.get('password1') - password2 = self.cleaned_data.get('password2') - if password1 and password2 and password1 != password2: - raise forms.ValidationError(_("Passwords don't match")) - return password2 - - def save(self, commit=True): - user = super().save(commit=False) - user.set_password(self.cleaned_data['password1']) - if commit: - user.save() - return user - - -class UserChangeForm(forms.ModelForm): - password = ReadOnlyPasswordHashField( - label=_('Password'), - help_text=_('Raw passwords are not stored. Use the "change password" form to set a new one.'), - ) - - class Meta: - model = User - fields = ( - 'email', 'name', 'surname', 'password', - 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions', - ) - - -@admin.register(User) -class UserAdmin(BaseUserAdmin): - form = UserChangeForm - add_form = UserCreationForm - - ordering = ('email',) - list_display = ('email', 'name', 'surname', 'is_active', 'is_staff', 'is_superuser') - list_filter = ('is_active', 'is_staff', 'is_superuser') - search_fields = ('email', 'name', 'surname') - readonly_fields = ('last_login', 'created_at', 'updated_at') - filter_horizontal = ('groups', 'user_permissions') - fieldsets = ( - (None, {'fields': ('email', 'password')}), - (_('Personal info'), {'fields': ('name', 'surname')}), - (_('Permissions'), { - 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'), - }), - (_('Dates'), {'fields': ('last_login', 'created_at', 'updated_at')}), - ) - add_fieldsets = ( - (None, { - 'classes': ('wide',), - 'fields': ('email', 'name', 'surname', 'password1', 'password2'), - }), - ) - - -@admin.register(ApiKey) -class ApiKeyAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'platform', 'is_active', 'created_at') - list_filter = ('platform', 'is_active') - search_fields = ('name', 'id') - - -@admin.register(Token) -class TokenAdmin(admin.ModelAdmin): - list_display = ('id', 'user', 'expires_at', 'created_at') - search_fields = ('user__email',) - raw_id_fields = ('user',) - - -@admin.register(RecoveryCode) -class RecoveryCodeAdmin(admin.ModelAdmin): - list_display = ('id', 'user', 'created_at') - search_fields = ('user__email',) - raw_id_fields = ('user',) diff --git a/{{cookiecutter.directory_name}}/apps/core/models/user.py b/{{cookiecutter.directory_name}}/apps/core/models/user.py index 12682f7..cbf886e 100644 --- a/{{cookiecutter.directory_name}}/apps/core/models/user.py +++ b/{{cookiecutter.directory_name}}/apps/core/models/user.py @@ -22,7 +22,6 @@ class Meta: name = models.CharField(null=False, max_length=30, verbose_name=_('user_name')) surname = models.CharField(null=False, max_length=150, verbose_name=_('user_surname')) is_active = models.BooleanField(null=False, default=True, db_default=True, verbose_name=_('user_is_active')) - # Grants access to the Django admin (see apps/core/admin.py). is_staff = models.BooleanField(null=False, default=False, db_default=False, verbose_name=_('user_is_staff')) objects = UserManager() diff --git a/{{cookiecutter.directory_name}}/docker-compose.yml b/{{cookiecutter.directory_name}}/docker-compose.yml index 3d29c18..7f29849 100644 --- a/{{cookiecutter.directory_name}}/docker-compose.yml +++ b/{{cookiecutter.directory_name}}/docker-compose.yml @@ -35,7 +35,7 @@ services: depends_on: - db redis: - image: "redis:7-alpine" + image: "redis:6-alpine" command: redis-server ports: - "6379:6379" diff --git a/{{cookiecutter.directory_name}}/docs/.authors.yml b/{{cookiecutter.directory_name}}/docs/.authors.yml index 29a48ba..5806b96 100644 --- a/{{cookiecutter.directory_name}}/docs/.authors.yml +++ b/{{cookiecutter.directory_name}}/docs/.authors.yml @@ -4,6 +4,6 @@ authors: author: name: BACKBONE - description: BACKBONE s.r.o. + description: BACKBONE, s.r.o. avatar: https://www.gravatar.com/avatar/00000000000000000000000000000000?d=identicon&s=200 url: mailto:office@backbone.sk diff --git a/{{cookiecutter.directory_name}}/mkdocs.yml b/{{cookiecutter.directory_name}}/mkdocs.yml index f0bbf83..1af45fa 100644 --- a/{{cookiecutter.directory_name}}/mkdocs.yml +++ b/{{cookiecutter.directory_name}}/mkdocs.yml @@ -1,6 +1,6 @@ site_name: {{cookiecutter.directory_name}} site_description: {{cookiecutter.description}} -site_author: BACKBONE s.r.o. +site_author: BACKBONE, s.r.o. theme: name: material diff --git a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/base.py b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/base.py index 9439548..a13bdcb 100644 --- a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/base.py +++ b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/base.py @@ -57,7 +57,6 @@ # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -255,9 +254,19 @@ def before_send(event, hint): # Notifications -EMAIL_SENDER_NAME = os.getenv('EMAIL_SENDER_NAME') +EMAIL_SENDER_NAME = os.getenv('EMAIL_SENDER_NAME', '{{cookiecutter.project_name}}') EMAIL_IMAP_USER = os.getenv('EMAIL_IMAP_USER') +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'false').lower() == 'true' +EMAIL_HOST = os.getenv('EMAIL_HOST') +EMAIL_PORT = os.getenv('EMAIL_PORT') +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') +EMAIL_SENDER = os.getenv('EMAIL_HOST_USER') +EMAIL_REPLY_TO = os.getenv('EMAIL_HOST_USER') +DEFAULT_FROM_EMAIL = f'{EMAIL_SENDER_NAME} <{os.getenv("EMAIL_HOST_USER")}>' + # Templates EMAIL_REGISTRATION_PATH = '_emails/registration.html' diff --git a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/production.py b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/production.py index 4e29fe2..a7376d4 100644 --- a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/production.py +++ b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/production.py @@ -17,10 +17,5 @@ SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True -# HSTS (enable once you are sure every subdomain is served over HTTPS) -SECURE_HSTS_SECONDS = int(os.getenv('SECURE_HSTS_SECONDS', 60 * 60 * 24 * 30)) -SECURE_HSTS_INCLUDE_SUBDOMAINS = True -SECURE_HSTS_PRELOAD = True - # Misc hardening SECURE_CONTENT_TYPE_NOSNIFF = True diff --git a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/urls.py b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/urls.py index f64839c..65ed215 100644 --- a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/urls.py +++ b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/urls.py @@ -15,7 +15,6 @@ """ from django.conf import settings from django.conf.urls.static import static -from django.contrib import admin from django.urls import path, include from django.views.static import serve @@ -24,7 +23,6 @@ urlpatterns = [] urlpatterns += [ - path('admin/', admin.site.urls), path(r'api/v1/', include(api_urlpatterns)), ] From fdbadf507ae984a9689b2ebdf97375f05595de0c Mon Sep 17 00:00:00 2001 From: Erik Belak Date: Wed, 24 Jun 2026 16:55:31 +0200 Subject: [PATCH 3/7] Replace Redis with Valkey and update related configurations - Swap Redis for Valkey (a BSD-licensed alternative) to avoid restrictive licensing. - Update `docker-compose.yml` with Valkey image and configurations. - Add `REDIS_DB` to `.env.example` for improved flexibility. --- {{cookiecutter.directory_name}}/.env.example | 1 + {{cookiecutter.directory_name}}/docker-compose.yml | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/{{cookiecutter.directory_name}}/.env.example b/{{cookiecutter.directory_name}}/.env.example index b2e7db0..857a279 100644 --- a/{{cookiecutter.directory_name}}/.env.example +++ b/{{cookiecutter.directory_name}}/.env.example @@ -5,6 +5,7 @@ PGUSER=postgres PGPASSWORD=admin REDIS_HOST=localhost +REDIS_DB=0 LOG_LEVEL=INFO diff --git a/{{cookiecutter.directory_name}}/docker-compose.yml b/{{cookiecutter.directory_name}}/docker-compose.yml index 7f29849..34aec0c 100644 --- a/{{cookiecutter.directory_name}}/docker-compose.yml +++ b/{{cookiecutter.directory_name}}/docker-compose.yml @@ -27,22 +27,22 @@ services: PGPASSWORD: ${PGPASSWORD:-postgres} PGDATABASE: ${PGDATABASE:-{{cookiecutter.project_name}}} DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-{{cookiecutter.project_name}}.settings.development} - REDIS_HOST: redis + REDIS_HOST: valkey # Development-only key. Generate a fresh one per environment and inject it via secrets in production. SECRET_KEY: ${SECRET_KEY:-'oqjwvmob^(qwlil^8ub8%a@o5@a!^x0j1*^*1m@y46k%(6+w'} ports: - 8000:8000 depends_on: - db - redis: - image: "redis:6-alpine" - command: redis-server + # Valkey: BSD-licensed, drop-in replacement for Redis (same protocol/commands). + # Used instead of Redis to avoid Redis' SSPL/RSAL/AGPL licensing. + valkey: + image: "valkey/valkey:8-alpine" + command: valkey-server ports: - "6379:6379" volumes: - - ./data/redis:/data - environment: - REDIS_REPLICATION_MODE: master + - ./data/valkey:/data volumes: private_storage: media_storage: From c4a997979a5b89577565ac42136ed4e5ca29e100 Mon Sep 17 00:00:00 2001 From: Erik Belak Date: Tue, 30 Jun 2026 14:29:03 +0200 Subject: [PATCH 4/7] Update CHANGELOG and .env.example - Remove HSTS mention from CHANGELOG for accuracy. - Drop unused `REDIS_HOST` from `.env.example`. --- CHANGELOG.md | 2 +- {{cookiecutter.directory_name}}/.env.example | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dace53..78730df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ - `default_permissions` now include `view`/`change` so `core.view_user` exists - `order_by` allow-list (`Model.ORDERING_FIELDS`) to prevent ordering injection - Token expiry computed at creation via `TokenManager` -- Added CSRF middleware (API views are `csrf_exempt`) and production security settings (HSTS, secure cookies, SSL redirect, `ALLOWED_HOSTS` from env) +- Added CSRF middleware (API views are `csrf_exempt`) and production security settings (secure cookies, SSL redirect, `ALLOWED_HOSTS` from env) - `SECRET_KEY` now required (raises `ImproperlyConfigured` if missing) ### Deployment fixes diff --git a/{{cookiecutter.directory_name}}/.env.example b/{{cookiecutter.directory_name}}/.env.example index 857a279..7cda18e 100644 --- a/{{cookiecutter.directory_name}}/.env.example +++ b/{{cookiecutter.directory_name}}/.env.example @@ -4,7 +4,6 @@ PGDATABASE={{cookiecutter.project_name}} PGUSER=postgres PGPASSWORD=admin -REDIS_HOST=localhost REDIS_DB=0 LOG_LEVEL=INFO From 8175301892b053548d6198b808cfa15792cb1b45 Mon Sep 17 00:00:00 2001 From: Phillip Date: Sun, 5 Jul 2026 17:39:15 +0200 Subject: [PATCH 5/7] fix(auth): normalize user emails to lowercase --- .../apps/api/forms/recovery_code.py | 4 ++++ {{cookiecutter.directory_name}}/apps/api/forms/token.py | 4 ++++ {{cookiecutter.directory_name}}/apps/api/forms/user.py | 6 +++++- {{cookiecutter.directory_name}}/apps/core/managers/user.py | 4 ++-- .../apps/tests/db/test_user_manager.py | 4 ++-- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/{{cookiecutter.directory_name}}/apps/api/forms/recovery_code.py b/{{cookiecutter.directory_name}}/apps/api/forms/recovery_code.py index cfb632b..9e694ac 100644 --- a/{{cookiecutter.directory_name}}/apps/api/forms/recovery_code.py +++ b/{{cookiecutter.directory_name}}/apps/api/forms/recovery_code.py @@ -7,5 +7,9 @@ class RecoveryCodeForm: class Email(Form): email = fields.EmailField(label='Email') + def clean_email(self): + email = self.cleaned_data['email'].lower() + return email + class Password(Form): password = fields.CharField(validators=[validate_password], label='Password') diff --git a/{{cookiecutter.directory_name}}/apps/api/forms/token.py b/{{cookiecutter.directory_name}}/apps/api/forms/token.py index c7c63cd..b68182a 100644 --- a/{{cookiecutter.directory_name}}/apps/api/forms/token.py +++ b/{{cookiecutter.directory_name}}/apps/api/forms/token.py @@ -6,3 +6,7 @@ class TokenForm: class Basic(Form): email = fields.EmailField(required=True, label='Email') password = fields.CharField(required=True, max_length=128, label='Password') + + def clean_email(self): + email = self.cleaned_data['email'].lower() + return email diff --git a/{{cookiecutter.directory_name}}/apps/api/forms/user.py b/{{cookiecutter.directory_name}}/apps/api/forms/user.py index f4790c8..b444bc4 100644 --- a/{{cookiecutter.directory_name}}/apps/api/forms/user.py +++ b/{{cookiecutter.directory_name}}/apps/api/forms/user.py @@ -13,9 +13,13 @@ class Update(Form): surname = fields.CharField(required=True, max_length=150, label="Surname") email = fields.EmailField(required=True, label="Email") + def clean_email(self): + email = self.cleaned_data['email'].lower() + return email + class Create(Update): def clean_email(self): - email = self.cleaned_data['email'] + email = self.cleaned_data['email'].lower() if User.all_objects.filter(email=email).exists(): self.add_error( ('email',), diff --git a/{{cookiecutter.directory_name}}/apps/core/managers/user.py b/{{cookiecutter.directory_name}}/apps/core/managers/user.py index 2a16068..85c7311 100644 --- a/{{cookiecutter.directory_name}}/apps/core/managers/user.py +++ b/{{cookiecutter.directory_name}}/apps/core/managers/user.py @@ -8,12 +8,12 @@ class UserManager(BaseUserManager, BaseManager): def get_by_natural_key(self, username): conditions = { - f"{self.model.USERNAME_FIELD}__iexact": username + f"{self.model.USERNAME_FIELD}__exact": username } return self.get(**conditions) def _create_user(self, email, name, surname, password): - user = self.model(email=email, name=name, surname=surname) + user = self.model(email=email.lower(), name=name, surname=surname) user.set_password(password) return user diff --git a/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py b/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py index 3d0a4f8..a796685 100644 --- a/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py +++ b/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py @@ -22,9 +22,9 @@ def test_create_superuser(self): self.assertTrue(user.is_superuser) - def test_get_by_natural_key_is_case_insensitive(self): + def test_get_by_natural_key(self): user = User.objects.create_user( - email='Mixed@Example.com', name='Mixed', surname='Case', password='Secret123!' + email='mixed@Example.com', name='Mixed', surname='Case', password='Secret123!' ) self.assertEqual(User.objects.get_by_natural_key('mixed@example.com'), user) From 706e8fda9ad2ca1d3cb6ba3e3d093430b3f42b56 Mon Sep 17 00:00:00 2001 From: Erik Belak Date: Mon, 6 Jul 2026 09:42:24 +0200 Subject: [PATCH 6/7] Ensure case-insensitive, normalized email behavior across user workflow - Enforce email normalization (`strip` and `lower`) in models, forms, and managers. - Add database-level case-insensitive unique constraint for `email` field. - Update user-related tests (`manager`, `views`, `db`) to validate expected behavior. - Enhance email validation logic in registration and update forms. - Refactor and expand tests to verify email handling during CRUD operations. --- .../apps/api/forms/recovery_code.py | 2 +- .../apps/api/forms/token.py | 2 +- .../apps/api/forms/user.py | 6 ++-- .../apps/api/views/user.py | 2 +- .../apps/core/managers/user.py | 4 +-- .../apps/core/models/user.py | 9 +++++ .../apps/tests/api/user/test_user.py | 19 ++++++++++ .../apps/tests/db/test_user_manager.py | 7 ++++ .../apps/tests/db/test_user_model.py | 36 +++++++++++++++++++ 9 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 {{cookiecutter.directory_name}}/apps/tests/db/test_user_model.py diff --git a/{{cookiecutter.directory_name}}/apps/api/forms/recovery_code.py b/{{cookiecutter.directory_name}}/apps/api/forms/recovery_code.py index 9e694ac..2cc2a83 100644 --- a/{{cookiecutter.directory_name}}/apps/api/forms/recovery_code.py +++ b/{{cookiecutter.directory_name}}/apps/api/forms/recovery_code.py @@ -8,7 +8,7 @@ class Email(Form): email = fields.EmailField(label='Email') def clean_email(self): - email = self.cleaned_data['email'].lower() + email = self.cleaned_data['email'].strip().lower() return email class Password(Form): diff --git a/{{cookiecutter.directory_name}}/apps/api/forms/token.py b/{{cookiecutter.directory_name}}/apps/api/forms/token.py index b68182a..228a7c6 100644 --- a/{{cookiecutter.directory_name}}/apps/api/forms/token.py +++ b/{{cookiecutter.directory_name}}/apps/api/forms/token.py @@ -8,5 +8,5 @@ class Basic(Form): password = fields.CharField(required=True, max_length=128, label='Password') def clean_email(self): - email = self.cleaned_data['email'].lower() + email = self.cleaned_data['email'].strip().lower() return email diff --git a/{{cookiecutter.directory_name}}/apps/api/forms/user.py b/{{cookiecutter.directory_name}}/apps/api/forms/user.py index b444bc4..439e711 100644 --- a/{{cookiecutter.directory_name}}/apps/api/forms/user.py +++ b/{{cookiecutter.directory_name}}/apps/api/forms/user.py @@ -14,13 +14,13 @@ class Update(Form): email = fields.EmailField(required=True, label="Email") def clean_email(self): - email = self.cleaned_data['email'].lower() + email = self.cleaned_data['email'].strip().lower() return email class Create(Update): def clean_email(self): - email = self.cleaned_data['email'].lower() - if User.all_objects.filter(email=email).exists(): + email = self.cleaned_data['email'].strip().lower() + if User.all_objects.filter(email__iexact=email).exists(): self.add_error( ('email',), ValidationError(_('User with the same email already exists.'), code='email-already-exists') diff --git a/{{cookiecutter.directory_name}}/apps/api/views/user.py b/{{cookiecutter.directory_name}}/apps/api/views/user.py index 1d23985..1b645ef 100644 --- a/{{cookiecutter.directory_name}}/apps/api/views/user.py +++ b/{{cookiecutter.directory_name}}/apps/api/views/user.py @@ -83,7 +83,7 @@ def put(self, request, user_id: UUID): user = self._get_user(request, user_id, 'core.change_user') - if User.objects.filter(email=form.cleaned_data['email']).exclude(pk=user.id).exists(): + if User.objects.filter(email__iexact=form.cleaned_data['email']).exclude(pk=user.id).exists(): raise ProblemDetailException( _('User with the same email already exists.'), status=HTTPStatus.CONFLICT ) diff --git a/{{cookiecutter.directory_name}}/apps/core/managers/user.py b/{{cookiecutter.directory_name}}/apps/core/managers/user.py index 85c7311..ca54ff1 100644 --- a/{{cookiecutter.directory_name}}/apps/core/managers/user.py +++ b/{{cookiecutter.directory_name}}/apps/core/managers/user.py @@ -8,12 +8,12 @@ class UserManager(BaseUserManager, BaseManager): def get_by_natural_key(self, username): conditions = { - f"{self.model.USERNAME_FIELD}__exact": username + f"{self.model.USERNAME_FIELD}__iexact": username } return self.get(**conditions) def _create_user(self, email, name, surname, password): - user = self.model(email=email.lower(), name=name, surname=surname) + user = self.model(email=email.strip().lower(), name=name, surname=surname) user.set_password(password) return user diff --git a/{{cookiecutter.directory_name}}/apps/core/models/user.py b/{{cookiecutter.directory_name}}/apps/core/models/user.py index cbf886e..6c15eed 100644 --- a/{{cookiecutter.directory_name}}/apps/core/models/user.py +++ b/{{cookiecutter.directory_name}}/apps/core/models/user.py @@ -2,6 +2,7 @@ from django.contrib.auth.models import PermissionsMixin from django.utils.translation import gettext as _ from django.db import models +from django.db.models.functions import Lower from apps.core.managers.user import UserManager @@ -13,6 +14,9 @@ class Meta: app_label = 'core' db_table = 'users' default_permissions = ('add', 'change', 'delete', 'view') + constraints = [ + models.UniqueConstraint(Lower('email'), name='user_email_ci_unique'), + ] # Whitelist of columns clients may sort by via ``?order_by=`` (see apps.api.response.Ordering) ORDERING_FIELDS = ('created_at', 'updated_at', 'email', 'name', 'surname', 'last_login') @@ -31,6 +35,11 @@ class Meta: EMAIL_FIELD = 'email' REQUIRED_FIELDS = ['name', 'surname'] + def save(self, *args, **kwargs): + if self.email: + self.email = self.email.strip().lower() + super().save(*args, **kwargs) + def get_full_name(self) -> str: return f'{self.name} {self.surname}' diff --git a/{{cookiecutter.directory_name}}/apps/tests/api/user/test_user.py b/{{cookiecutter.directory_name}}/apps/tests/api/user/test_user.py index d02407c..f60f21e 100644 --- a/{{cookiecutter.directory_name}}/apps/tests/api/user/test_user.py +++ b/{{cookiecutter.directory_name}}/apps/tests/api/user/test_user.py @@ -46,6 +46,25 @@ def test_register_missing_fields(self): self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY) +class TestUserEmailCaseInsensitive(Base): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls._url = reverse('user-management') + + def test_register_rejects_case_variant_of_existing_email(self): + UserFixture.create_user(email='taken@example.com') + response = self.post(self._url, {'email': 'Taken@Example.com', 'name': 'New', 'surname': 'User'}) + + self.assertEqual(response.status_code, HTTPStatus.UNPROCESSABLE_ENTITY) + + def test_register_strips_and_lowercases_email(self): + response = self.post(self._url, {'email': ' Fresh@Example.COM ', 'name': 'Fr', 'surname': 'Esh'}) + + self.assertEqual(response.status_code, HTTPStatus.CREATED) + self.assertTrue(User.objects.filter(email='fresh@example.com').exists()) + + class TestUserList(Base): @classmethod def setUpTestData(cls): diff --git a/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py b/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py index a796685..4acca8e 100644 --- a/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py +++ b/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py @@ -28,3 +28,10 @@ def test_get_by_natural_key(self): ) self.assertEqual(User.objects.get_by_natural_key('mixed@example.com'), user) + + def test_create_user_strips_and_lowercases_email(self): + user = User.objects.create_user( + email=' Spaced@Example.COM ', name='Sp', surname='Aced', password='Secret123!' + ) + + self.assertEqual(user.email, 'spaced@example.com') diff --git a/{{cookiecutter.directory_name}}/apps/tests/db/test_user_model.py b/{{cookiecutter.directory_name}}/apps/tests/db/test_user_model.py new file mode 100644 index 0000000..7703732 --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/db/test_user_model.py @@ -0,0 +1,36 @@ +""" +Tests for User email normalization and case-insensitive uniqueness. +""" +from django.db import IntegrityError, transaction +from django.test import TestCase + +from apps.core.models import User + + +class TestUserEmailNormalization(TestCase): + def test_save_strips_and_lowercases_email(self): + user = User(email=' Mixed@Example.COM ', name='Mixed', surname='Case') + user.set_unusable_password() + user.save() + user.refresh_from_db() + + self.assertEqual(user.email, 'mixed@example.com') + + def test_case_variant_email_violates_unique_constraint(self): + User.objects.create_user( + email='erik@mail.com', name='Erik', surname='B', password='Secret123!' + ) + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + # Bypass manager/model normalization to prove the DB rejects it. + User.objects.bulk_create([ + User(email='Erik@mail.com', name='Erik', surname='B'), + ]) + + def test_get_by_natural_key_is_case_insensitive(self): + user = User.objects.create_user( + email='natural@example.com', name='Nat', surname='Key', password='Secret123!' + ) + + self.assertEqual(User.objects.get_by_natural_key('NATURAL@Example.com'), user) From 4877de773766596948eb9bfec488c82add04c9ae Mon Sep 17 00:00:00 2001 From: Erik Belak Date: Mon, 6 Jul 2026 10:13:13 +0200 Subject: [PATCH 7/7] Remove MkDocs support and refactor documentation workflow - Drop MkDocs-related dependencies, files, and configurations (`mkdocs-material`, `.authors.yml`, `mkdocs.yml`). - Transition to a markdown-only proposal system under `docs/proposals/`. - Update documentation and proposal guidelines in `CLAUDE.md` and `CHANGELOG`. - Refactor base model to leverage `RandomUUID` and improve field defaults. - Remove unused Makefile `docs` target and related build instructions. --- CHANGELOG.md | 2 +- .../.claude/skills/ip/SKILL.md | 17 ++-- {{cookiecutter.directory_name}}/CLAUDE.md | 2 +- {{cookiecutter.directory_name}}/Makefile | 7 +- .../apps/core/models/base.py | 7 +- .../docs/.authors.yml | 9 -- {{cookiecutter.directory_name}}/docs/index.md | 10 +- {{cookiecutter.directory_name}}/mkdocs.yml | 99 ------------------- .../pyproject.toml | 3 - 9 files changed, 15 insertions(+), 141 deletions(-) delete mode 100644 {{cookiecutter.directory_name}}/docs/.authors.yml delete mode 100644 {{cookiecutter.directory_name}}/mkdocs.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 78730df..3174e4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ - Fixed `docker-compose.yml` database credentials, volumes and healthcheck ### Documentation -- mkdocs-material documentation site with the proposal (IP) system and `.authors.yml` +- Markdown-based proposal (IP) system under `docs/proposals/` - `/ip` skill for quick proposal capture ### Testing diff --git a/{{cookiecutter.directory_name}}/.claude/skills/ip/SKILL.md b/{{cookiecutter.directory_name}}/.claude/skills/ip/SKILL.md index f13e900..962646c 100644 --- a/{{cookiecutter.directory_name}}/.claude/skills/ip/SKILL.md +++ b/{{cookiecutter.directory_name}}/.claude/skills/ip/SKILL.md @@ -33,16 +33,12 @@ All paths are relative to the **current working directory** (the project root). ## Author -Two distinct author fields, do not confuse them: +Derive the author from git at runtime; do not hardcode a username. This value is used for +both the frontmatter `author:` field and the Changelog column: -- **Changelog column** (`{author}`): derive from git at runtime; do not hardcode a username: - ```sh - git config user.name || git config user.email || echo "author" - ``` -- **Frontmatter `authors:` list**: must contain **keys defined in `docs/.authors.yml`** - (the mkdocs-material blog plugin validates this, and each entry requires an `avatar`). Default - to `author`. If the contributor is not yet listed, add an entry to `docs/.authors.yml` first, - then reference its key here. +```sh +git config user.name || git config user.email || echo "author" +``` ## Workflow @@ -80,8 +76,7 @@ Two distinct author fields, do not confuse them: 3. Derive slug from description (lowercase, hyphens, max 40 chars) 4. Read template: `docs/proposals/.template.md` 5. Create `docs/proposals/posts/IP-{NNN}-{slug}.md` with: - - Updated frontmatter (`date: {today}`, `authors: [author]` — a key from `.authors.yml`, categories, tags) - - A `` excerpt separator after the intro (required by the blog plugin) + - Updated frontmatter (`date: {today}`, `author: {author}`, tags) - Title: `# IP-{NNN}: [Full Title]` - All template sections filled in - **Review Questions section** (required for AI-created proposals) diff --git a/{{cookiecutter.directory_name}}/CLAUDE.md b/{{cookiecutter.directory_name}}/CLAUDE.md index 29dc01b..05115c3 100644 --- a/{{cookiecutter.directory_name}}/CLAUDE.md +++ b/{{cookiecutter.directory_name}}/CLAUDE.md @@ -75,7 +75,7 @@ All feature development follows the **proposal-first methodology**: 1. Create proposal in `docs/proposals/posts/IP-XXX-feature-name.md` (use the `/ip` skill to scaffold it) 2. Follow template: Status, Problem Statement, Proposed Solution, Implementation Plan, Alternatives, Trade-offs -3. Proposals use mkdocs-material blog format with metadata (draft, date, authors, categories, tags) +3. Proposals are markdown files with frontmatter metadata (date, author, tags) 4. Accepted proposals become implementation specifications **Proposal Template Structure** (see `docs/proposals/.template.md`): diff --git a/{{cookiecutter.directory_name}}/Makefile b/{{cookiecutter.directory_name}}/Makefile index ac6cd51..bf534b4 100644 --- a/{{cookiecutter.directory_name}}/Makefile +++ b/{{cookiecutter.directory_name}}/Makefile @@ -1,7 +1,7 @@ SETTINGS = {{cookiecutter.project_name}}.settings.test DEV_SETTINGS = {{cookiecutter.project_name}}.settings.development -.PHONY: run test test-keepdb migrations migrate format lint typecheck docs +.PHONY: run test test-keepdb migrations migrate format lint typecheck run: ## Start the development server on port 8000 python manage.py runserver 0.0.0.0:8000 --settings=$(DEV_SETTINGS) @@ -25,7 +25,4 @@ lint: ## Lint with flake8 flake8 apps/ typecheck: ## Type-check with mypy - mypy apps/ - -docs: ## Serve the documentation site locally - mkdocs serve \ No newline at end of file + mypy apps/ \ No newline at end of file diff --git a/{{cookiecutter.directory_name}}/apps/core/models/base.py b/{{cookiecutter.directory_name}}/apps/core/models/base.py index a5de784..e22cfa5 100644 --- a/{{cookiecutter.directory_name}}/apps/core/models/base.py +++ b/{{cookiecutter.directory_name}}/apps/core/models/base.py @@ -1,6 +1,7 @@ import uuid from django.conf import settings +from django.contrib.postgres.functions import RandomUUID from django.core.files.storage import FileSystemStorage from django.db import models from django.db.models.functions import Now @@ -18,9 +19,9 @@ class Meta: # Override on subclasses to expose additional sortable fields. ORDERING_FIELDS: tuple[str, ...] = ('created_at', 'updated_at') - id = models.UUIDField(primary_key=True, default=uuid.uuid4) - created_at = models.DateTimeField(db_default=Now()) - updated_at = models.DateTimeField(auto_now=True) + id = models.UUIDField(primary_key=True, default=uuid.uuid4, db_default=RandomUUID()) + created_at = models.DateTimeField(auto_now_add=True, db_default=Now()) + updated_at = models.DateTimeField(auto_now=True, db_default=Now()) deleted_at = models.DateTimeField(blank=True, null=True) objects = BaseManager() diff --git a/{{cookiecutter.directory_name}}/docs/.authors.yml b/{{cookiecutter.directory_name}}/docs/.authors.yml deleted file mode 100644 index 5806b96..0000000 --- a/{{cookiecutter.directory_name}}/docs/.authors.yml +++ /dev/null @@ -1,9 +0,0 @@ -# Authors referenced by proposal frontmatter (`authors:` keys) and the mkdocs-material blog plugin. -# Add an entry per contributor; the key is what you put in a proposal's `authors:` list. -# `avatar` is required by the blog plugin. -authors: - author: - name: BACKBONE - description: BACKBONE, s.r.o. - avatar: https://www.gravatar.com/avatar/00000000000000000000000000000000?d=identicon&s=200 - url: mailto:office@backbone.sk diff --git a/{{cookiecutter.directory_name}}/docs/index.md b/{{cookiecutter.directory_name}}/docs/index.md index d20ddff..bb807fc 100644 --- a/{{cookiecutter.directory_name}}/docs/index.md +++ b/{{cookiecutter.directory_name}}/docs/index.md @@ -2,15 +2,7 @@ {{cookiecutter.description}} -This site is the project's documentation. Technical decisions, feature designs, and +This directory holds the project's documentation. Technical decisions, feature designs, and architectural changes are captured as [Proposals](proposals/index.md) before implementation. -## Building these docs - -```shell -poetry install --with docs -mkdocs serve # live preview at http://127.0.0.1:8000 -mkdocs build # render the static site into ./site -``` - See `CLAUDE.md` for the proposal workflow, or use the `/ip` skill to scaffold a new proposal. diff --git a/{{cookiecutter.directory_name}}/mkdocs.yml b/{{cookiecutter.directory_name}}/mkdocs.yml deleted file mode 100644 index 1af45fa..0000000 --- a/{{cookiecutter.directory_name}}/mkdocs.yml +++ /dev/null @@ -1,99 +0,0 @@ -site_name: {{cookiecutter.directory_name}} -site_description: {{cookiecutter.description}} -site_author: BACKBONE, s.r.o. - -theme: - name: material - features: - - navigation.instant - - navigation.tracking - - navigation.tabs - - navigation.sections - - navigation.expand - - navigation.top - - toc.integrate - - search.suggest - - search.highlight - - content.tabs.link - - content.code.copy - - content.code.annotate - icon: - repo: fontawesome/brands/github - palette: - - scheme: default - primary: indigo - accent: indigo - toggle: - icon: material/brightness-7 - name: Switch to dark mode - - scheme: slate - primary: indigo - accent: indigo - toggle: - icon: material/brightness-4 - name: Switch to light mode - -nav: - - Home: index.md - - Proposals: - - proposals/index.md - -markdown_extensions: - - attr_list - - md_in_html - - def_list - - pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg - - pymdownx.highlight: - anchor_linenums: true - line_spans: __span - pygments_lang_class: true - - pymdownx.inlinehilite - - pymdownx.snippets - - pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format - - pymdownx.tabbed: - alternate_style: true - - pymdownx.tasklist: - custom_checkbox: true - - admonition - - pymdownx.details - - tables - - toc: - permalink: true - permalink_title: Anchor link to this section - -plugins: - - search - - meta - - blog: - blog_dir: proposals - blog_toc: true - post_date_format: full - post_url_format: "{slug}" - post_slugify: !!python/object/apply:pymdownx.slugs.slugify - kwds: - case: lower - post_excerpt: required - post_excerpt_separator: - archive: true - archive_name: All Proposals - archive_date_format: MMMM yyyy - archive_url_format: "archive/{date}" - categories: true - categories_name: Categories - categories_url_format: "category/{slug}" - categories_slugify: !!python/object/apply:pymdownx.slugs.slugify - kwds: - case: lower - pagination: true - pagination_per_page: 10 - authors: true - authors_file: .authors.yml - -extra: - generator: false diff --git a/{{cookiecutter.directory_name}}/pyproject.toml b/{{cookiecutter.directory_name}}/pyproject.toml index a59b5df..4ef05a1 100644 --- a/{{cookiecutter.directory_name}}/pyproject.toml +++ b/{{cookiecutter.directory_name}}/pyproject.toml @@ -24,9 +24,6 @@ flake8 = "^7.1" mypy = "^1.13" fabric = "^3.2.2" -[tool.poetry.group.docs.dependencies] -mkdocs-material = "^9.6" - [tool.mypy] python_version = "3.14" implicit_optional = true