diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fed553..3174e4f 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 (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 +- Markdown-based proposal (IP) system under `docs/proposals/` +- `/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..5a2d504 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..962646c --- /dev/null +++ b/{{cookiecutter.directory_name}}/.claude/skills/ip/SKILL.md @@ -0,0 +1,93 @@ +--- +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 + +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: + +```sh +git config user.name || git config user.email || echo "author" +``` + +## 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}`, `author: {author}`, tags) + - 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..7cda18e 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_DB=0 + +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,12 @@ EMAIL_IMAP_HOST='' EMAIL_IMAP_USER='' 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}}/.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..05115c3 --- /dev/null +++ b/{{cookiecutter.directory_name}}/CLAUDE.md @@ -0,0 +1,188 @@ +# 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 are markdown files with frontmatter metadata (date, author, 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. 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}}/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..bf534b4 --- /dev/null +++ b/{{cookiecutter.directory_name}}/Makefile @@ -0,0 +1,28 @@ +SETTINGS = {{cookiecutter.project_name}}.settings.test +DEV_SETTINGS = {{cookiecutter.project_name}}.settings.development + +.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) + +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/ \ No newline at end of file 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/__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/forms/recovery_code.py b/{{cookiecutter.directory_name}}/apps/api/forms/recovery_code.py index cfb632b..2cc2a83 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'].strip().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..228a7c6 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'].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 f4790c8..439e711 100644 --- a/{{cookiecutter.directory_name}}/apps/api/forms/user.py +++ b/{{cookiecutter.directory_name}}/apps/api/forms/user.py @@ -13,10 +13,14 @@ 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'].strip().lower() + return email + class Create(Update): def clean_email(self): - email = self.cleaned_data['email'] - 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/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..1b645ef 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,9 +81,9 @@ 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(): + 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 ) @@ -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/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..ca54ff1 100644 --- a/{{cookiecutter.directory_name}}/apps/core/managers/user.py +++ b/{{cookiecutter.directory_name}}/apps/core/managers/user.py @@ -13,18 +13,20 @@ def get_by_natural_key(self, 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.strip().lower(), name=name, surname=surname) user.set_password(password) return user 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..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 @@ -14,9 +15,13 @@ class BaseModel(models.Model): class Meta: abstract = True - id = models.UUIDField(primary_key=True, default=uuid.uuid4) - created_at = models.DateTimeField(db_default=Now()) - updated_at = models.DateTimeField(auto_now=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, 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}}/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..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 @@ -12,13 +13,20 @@ class User(BaseModel, AbstractBaseUser, PermissionsMixin): class Meta: app_label = 'core' db_table = 'users' - default_permissions = ('add', 'delete') + 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') # 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')) + is_staff = models.BooleanField(null=False, default=False, db_default=False, verbose_name=_('user_is_staff')) objects = UserManager() all_objects = UserManager(alive_only=False) @@ -27,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/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..f60f21e --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/api/user/test_user.py @@ -0,0 +1,158 @@ +""" +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 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): + 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..4acca8e --- /dev/null +++ b/{{cookiecutter.directory_name}}/apps/tests/db/test_user_manager.py @@ -0,0 +1,37 @@ +""" +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(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) + + 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) 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..34aec0c 100644 --- a/{{cookiecutter.directory_name}}/docker-compose.yml +++ b/{{cookiecutter.directory_name}}/docker-compose.yml @@ -1,43 +1,48 @@ -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 - REDIS_HOST: redis - SECRET_KEY: 'oqjwvmob^(qwlil^8ub8%a@o5@a!^x0j1*^*1m@y46k%(6+w' + 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: 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: diff --git a/{{cookiecutter.directory_name}}/docs/index.md b/{{cookiecutter.directory_name}}/docs/index.md new file mode 100644 index 0000000..bb807fc --- /dev/null +++ b/{{cookiecutter.directory_name}}/docs/index.md @@ -0,0 +1,8 @@ +# {{cookiecutter.directory_name}} + +{{cookiecutter.description}} + +This directory holds the project's documentation. Technical decisions, feature designs, and +architectural changes are captured as [Proposals](proposals/index.md) before implementation. + +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}}/pyproject.toml b/{{cookiecutter.directory_name}}/pyproject.toml index d3b50b3..4ef05a1 100644 --- a/{{cookiecutter.directory_name}}/pyproject.toml +++ b/{{cookiecutter.directory_name}}/pyproject.toml @@ -5,26 +5,42 @@ 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.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..a13bdcb 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 @@ -74,6 +74,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 +103,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 +123,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 +163,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 +177,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 +190,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 +242,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, ) @@ -215,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/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..a7376d4 100644 --- a/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/production.py +++ b/{{cookiecutter.directory_name}}/{{cookiecutter.project_name}}/settings/production.py @@ -2,8 +2,20 @@ 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 + +# 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..65ed215 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 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