From 64cdefe83f11b444733182071d6d4ea452073931 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Wed, 29 Apr 2026 11:14:03 +0200 Subject: [PATCH] Introduce the Attachment plugin --- django/README.md | 57 +- django/demo/README.md | 96 ++++ django/demo/conftest.py | 98 ++++ django/demo/documents/__init__.py | 0 django/demo/documents/apps.py | 7 + .../demo/documents/migrations/0001_initial.py | 133 +++++ django/demo/documents/migrations/__init__.py | 0 django/demo/documents/models.py | 88 +++ django/demo/documents/tests/__init__.py | 0 django/demo/documents/tests/test_documents.py | 192 +++++++ django/demo/documents/urls.py | 64 +++ .../demo/profiles/migrations/0001_initial.py | 125 +++++ django/demo/profiles/migrations/__init__.py | 0 django/demo/profiles/models.py | 64 +++ django/demo/profiles/tests/__init__.py | 0 django/demo/profiles/tests/test_avatar.py | 145 +++++ django/demo/profiles/urls.py | 49 ++ django/demo/settings.py | 24 + django/demo/urls.py | 6 + django/nside_wefa/attachments/README.md | 211 +++++++ django/nside_wefa/attachments/__init__.py | 8 + django/nside_wefa/attachments/admin.py | 41 ++ django/nside_wefa/attachments/apps.py | 11 + django/nside_wefa/attachments/checks.py | 106 ++++ .../attachments/migrations/__init__.py | 0 .../nside_wefa/attachments/models/__init__.py | 5 + .../attachments/models/attachment.py | 485 ++++++++++++++++ django/nside_wefa/attachments/serializers.py | 83 +++ django/nside_wefa/attachments/settings.py | 44 ++ django/nside_wefa/attachments/storage.py | 23 + .../nside_wefa/attachments/tests/__init__.py | 0 .../nside_wefa/attachments/tests/conftest.py | 88 +++ .../nside_wefa/attachments/tests/settings.py | 14 + .../attachments/tests/test_app/__init__.py | 3 + .../attachments/tests/test_app/apps.py | 8 + .../tests/test_app/migrations/0001_initial.py | 339 +++++++++++ .../tests/test_app/migrations/__init__.py | 0 .../attachments/tests/test_app/models.py | 58 ++ .../attachments/tests/test_app/urls.py | 66 +++ .../attachments/tests/test_checks.py | 54 ++ .../attachments/tests/test_models.py | 228 ++++++++ .../attachments/tests/test_storage.py | 38 ++ .../attachments/tests/test_validators.py | 119 ++++ .../attachments/tests/test_views/__init__.py | 0 .../tests/test_views/test_multi.py | 128 +++++ .../tests/test_views/test_replace_in_place.py | 81 +++ .../tests/test_views/test_singleton.py | 164 ++++++ django/nside_wefa/attachments/urls.py | 129 +++++ django/nside_wefa/attachments/validators.py | 178 ++++++ .../nside_wefa/attachments/views/__init__.py | 3 + .../attachments/views/attachment_views.py | 530 ++++++++++++++++++ django/pyproject.toml | 2 + django/pytest.ini | 2 +- django/uv.lock | 25 + 54 files changed, 4405 insertions(+), 17 deletions(-) create mode 100644 django/demo/README.md create mode 100644 django/demo/conftest.py create mode 100644 django/demo/documents/__init__.py create mode 100644 django/demo/documents/apps.py create mode 100644 django/demo/documents/migrations/0001_initial.py create mode 100644 django/demo/documents/migrations/__init__.py create mode 100644 django/demo/documents/models.py create mode 100644 django/demo/documents/tests/__init__.py create mode 100644 django/demo/documents/tests/test_documents.py create mode 100644 django/demo/documents/urls.py create mode 100644 django/demo/profiles/migrations/0001_initial.py create mode 100644 django/demo/profiles/migrations/__init__.py create mode 100644 django/demo/profiles/models.py create mode 100644 django/demo/profiles/tests/__init__.py create mode 100644 django/demo/profiles/tests/test_avatar.py create mode 100644 django/demo/profiles/urls.py create mode 100644 django/nside_wefa/attachments/README.md create mode 100644 django/nside_wefa/attachments/__init__.py create mode 100644 django/nside_wefa/attachments/admin.py create mode 100644 django/nside_wefa/attachments/apps.py create mode 100644 django/nside_wefa/attachments/checks.py create mode 100644 django/nside_wefa/attachments/migrations/__init__.py create mode 100644 django/nside_wefa/attachments/models/__init__.py create mode 100644 django/nside_wefa/attachments/models/attachment.py create mode 100644 django/nside_wefa/attachments/serializers.py create mode 100644 django/nside_wefa/attachments/settings.py create mode 100644 django/nside_wefa/attachments/storage.py create mode 100644 django/nside_wefa/attachments/tests/__init__.py create mode 100644 django/nside_wefa/attachments/tests/conftest.py create mode 100644 django/nside_wefa/attachments/tests/settings.py create mode 100644 django/nside_wefa/attachments/tests/test_app/__init__.py create mode 100644 django/nside_wefa/attachments/tests/test_app/apps.py create mode 100644 django/nside_wefa/attachments/tests/test_app/migrations/0001_initial.py create mode 100644 django/nside_wefa/attachments/tests/test_app/migrations/__init__.py create mode 100644 django/nside_wefa/attachments/tests/test_app/models.py create mode 100644 django/nside_wefa/attachments/tests/test_app/urls.py create mode 100644 django/nside_wefa/attachments/tests/test_checks.py create mode 100644 django/nside_wefa/attachments/tests/test_models.py create mode 100644 django/nside_wefa/attachments/tests/test_storage.py create mode 100644 django/nside_wefa/attachments/tests/test_validators.py create mode 100644 django/nside_wefa/attachments/tests/test_views/__init__.py create mode 100644 django/nside_wefa/attachments/tests/test_views/test_multi.py create mode 100644 django/nside_wefa/attachments/tests/test_views/test_replace_in_place.py create mode 100644 django/nside_wefa/attachments/tests/test_views/test_singleton.py create mode 100644 django/nside_wefa/attachments/urls.py create mode 100644 django/nside_wefa/attachments/validators.py create mode 100644 django/nside_wefa/attachments/views/__init__.py create mode 100644 django/nside_wefa/attachments/views/attachment_views.py diff --git a/django/README.md b/django/README.md index 53028dc1..fba87262 100644 --- a/django/README.md +++ b/django/README.md @@ -17,12 +17,14 @@ WeFa (Web Factory) delivers a set of modular Django apps that cover recurring we ## Features -- Shared utilities that power the higher-level apps (`nside_wefa.common`) -- Plug-and-play Django REST Framework authentication configuration (token and JWT) (`nside_wefa.authentication`) -- Legal consent tracking with automatic user onboarding and templated documents (`nside_wefa.legal_consent`) -- Per-user locale persistence with a public discovery endpoint (`nside_wefa.locale`) -- Append-only audit log with REST endpoints, model-registration UX, built-in event sources, and optional tamper-evident hash chain — built on `django-auditlog` (`nside_wefa.audit`) +- Shared utilities that power the higher-level apps ([`nside_wefa.common`](nside_wefa/common/)) +- Plug-and-play Django REST Framework authentication configuration (token and JWT) ([`nside_wefa.authentication`](nside_wefa/authentication/README.md)) +- Legal consent tracking with automatic user onboarding and templated documents ([`nside_wefa.legal_consent`](nside_wefa/legal_consent/README.md)) +- Per-user locale persistence with a public discovery endpoint ([`nside_wefa.locale`](nside_wefa/locale/README.md)) +- Append-only audit log with REST endpoints, model-registration UX, built-in event sources, and optional tamper-evident hash chain — built on `django-auditlog` ([`nside_wefa.audit`](nside_wefa/audit/README.md)) +- Abstract attachment model with versioning, pluggable storage (S3/local/SFTP via `django-storages`), python-magic content-type sniffing, and a generic CRUD endpoint factory ([`nside_wefa.attachments`](nside_wefa/attachments/README.md)) - System checks and sensible defaults so configuration mistakes surface early +- A runnable [demo project](demo/README.md) showing how to consume every app end-to-end ## Installation @@ -40,25 +42,35 @@ nside-wefa>=0.3.0 ## Included Apps -### Common +### [Common](nside_wefa/common/) Foundational helpers shared across the toolkit. You rarely interact with it directly, but it must be installed before the other apps. -### Authentication +### [Authentication](nside_wefa/authentication/README.md) -Automatically wires Django REST Framework authentication classes, URLs, and dependency checks. See `nside_wefa/authentication/README.md` for the full guide. +Automatically wires Django REST Framework authentication classes, URLs, and dependency checks. See the [authentication README](nside_wefa/authentication/README.md) for the full guide. -### Legal Consent +### [Legal Consent](nside_wefa/legal_consent/README.md) -Tracks acceptance of privacy and terms documents with templating support and REST endpoints. See `nside_wefa/legal_consent/README.md` for details. +Tracks acceptance of privacy and terms documents with templating support and REST endpoints. See the [legal consent README](nside_wefa/legal_consent/README.md) for details. -### Locale +### [Locale](nside_wefa/locale/README.md) -Persists each user's preferred locale and exposes the supported locales for the project over REST. See `nside_wefa/locale/README.md` for details. +Persists each user's preferred locale and exposes the supported locales for the project over REST. See the [locale README](nside_wefa/locale/README.md) for details. -### Audit +### [Audit](nside_wefa/audit/README.md) -Wraps `django-auditlog` to give every product an append-only audit store with four ergonomic ways to register models, REST endpoints (`/audit/events/` for staff, `/audit/me/` for end users), built-in event sources for `auth` / `legal_consent` / `locale`, an optional SHA-256 tamper-evident chain, and management commands for purge / verify / GDPR export. See `nside_wefa/audit/README.md` for details. +Wraps `django-auditlog` to give every product an append-only audit store with four ergonomic ways to register models, REST endpoints (`/audit/events/` for staff, `/audit/me/` for end users), built-in event sources for `auth` / `legal_consent` / `locale`, an optional SHA-256 tamper-evident chain, and management commands for purge / verify / GDPR export. See the [audit README](nside_wefa/audit/README.md) for details. + +### [Attachments](nside_wefa/attachments/README.md) + +Provides an abstract `Attachment` base model that consumer apps subclass to add file-attachment semantics to their tables. Versioning, storage routing through `django-storages`, whitelist-only content-type sniffing via `python-magic`, streaming size enforcement, hashing, and a generic CRUD endpoint factory in either *singleton* or *multi* mode. See the [attachments README](nside_wefa/attachments/README.md) for details. + +### [Demo project](demo/README.md) + +A runnable Django project that consumes every WeFa app and ships two illustrative consumer apps (`demo.profiles` for avatars, `demo.documents` for versioned PDFs / spreadsheets). Use it as a worked example when integrating the toolkit into a new project. See the [demo README](demo/README.md) for the reading order and quick start. + +The Vue demo playground in [`../vue/`](../vue/README.md) talks to this Django demo over HTTP — its dev client is hard-wired to `http://localhost:8000`, so spin up `manage.py runserver` here before running `npm run dev` on the Vue side. ## Quick Start @@ -77,6 +89,7 @@ Wraps `django-auditlog` to give every product an append-only audit store with fo "nside_wefa.legal_consent", "nside_wefa.locale", "nside_wefa.audit", + "nside_wefa.attachments", ] ``` @@ -121,15 +134,25 @@ NSIDE_WEFA = { "DEFAULT": "en", }, "AUDIT": { - # All keys are optional. See nside_wefa/audit/README.md. + # All keys are optional. See the audit README. # Track third-party models you can't edit: # "MODELS": {"auth.Group": {"include_fields": ["name"]}}, # "TAMPER_EVIDENT": True, # SHA-256 hash chain (opt-in) # "RETENTION_DAYS": 365, # used by wefa_audit_purge }, + "ATTACHMENTS": { + # All keys are optional. See the attachments README. + # "STORAGE": "default", # alias into settings.STORAGES + # "MAX_FILE_SIZE": 10 * 1024 * 1024, # global cap; subclasses may override + # "UPLOAD_PATH_PREFIX": "attachments/", + # "HASH_ALGORITHM": "sha256", + # "CONTENT_TYPE_SNIFF_BYTES": 2048, + }, } ``` +Per-app settings reference: [authentication](nside_wefa/authentication/README.md), [legal consent](nside_wefa/legal_consent/README.md), [locale](nside_wefa/locale/README.md), [audit](nside_wefa/audit/README.md), [attachments](nside_wefa/attachments/README.md). + Validation happens through Django system checks. Run `python manage.py check` to surface configuration issues early. ## Requirements @@ -138,6 +161,8 @@ Validation happens through Django system checks. Run `python manage.py check` to - Django >= 6.0.4 - Django REST Framework >= 3.14.0 - djangorestframework-simplejwt >= 5.5.1 (if you enable JWT support) +- django-storages >= 1.14 (used by `nside_wefa.attachments`) +- python-magic >= 0.4.27 (used by `nside_wefa.attachments`; needs `libmagic` on the host) ## Local Development @@ -150,7 +175,7 @@ source .venv/bin/activate pip install -e .[dev] ``` -Run the demo project: +Run the [demo project](demo/README.md): ```bash python manage.py migrate diff --git a/django/demo/README.md b/django/demo/README.md new file mode 100644 index 00000000..c17df7af --- /dev/null +++ b/django/demo/README.md @@ -0,0 +1,96 @@ +# WeFa Demo Project + +A minimal Django project that consumes every app shipped by the +`nside-wefa` package. Its purpose is **educational** — it shows the +shortest path from a fresh Django installation to a working integration +of the toolkit. + +If you're new to the package, read the demo's source files alongside +each app's README to see how the documented APIs are wired up in +practice. + +## What's inside + +The demo project is a regular Django project with two demo apps that +illustrate consumer-side wiring: + +| Folder | What it shows | +|---|---| +| [`demo/settings.py`](settings.py) | Installing every WeFa app, populating the `NSIDE_WEFA` settings dict, declaring `STORAGES` for the attachments app. | +| [`demo/urls.py`](urls.py) | Mounting each app's URLs at a stable prefix and including the demo apps' URL trees. | +| [`demo/profiles/`](profiles/) | Demo app — adds an `Avatar` model to users using the **singleton, non-versioned** mode of [`nside_wefa.attachments`](../nside_wefa/attachments/README.md). | +| [`demo/documents/`](documents/) | Demo app — adds a `Document` model accepting PDFs and Excel files using the **multi, versioned** mode of [`nside_wefa.attachments`](../nside_wefa/attachments/README.md). | +| [`demo/conftest.py`](conftest.py) | Shared pytest fixtures: per-test `MEDIA_ROOT` redirect, byte builders for PNG/PDF, two-user setup. | + +Each demo app contains heavily commented `models.py`, `urls.py`, and a +matching `tests/test_*.py` file. The comments explain *why* each line +is there and what the alternatives look like, rather than restating +*what* the code does — they're written as reading material for +developers working through the toolkit for the first time. + +## Per-app reading order + +The fastest way to learn the toolkit is to read each app's README and +then jump to the matching demo wiring: + +1. [`nside_wefa.common`](../nside_wefa/common/) — foundational helpers + (`get_section`, system check primitives). No demo app of its own; + the other demos rely on it transparently. +2. [`nside_wefa.authentication`](../nside_wefa/authentication/README.md) — + wired in `demo/settings.py` and `demo/urls.py`. +3. [`nside_wefa.legal_consent`](../nside_wefa/legal_consent/README.md) — + wired in `demo/settings.py` and `demo/urls.py`. +4. [`nside_wefa.locale`](../nside_wefa/locale/README.md) — + wired in `demo/settings.py` and `demo/urls.py`. +5. [`nside_wefa.audit`](../nside_wefa/audit/README.md) — + wired in `demo/settings.py` and `demo/urls.py`. +6. [`nside_wefa.attachments`](../nside_wefa/attachments/README.md) — + wired in `demo/settings.py`; demo apps live at + [`demo/profiles/`](profiles/) and [`demo/documents/`](documents/). + +## Running the demo + +From the `django/` directory: + +```bash +uv sync --all-extras # install runtime + dev dependencies +uv run python manage.py migrate +uv run python manage.py createsuperuser +uv run python manage.py runserver +``` + +A few interesting URLs once the server is up: + +| URL | What it does | +|---|---| +| `/admin/` | Django admin — log in with the superuser. | +| `/auth/` | Authentication endpoints exposed by `nside_wefa.authentication`. | +| `/legal-consent/agreement/` | Per-user consent state (login required). | +| `/locale/` | List supported locales. | +| `/audit/events/` | Audit event log (staff only). | +| `/me/avatar/` | Demo: singleton avatar (POST to upload, GET to read, GET `/download/` to stream). | +| `/documents/` | Demo: multi-document store (POST to upload v1, POST `//versions/` to bump, GET `//versions/` for history). | + +## Running the demo's tests + +```bash +uv run pytest demo/ +``` + +The demo tests are HTTP-level — they exercise the same endpoints a +frontend would call and exist primarily as worked examples for +consumers writing their own test suites against the toolkit. See +[`demo/profiles/tests/test_avatar.py`](profiles/tests/test_avatar.py) +and [`demo/documents/tests/test_documents.py`](documents/tests/test_documents.py). + +## Notes for readers + +- The demo intentionally uses `FileSystemStorage` and SQLite. Swap + these for S3 / Postgres in a real product by editing + [`STORAGES`](settings.py) and [`DATABASES`](settings.py). +- Several of the per-app READMEs include *additional* configuration + knobs the demo doesn't exercise. Consult them when adapting a demo + pattern to a real product. +- The demo project is **not** packaged on PyPI — only the + `nside_wefa.*` apps are. The demo lives in the source tree to make + the toolkit easier to learn and to drive the test suite. diff --git a/django/demo/conftest.py b/django/demo/conftest.py new file mode 100644 index 00000000..73b38194 --- /dev/null +++ b/django/demo/conftest.py @@ -0,0 +1,98 @@ +"""Shared pytest fixtures for the demo apps. + +Fixtures are scoped to ``demo/`` — they auto-apply to any test under +this tree. The patterns shown here are the same ones a real consumer +would use when writing tests against their own ``Attachment`` subclass: + +- :func:`isolated_media` redirects ``MEDIA_ROOT`` to a per-test temp + directory so test runs never leak files into the project's persistent + media folder. +- The byte fixtures are real magic-byte headers. python-magic relies on + the leading bytes of the stream to determine the MIME, so synthetic + payloads must look like the real format. +- :func:`upload_file` is a small builder that returns a Django + :class:`SimpleUploadedFile` — what views expect on the request. +""" + +from __future__ import annotations + +from typing import Callable + +import pytest +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile + + +# A 1×1 transparent PNG. Just enough bytes for libmagic to identify +# the file as image/png. +PNG_HEADER = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\rIDATx\x9cc\xfa\xcf\x00\x00\x00\x02\x00\x01\xe2!\xbc\x33" + b"\x00\x00\x00\x00IEND\xaeB`\x82" +) + +# The smallest PDF that's also a valid one — enough for libmagic to +# detect application/pdf. +PDF_HEADER = ( + b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" + b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + b"2 0 obj\n<< /Type /Pages /Count 0 /Kids [] >>\nendobj\n" + b"xref\n0 3\n0000000000 65535 f \n" + b"0000000015 00000 n \n0000000060 00000 n \n" + b"trailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n110\n%%EOF\n" +) + + +@pytest.fixture(autouse=True) +def isolated_media(tmp_path, settings): + """Redirect MEDIA_ROOT to a tmp dir so test files don't persist.""" + settings.MEDIA_ROOT = str(tmp_path / "media") + return tmp_path / "media" + + +@pytest.fixture +def png_bytes() -> bytes: + return PNG_HEADER + + +@pytest.fixture +def pdf_bytes() -> bytes: + return PDF_HEADER + + +@pytest.fixture +def upload_file() -> Callable[..., SimpleUploadedFile]: + """Return a builder for ``SimpleUploadedFile`` payloads. + + The third arg (``content_type``) is what the client *claims*. The + attachments library ignores it — it sniffs the actual MIME from the + bytes via python-magic — but the field is still useful when writing + tests that exercise the "client lies about type" branch. + """ + + def _build( + name: str, + content: bytes, + content_type: str = "application/octet-stream", + ) -> SimpleUploadedFile: + return SimpleUploadedFile(name=name, content=content, content_type=content_type) + + return _build + + +@pytest.fixture +def user(db): + """A logged-in test user.""" + return get_user_model().objects.create_user( + username="alice", email="alice@example.com", password="x" + ) + + +@pytest.fixture +def other_user(db): + """A second user, used to assert tenant isolation.""" + return get_user_model().objects.create_user( + username="bob", email="bob@example.com", password="x" + ) diff --git a/django/demo/documents/__init__.py b/django/demo/documents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/demo/documents/apps.py b/django/demo/documents/apps.py new file mode 100644 index 00000000..62cb3de5 --- /dev/null +++ b/django/demo/documents/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class DocumentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "demo.documents" + verbose_name = "Demo Documents" diff --git a/django/demo/documents/migrations/0001_initial.py b/django/demo/documents/migrations/0001_initial.py new file mode 100644 index 00000000..6d10c824 --- /dev/null +++ b/django/demo/documents/migrations/0001_initial.py @@ -0,0 +1,133 @@ +# Generated by Django 6.0.4 on 2026-04-29 08:34 + +import django.db.models.deletion +import nside_wefa.attachments.models.attachment +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Document", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "attachment_uid", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + help_text="Stable identifier shared across all versions of one logical attachment.", + ), + ), + ( + "version", + models.PositiveIntegerField( + default=1, + help_text="Monotonically increasing revision counter within an attachment_uid.", + ), + ), + ( + "is_current", + models.BooleanField( + db_index=True, + default=True, + help_text="True for the most recent revision of this attachment_uid.", + ), + ), + ( + "file", + models.FileField( + help_text="The stored file blob.", + max_length=512, + upload_to=nside_wefa.attachments.models.attachment._attachment_upload_to, + ), + ), + ( + "filename", + models.CharField( + help_text="Sanitised original filename, for display.", + max_length=255, + ), + ), + ( + "content_type", + models.CharField( + help_text="MIME type as detected from the file bytes (not the client header).", + max_length=128, + ), + ), + ( + "byte_size", + models.PositiveBigIntegerField( + help_text="Verified size in bytes after streaming." + ), + ), + ( + "file_hash", + models.CharField( + db_index=True, + help_text="Hex digest computed by streaming the file through `hash_algorithm`.", + max_length=128, + ), + ), + ( + "hash_algorithm", + models.CharField( + default="sha256", + help_text="Algorithm used for `file_hash`.", + max_length=32, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "title", + models.CharField( + blank=True, + default="", + help_text="Optional human-friendly title shown in the UI.", + max_length=255, + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="documents", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "uploaded_by", + models.ForeignKey( + blank=True, + help_text="The user who performed the upload, when known.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ("-created_at", "-id"), + }, + ), + ] diff --git a/django/demo/documents/migrations/__init__.py b/django/demo/documents/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/demo/documents/models.py b/django/demo/documents/models.py new file mode 100644 index 00000000..e01b07e2 --- /dev/null +++ b/django/demo/documents/models.py @@ -0,0 +1,88 @@ +"""Demo: how to add a versioned document store with the WeFa attachments app. + +This module shows the **multi, versioned** mode of the attachments +library — appropriate when a user (or any owning entity) can attach +many distinct documents, and each document has its own revision +history. + +What to look at when reading this file: + +1. ``versioning_enabled = True`` (the default) makes every re-upload of + a logical document create a new row with ``version + 1``. Prior rows + are flipped to ``is_current = False`` so the manager's ``.current()`` + helper still returns one row per logical document. +2. The whitelist below is intentionally small. To accept additional + spreadsheet formats (e.g. ODS), add their MIME strings — never wrap + in wildcards or fall back to allowing "anything that isn't an + executable". Whitelist-only validation is the security perimeter of + the attachments app. +3. Subclasses can store any extra columns alongside the file. The + ``title`` field is illustrative — a real product might add things + like a description, expiry date, or signing status. +""" + +from django.conf import settings +from django.db import models + +from nside_wefa.attachments.models import Attachment + + +# MIME types a "PDF or spreadsheet" upload might legitimately match. +# Listed as a module-level constant so the whitelist can be referenced +# from tests, admin, and downstream code without re-typing it. +DOCUMENT_CONTENT_TYPES = [ + "application/pdf", + # Modern .xlsx (zip-based, Open Office XML). + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + # Legacy .xls (OLE2 compound document). + "application/vnd.ms-excel", + # Some libmagic builds report legacy .xls as the generic OLE2 + # storage type. Whitelist it so legitimate .xls uploads work + # regardless of the host's libmagic version. + "application/x-ole-storage", +] + + +class Document(Attachment): + """A versioned PDF or spreadsheet owned by a user.""" + + # --- Attachment policy ------------------------------------------------ + # versioning_enabled defaults to True; left explicit here for + # pedagogical clarity. + versioning_enabled = True + + # Whitelist-only content types — see DOCUMENT_CONTENT_TYPES above. + allowed_content_types = DOCUMENT_CONTENT_TYPES + + # 25 MB cap. For larger files, consider chunked uploads or storing + # outside the request/response cycle (out of scope for this demo). + max_size = 25 * 1024 * 1024 + + # Stored under MEDIA_ROOT/documents///. + upload_path_prefix = "documents/" + + # --- Owning relation ------------------------------------------------- + # ForeignKey, not OneToOne — each user can have many documents. + # ``related_name="documents"`` exposes ``user.documents`` for + # convenient query in views and templates. + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="documents", + ) + + # --- Optional subclass-specific columns ------------------------------ + # Demonstrates that subclasses can add their own data alongside the + # file. The serializer factory exposes these via ``extra_writable`` / + # ``extra_read_only``; see the urls.py module for how to wire it up. + title = models.CharField( + max_length=255, + blank=True, + default="", + help_text="Optional human-friendly title shown in the UI.", + ) + + class Meta: + # Sort by most-recent-first within each owner. The manager's + # ``.current()`` helper applies on top of this. + ordering = ("-created_at", "-id") diff --git a/django/demo/documents/tests/__init__.py b/django/demo/documents/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/demo/documents/tests/test_documents.py b/django/demo/documents/tests/test_documents.py new file mode 100644 index 00000000..fb85837b --- /dev/null +++ b/django/demo/documents/tests/test_documents.py @@ -0,0 +1,192 @@ +"""Demo tests: documents (multi, versioned). + +Each test demonstrates one shape of the multi-mode endpoint surface: +listing, creating, bumping versions, retrieving history, downloading, +and tenant isolation. +""" + +from __future__ import annotations + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from demo.documents.models import Document + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def client(user): + api = APIClient() + api.force_authenticate(user=user) + return api + + +def test_collection_starts_empty(client): + response = client.get("/documents/") + assert response.status_code == status.HTTP_200_OK + assert response.data == [] + + +def test_post_creates_a_new_logical_document(client, user, pdf_bytes, upload_file): + """In multi-mode, two POSTs on the collection produce two + independent logical documents — each with its own attachment_uid + and starting at version 1. + + Re-uploading a *new revision* of the same document is a different + verb (POST //versions/), tested below. + """ + first = client.post( + "/documents/", + {"file": upload_file("a.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + second = client.post( + "/documents/", + {"file": upload_file("b.pdf", pdf_bytes + b"\x00", "application/pdf")}, + format="multipart", + ) + assert first.status_code == status.HTTP_201_CREATED + assert second.status_code == status.HTTP_201_CREATED + assert first.data["attachment_uid"] != second.data["attachment_uid"] + assert first.data["version"] == 1 + assert second.data["version"] == 1 + + +def test_post_to_versions_endpoint_bumps_specific_document( + client, user, pdf_bytes, upload_file +): + """POST /documents//versions/ creates a new version of the + document anchored by ``pk``. The new row shares its parent's + ``attachment_uid``, gets ``version + 1``, and the parent is flipped + to ``is_current = False`` atomically. + + Inside ``add_version`` the library inherits the parent's owning FKs + (here, ``owner_id``) onto the new row so the constraint is satisfied + without the caller having to repeat itself. + """ + create = client.post( + "/documents/", + {"file": upload_file("v1.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + pk = create.data["id"] + + bump = client.post( + f"/documents/{pk}/versions/", + {"file": upload_file("v2.pdf", pdf_bytes + b"x", "application/pdf")}, + format="multipart", + ) + assert bump.status_code == status.HTTP_201_CREATED + assert bump.data["version"] == 2 + assert bump.data["attachment_uid"] == create.data["attachment_uid"] + + +def test_versions_endpoint_lists_history_oldest_first( + client, user, pdf_bytes, upload_file +): + create = client.post( + "/documents/", + {"file": upload_file("v1.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + pk = create.data["id"] + client.post( + f"/documents/{pk}/versions/", + {"file": upload_file("v2.pdf", pdf_bytes + b"x", "application/pdf")}, + format="multipart", + ) + response = client.get(f"/documents/{pk}/versions/") + assert response.status_code == status.HTTP_200_OK + assert [r["version"] for r in response.data] == [1, 2] + + +def test_collection_lists_only_current_versions(client, user, pdf_bytes, upload_file): + """The list endpoint returns one row per logical document (the + current version), even after several version bumps.""" + create = client.post( + "/documents/", + {"file": upload_file("v1.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + pk = create.data["id"] + client.post( + f"/documents/{pk}/versions/", + {"file": upload_file("v2.pdf", pdf_bytes + b"x", "application/pdf")}, + format="multipart", + ) + listing = client.get("/documents/") + assert len(listing.data) == 1 + assert listing.data[0]["version"] == 2 + + +def test_download_streams_specific_row_bytes(client, user, pdf_bytes, upload_file): + create = client.post( + "/documents/", + {"file": upload_file("v1.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + pk = create.data["id"] + response = client.get(f"/documents/{pk}/download/") + assert response.status_code == status.HTTP_200_OK + assert "attachment" in response["Content-Disposition"] + body = b"".join(response.streaming_content) + assert body == pdf_bytes + + +def test_delete_removes_every_version(client, user, pdf_bytes, upload_file): + """DELETE on a row hard-deletes the entire logical document — every + historical row plus its blob in storage. Use with care.""" + create = client.post( + "/documents/", + {"file": upload_file("v1.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + pk = create.data["id"] + client.post( + f"/documents/{pk}/versions/", + {"file": upload_file("v2.pdf", pdf_bytes + b"x", "application/pdf")}, + format="multipart", + ) + response = client.delete(f"/documents/{pk}/") + assert response.status_code == status.HTTP_204_NO_CONTENT + assert Document.objects.filter(owner=user).count() == 0 + + +def test_image_is_rejected_by_whitelist(client, png_bytes, upload_file): + """Documents accept PDFs and Excel files only — a PNG is refused + even with a credible filename.""" + response = client.post( + "/documents/", + {"file": upload_file("looks-like-a-doc.pdf", png_bytes, "application/pdf")}, + format="multipart", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_users_cannot_see_each_others_documents( + client, user, other_user, pdf_bytes, upload_file +): + """Same scoping pattern as the avatar app: every endpoint runs + through the ``queryset`` callable, which restricts to + ``Document.objects.filter(owner=request.user)``.""" + create = client.post( + "/documents/", + {"file": upload_file("alice.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + pk = create.data["id"] + + bob = APIClient() + bob.force_authenticate(user=other_user) + assert bob.get("/documents/").data == [] + assert bob.get(f"/documents/{pk}/").status_code == status.HTTP_404_NOT_FOUND + # Bob cannot bump Alice's document either — the queryset hides it. + bump = bob.post( + f"/documents/{pk}/versions/", + {"file": upload_file("v2.pdf", pdf_bytes + b"x", "application/pdf")}, + format="multipart", + ) + assert bump.status_code == status.HTTP_404_NOT_FOUND diff --git a/django/demo/documents/urls.py b/django/demo/documents/urls.py new file mode 100644 index 00000000..3c91f826 --- /dev/null +++ b/django/demo/documents/urls.py @@ -0,0 +1,64 @@ +"""Demo: wiring HTTP endpoints for versioned documents. + +This is the **multi-mode** counterpart to the avatar URLs. The crucial +differences: + +- POST on the collection URL creates a **new logical document** (v1 + with a fresh ``attachment_uid``). Two POSTs back-to-back produce two + independent documents, not v1+v2 of the same one. +- POST on ``//versions/`` is the verb that bumps a specific + document's version. The pk on the URL is the anchor; the new row + shares its ``attachment_uid``. + +The ``queryset`` callable below is the single most important line for +authorisation: every endpoint built by ``register_attachment_endpoints`` +runs its query through it before doing anything else, so a row outside +the queryset is invisible to the caller — including the bump-version +path. +""" + +from rest_framework.permissions import IsAuthenticated + +from nside_wefa.attachments.serializers import build_attachment_serializer +from nside_wefa.attachments.urls import register_attachment_endpoints + +from .models import Document + + +def _document_queryset(request, **url_kwargs): + # Scope every endpoint to documents owned by the requesting user. + # The library handles the "is_current" filtering internally where it + # makes sense (e.g. the list endpoint), so we don't filter by it + # here — the version-history endpoint needs the prior rows. + return Document.objects.filter(owner=request.user) + + +def _bind_document_owner(request, instance, **url_kwargs): + # Set the owning FK on the new document just before ingest. This + # only fires on POST; the bump-version path inherits FKs from the + # parent row automatically (see Attachment.add_version). + instance.owner = request.user + + +# Build the serializer once so we can list ``title`` as a read-only +# field. The default serializer would still work — this is here to +# show the ``extra_read_only`` / ``extra_writable`` extension points. +DocumentSerializer = build_attachment_serializer( + Document, + extra_read_only=("title",), +) + + +urlpatterns = [ + *register_attachment_endpoints( + Document, + prefix="documents", + # Multi-mode: many logical documents per user, each with its own + # version chain. POST creates new; POST /versions/ bumps. + singleton=False, + permissions=[IsAuthenticated], + queryset=_document_queryset, + on_create=_bind_document_owner, + serializer_class=DocumentSerializer, + ), +] diff --git a/django/demo/profiles/migrations/0001_initial.py b/django/demo/profiles/migrations/0001_initial.py new file mode 100644 index 00000000..b6edf37d --- /dev/null +++ b/django/demo/profiles/migrations/0001_initial.py @@ -0,0 +1,125 @@ +# Generated by Django 6.0.4 on 2026-04-29 08:34 + +import django.db.models.deletion +import nside_wefa.attachments.models.attachment +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Avatar", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "attachment_uid", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + help_text="Stable identifier shared across all versions of one logical attachment.", + ), + ), + ( + "version", + models.PositiveIntegerField( + default=1, + help_text="Monotonically increasing revision counter within an attachment_uid.", + ), + ), + ( + "is_current", + models.BooleanField( + db_index=True, + default=True, + help_text="True for the most recent revision of this attachment_uid.", + ), + ), + ( + "file", + models.FileField( + help_text="The stored file blob.", + max_length=512, + upload_to=nside_wefa.attachments.models.attachment._attachment_upload_to, + ), + ), + ( + "filename", + models.CharField( + help_text="Sanitised original filename, for display.", + max_length=255, + ), + ), + ( + "content_type", + models.CharField( + help_text="MIME type as detected from the file bytes (not the client header).", + max_length=128, + ), + ), + ( + "byte_size", + models.PositiveBigIntegerField( + help_text="Verified size in bytes after streaming." + ), + ), + ( + "file_hash", + models.CharField( + db_index=True, + help_text="Hex digest computed by streaming the file through `hash_algorithm`.", + max_length=128, + ), + ), + ( + "hash_algorithm", + models.CharField( + default="sha256", + help_text="Algorithm used for `file_hash`.", + max_length=32, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "uploaded_by", + models.ForeignKey( + blank=True, + help_text="The user who performed the upload, when known.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="avatar", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at", "-id"], + "abstract": False, + }, + ), + ] diff --git a/django/demo/profiles/migrations/__init__.py b/django/demo/profiles/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/demo/profiles/models.py b/django/demo/profiles/models.py new file mode 100644 index 00000000..690fae09 --- /dev/null +++ b/django/demo/profiles/models.py @@ -0,0 +1,64 @@ +"""Demo: how to add a user avatar with the WeFa attachments app. + +This module shows the **singleton, non-versioned** mode of the +attachments library — ideal for "one-of" media that should be replaced +on re-upload rather than kept as history (avatars, hero images, profile +banners, etc.). + +What to look at when reading this file: + +1. The subclass declares its policy as plain class attributes. The + abstract :class:`Attachment` base reads them on every save, so the + contract is fully visible at the call site — no settings file to + chase. +2. ``versioning_enabled = False`` flips the model into replace-in-place + semantics. Re-uploads mutate the same row and discard the previous + blob from storage. The ``/versions/`` endpoint is automatically + omitted from the URL set. +3. ``allowed_content_types`` is a strict whitelist of MIME strings. + python-magic sniffs the actual MIME from the file bytes — the client + header is logged but never trusted. +""" + +from django.conf import settings +from django.db import models + +from nside_wefa.attachments.models import Attachment + + +class Avatar(Attachment): + """A user's avatar image. + + Singleton (one per user) with replace-in-place semantics. Re-uploads + overwrite the existing row instead of creating a new version. + """ + + # --- Attachment policy ------------------------------------------------ + # Re-uploads replace the existing row in place — no version history. + versioning_enabled = False + + # Whitelist of MIME types accepted on upload. python-magic reads the + # file's first ~2 KB and the resulting MIME must be in this list. + allowed_content_types = [ + "image/png", + "image/jpeg", + "image/webp", + ] + + # Per-subclass cap, in bytes. Overrides the global + # NSIDE_WEFA.ATTACHMENTS.MAX_FILE_SIZE for this model. + max_size = 2 * 1024 * 1024 # 2 MB + + # Stored under MEDIA_ROOT/avatars///. The uid + # and version come from server-side state, so a hostile client cannot + # influence anything beyond the trailing filename component. + upload_path_prefix = "avatars/" + + # --- Owning relation ------------------------------------------------- + # OneToOneField makes the (User, Avatar) relationship explicit at the + # schema level and gives you ``user.avatar`` for free in templates. + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="avatar", + ) diff --git a/django/demo/profiles/tests/__init__.py b/django/demo/profiles/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/demo/profiles/tests/test_avatar.py b/django/demo/profiles/tests/test_avatar.py new file mode 100644 index 00000000..e425867f --- /dev/null +++ b/django/demo/profiles/tests/test_avatar.py @@ -0,0 +1,145 @@ +"""Demo tests: avatar (singleton, non-versioned). + +These exercise the public HTTP surface of the avatar endpoints — the +same surface a real consumer's frontend or mobile app would call. Each +test is short and intentionally readable as documentation. + +Why HTTP-level tests instead of model-level ones? The model unit tests +already live with the abstract :class:`Attachment` class. The job of a +consumer's test suite is to assert that the *wiring* is correct: URL +routing, queryset scoping, FK binding, permission classes. Re-testing +sniffing or hashing here would just duplicate the library's coverage. +""" + +from __future__ import annotations + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from demo.profiles.models import Avatar + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def client(user): + """An APIClient already authenticated as the ``user`` fixture.""" + api = APIClient() + api.force_authenticate(user=user) + return api + + +def test_no_avatar_returns_404(client): + """Before any upload, GET on the singleton URL is a 404 — there is + nothing to return for this user yet.""" + response = client.get("/me/avatar/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_post_creates_avatar(client, user, png_bytes, upload_file): + """POST on the collection URL creates the avatar in singleton mode.""" + response = client.post( + "/me/avatar/", + {"file": upload_file("me.png", png_bytes, "image/png")}, + format="multipart", + ) + assert response.status_code == status.HTTP_201_CREATED + # Server-derived fields are populated on the response so the client + # can immediately render the new avatar without a follow-up GET. + assert response.data["content_type"] == "image/png" + assert response.data["version"] == 1 + assert Avatar.objects.filter(user=user).count() == 1 + + +def test_repeat_post_replaces_in_place(client, user, png_bytes, upload_file): + """Because ``versioning_enabled = False``, re-uploading mutates the + existing row instead of creating a new version. The user always has + exactly one avatar row.""" + client.post( + "/me/avatar/", + {"file": upload_file("first.png", png_bytes, "image/png")}, + format="multipart", + ) + client.post( + "/me/avatar/", + {"file": upload_file("second.png", png_bytes + b"\x00", "image/png")}, + format="multipart", + ) + # Single row, even after multiple uploads. + assert Avatar.objects.filter(user=user).count() == 1 + + +def test_versions_endpoint_is_not_registered(client): + """The /versions/ route is omitted automatically when the subclass + sets ``versioning_enabled = False`` — there's no history to list.""" + response = client.get("/me/avatar/versions/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_get_returns_current_avatar(client, png_bytes, upload_file): + client.post( + "/me/avatar/", + {"file": upload_file("me.png", png_bytes, "image/png")}, + format="multipart", + ) + response = client.get("/me/avatar/") + assert response.status_code == status.HTTP_200_OK + assert response.data["content_type"] == "image/png" + + +def test_download_streams_image_bytes(client, png_bytes, upload_file): + client.post( + "/me/avatar/", + {"file": upload_file("me.png", png_bytes, "image/png")}, + format="multipart", + ) + response = client.get("/me/avatar/download/") + assert response.status_code == status.HTTP_200_OK + assert response["Content-Type"].startswith("image/png") + body = b"".join(response.streaming_content) + assert body == png_bytes + + +def test_pdf_is_rejected_by_whitelist(client, pdf_bytes, upload_file): + """The avatar whitelist accepts PNG/JPEG/WebP — a PDF must be + rejected even if the client *claims* it is an image.""" + response = client.post( + "/me/avatar/", + {"file": upload_file("me.png", pdf_bytes, "image/png")}, + format="multipart", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_unauthenticated_request_is_rejected(png_bytes, upload_file): + """Permissions are enforced via the ``permissions=[IsAuthenticated]`` + argument to register_attachment_endpoints.""" + api = APIClient() + response = api.post( + "/me/avatar/", + {"file": upload_file("me.png", png_bytes, "image/png")}, + format="multipart", + ) + assert response.status_code in { + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + } + + +def test_users_cannot_see_each_others_avatars( + client, user, other_user, png_bytes, upload_file +): + """The ``queryset`` callable on the URL registration filters every + endpoint to ``Avatar.objects.filter(user=request.user)``, so Bob + cannot see Alice's avatar even by guessing the URL.""" + client.post( + "/me/avatar/", + {"file": upload_file("alice.png", png_bytes, "image/png")}, + format="multipart", + ) + + bob = APIClient() + bob.force_authenticate(user=other_user) + assert bob.get("/me/avatar/").status_code == status.HTTP_404_NOT_FOUND diff --git a/django/demo/profiles/urls.py b/django/demo/profiles/urls.py new file mode 100644 index 00000000..645f8e3a --- /dev/null +++ b/django/demo/profiles/urls.py @@ -0,0 +1,49 @@ +"""Demo: wiring HTTP endpoints for the avatar. + +``register_attachment_endpoints`` returns a list of URL patterns. Splat +the result into ``urlpatterns`` to mount it at the include() prefix. + +The two callables below are the standard pattern for scoping a +singleton attachment to the requesting user: + +- ``queryset`` returns the rows visible to the caller. Anything outside + the queryset is invisible to GET / POST / DELETE — a clean, + declarative authorisation boundary that doesn't require writing custom + views. +- ``on_create`` runs before the file ingest pipeline so you can attach + any owning FKs to the new instance. The ingest pipeline itself + (sniffing, hashing, validation, save) is handled by the library. +""" + +from rest_framework.permissions import IsAuthenticated + +from nside_wefa.attachments.urls import register_attachment_endpoints + +from .models import Avatar + + +def _avatar_queryset(request, **url_kwargs): + # The caller can only ever see their own avatar — there is no + # "list every avatar" endpoint exposed. + return Avatar.objects.filter(user=request.user) + + +def _bind_avatar_user(request, instance, **url_kwargs): + # Called once per POST, before the file is ingested. Set any + # subclass FKs on the instance here. + instance.user = request.user + + +urlpatterns = [ + *register_attachment_endpoints( + Avatar, + prefix="me/avatar", + # Singleton mode: one Avatar per scope (here, per user). POST on + # the collection URL creates the row or replaces it in place. + # The ```` segment is omitted from every URL. + singleton=True, + permissions=[IsAuthenticated], + queryset=_avatar_queryset, + on_create=_bind_avatar_user, + ), +] diff --git a/django/demo/settings.py b/django/demo/settings.py index 392b60b6..9fe5a496 100644 --- a/django/demo/settings.py +++ b/django/demo/settings.py @@ -47,7 +47,12 @@ "nside_wefa.legal_consent", "nside_wefa.locale", "nside_wefa.audit", + "nside_wefa.attachments", "drf_spectacular", + # Demo apps — illustrate how to consume the WeFa toolkit. Order them + # AFTER nside_wefa.* so any cross-app dependencies resolve cleanly. + "demo.profiles", + "demo.documents", ] NSIDE_WEFA = { @@ -72,8 +77,27 @@ # Tamper-evidence is off by default; flip to True to use WefaLogEntry. "TAMPER_EVIDENT": False, }, + "ATTACHMENTS": { + "STORAGE": "default", + "MAX_FILE_SIZE": 10 * 1024 * 1024, + "UPLOAD_PATH_PREFIX": "attachments/", + "HASH_ALGORITHM": "sha256", + "CONTENT_TYPE_SNIFF_BYTES": 2048, + }, } +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} + +MEDIA_ROOT = BASE_DIR / "media" +MEDIA_URL = "/media/" + REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_AUTHENTICATION_CLASSES": [ diff --git a/django/demo/urls.py b/django/demo/urls.py index 217abb37..6f21f3e7 100644 --- a/django/demo/urls.py +++ b/django/demo/urls.py @@ -24,4 +24,10 @@ path("authentication/", include("nside_wefa.authentication.urls")), path("locale/", include("nside_wefa.locale.urls")), path("audit/", include("nside_wefa.audit.urls")), + # Demo apps — illustrate how a consumer wires the attachments + # library into their own URL tree. The avatar endpoints live under + # /me/avatar/ (singleton) and the documents endpoints under + # /documents/ (multi). + path("", include("demo.profiles.urls")), + path("", include("demo.documents.urls")), ] diff --git a/django/nside_wefa/attachments/README.md b/django/nside_wefa/attachments/README.md new file mode 100644 index 00000000..c814b5a4 --- /dev/null +++ b/django/nside_wefa/attachments/README.md @@ -0,0 +1,211 @@ +# WeFa Attachments App + +An installable Django app that provides a reusable, abstract +`Attachment` base model with pluggable storage (S3, local, SFTP, FTP, …), +versioning, content-type sniffing, hashing, and a generic CRUD endpoint +factory. + +## Overview + +Consumer apps subclass `nside_wefa.attachments.models.Attachment` to add +file-attachment semantics to their own concrete tables. The base class +takes care of: + +- **Storage** via [`django-storages`](https://django-storages.readthedocs.io/) and Django 5+ `STORAGES`. Pick a backend per-subclass with a `storage_alias` class attribute. +- **MIME sniffing** with [`python-magic`](https://github.com/ahupp/python-magic). The client `Content-Type` header is logged but never trusted. +- **Whitelist-only validation.** Every subclass MUST declare + `allowed_content_types`. Unknown MIMEs are refused; there is no + blacklist. +- **Streaming size enforcement and content hashing.** Size is checked + both eagerly (declared size) and during streaming (running counter), so + a hostile client lying about size still hits a ceiling. +- **Versioning** with `versioning_enabled = True` (default): every new + upload creates a new row with `version + 1` and prior rows are flipped + to `is_current = False`. Set `versioning_enabled = False` to get + replace-in-place semantics for cases like avatars. +- **Generic CRUD endpoints.** `register_attachment_endpoints(...)` + returns URL patterns scoped to a concrete subclass, in either + *singleton* (one logical attachment per scope) or *multi* (many) + shape. + +## Installation + +1. Install the package — `django-storages` and `python-magic` are + pulled in automatically. `python-magic` requires `libmagic` on the + host (`brew install libmagic` on macOS, already present in the + standard CI image). +2. Add to `INSTALLED_APPS` (order matters — `nside_wefa.common` must + precede `nside_wefa.attachments`): + + ```python + INSTALLED_APPS = [ + # … + "nside_wefa.common", + "nside_wefa.attachments", + ] + ``` + +3. Configure `STORAGES` and `NSIDE_WEFA.ATTACHMENTS`: + + ```python + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + # Optional — register additional aliases for S3, SFTP, etc. + } + + NSIDE_WEFA = { + "ATTACHMENTS": { + # All keys optional; defaults shown. + "STORAGE": "default", + "MAX_FILE_SIZE": 10 * 1024 * 1024, + "UPLOAD_PATH_PREFIX": "attachments/", + "HASH_ALGORITHM": "sha256", + "CONTENT_TYPE_SNIFF_BYTES": 2048, + }, + } + ``` + + For non-default backends (S3, Azure, GCS, SFTP, FTP, Dropbox, …), see + the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/) + — every backend it ships is usable here by registering it as an alias + under Django's [`STORAGES`](https://docs.djangoproject.com/en/stable/ref/settings/#storages) + setting and pointing `NSIDE_WEFA.ATTACHMENTS.STORAGE` (or a + subclass's `storage_alias`) at it. The + [backends overview](https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html) + has copy-pasteable settings examples for the most common deployments. + + No migrations from this app — the model is abstract. + +## Defining a concrete attachment + +```python +from django.conf import settings +from django.db import models +from nside_wefa.attachments.models import Attachment + + +class ContractAttachment(Attachment): + # Required — whitelist of accepted MIME types (sniffed from bytes). + allowed_content_types = ["application/pdf"] + + # Optional knobs: + versioning_enabled = True # default + max_size = 25 * 1024 * 1024 # bytes; falls back to NSIDE_WEFA.ATTACHMENTS.MAX_FILE_SIZE + storage_alias = None # falls back to NSIDE_WEFA.ATTACHMENTS.STORAGE + upload_path_prefix = "contracts/" # falls back to UPLOAD_PATH_PREFIX + + contract = models.ForeignKey("contracts.Contract", on_delete=models.CASCADE, + related_name="attachments") +``` + +Run `makemigrations` / `migrate` for **your** app — the abstract base +contributes its columns to the concrete table. + +## Wiring HTTP endpoints + +### Singleton mode (avatar, contract PDF) + +```python +# myapp/urls.py +from rest_framework.permissions import IsAuthenticated +from nside_wefa.attachments.urls import register_attachment_endpoints +from .models import ContractAttachment + +urlpatterns = [ + *register_attachment_endpoints( + ContractAttachment, + prefix="contracts//attachment", + singleton=True, + permissions=[IsAuthenticated], + queryset=lambda request, **kw: ContractAttachment.objects.filter( + contract_id=kw["contract_id"], contract__user=request.user, + ), + on_create=lambda request, instance, **kw: setattr( + instance, "contract_id", kw["contract_id"] + ), + ), +] +``` + +| Method | URL | Behavior | +|---|---|---| +| `GET` | `/contracts/42/attachment/` | Current version, or 404. | +| `POST` | `/contracts/42/attachment/` | First call → v1; subsequent calls → v+1 (or replace in place when `versioning_enabled=False`). | +| `GET` | `/contracts/42/attachment/versions/` | Full history (omitted when `versioning_enabled=False`). | +| `GET` | `/contracts/42/attachment/versions//` | Specific historical version. | +| `GET` | `/contracts/42/attachment/download/` | Stream current bytes. | +| `DELETE` | `/contracts/42/attachment/` | Delete all versions and blobs. | + +### Multi mode (issue with many attachments) + +```python +*register_attachment_endpoints( + IssueAttachment, + prefix="issues//attachments", + permissions=[IsAuthenticated], + queryset=lambda request, **kw: IssueAttachment.objects.filter( + issue_id=kw["issue_id"] + ), + on_create=lambda request, instance, **kw: setattr( + instance, "issue_id", kw["issue_id"] + ), +), +``` + +| Method | URL | Behavior | +|---|---|---| +| `GET` | `/issues/7/attachments/` | List of current versions. | +| `POST` | `/issues/7/attachments/` | Create a new logical attachment (v1). | +| `GET` | `/issues/7/attachments//` | Retrieve a specific row. | +| `POST` | `/issues/7/attachments//versions/` | Bump that logical attachment. | +| `GET` | `/issues/7/attachments//versions/` | History for that uid. | +| `GET` | `/issues/7/attachments//download/` | Stream that row. | +| `DELETE` | `/issues/7/attachments//` | Hard-delete the entire logical attachment. | + +## Programmatic API + +```python +# Create v1 +ContractAttachment.upload(file=request.FILES["file"], contract=contract) + +# Bump to v2 (versioning_enabled=True) +ContractAttachment.add_version(file=request.FILES["file"], parent=current_row) + +# Replace in place (versioning_enabled=False) +existing.replace_in_place(file=request.FILES["file"]) + +# Manager helpers +contract.attachments.current().get() +contract.attachments.history(uid) + +# Hard delete with blob +existing.hard_delete_with_blob() +ContractAttachment.hard_delete_logical(uid) +``` + +## Security notes + +- Content-type sniffing always wins over the client header. +- The whitelist (`allowed_content_types`) is exact-match; wildcards + (`image/*`) are not supported in v1 to keep policies auditable. +- `MAX_FILE_SIZE` is enforced both eagerly and as a streaming counter. +- Filenames are sanitised (path separators, control chars, NUL stripped; + Unicode normalised; trimmed to 200 chars). The storage path is + `{prefix}/{attachment_uid}/{version}/{sanitised_filename}` so user + input never controls anything beyond the trailing component. +- Versioning and singleton-POST run inside `transaction.atomic()` with + `select_for_update()` over the scoped queryset, so concurrent uploads + serialise. +- Hard deletes remove both the database row and the underlying blob. + +## Settings reference + +| Key | Type | Default | Notes | +|---|---|---|---| +| `STORAGE` | `str` | `"default"` | Alias into `settings.STORAGES`. | +| `MAX_FILE_SIZE` | `int \| None` | `10485760` | Default cap; subclasses may override via `max_size`. | +| `UPLOAD_PATH_PREFIX` | `str` | `"attachments/"` | Prepended to the storage key. | +| `HASH_ALGORITHM` | `str` | `"sha256"` | Must be in `hashlib.algorithms_guaranteed`. | +| `CONTENT_TYPE_SNIFF_BYTES` | `int` | `2048` | Bytes read by libmagic. | diff --git a/django/nside_wefa/attachments/__init__.py b/django/nside_wefa/attachments/__init__.py new file mode 100644 index 00000000..a8ceafb8 --- /dev/null +++ b/django/nside_wefa/attachments/__init__.py @@ -0,0 +1,8 @@ +"""Attachments app — abstract file-attachment model with versioning, +pluggable storage (django-storages), MIME sniffing (python-magic), hashing +and configurable per-subclass validation. + +See ``README.md`` for usage and ``apps.AttachmentsConfig`` for the AppConfig. +""" + +default_app_config = "nside_wefa.attachments.apps.AttachmentsConfig" diff --git a/django/nside_wefa/attachments/admin.py b/django/nside_wefa/attachments/admin.py new file mode 100644 index 00000000..d29c4259 --- /dev/null +++ b/django/nside_wefa/attachments/admin.py @@ -0,0 +1,41 @@ +"""Admin helpers for concrete :class:`Attachment` subclasses. + +Provides a thin ``ModelAdmin`` mixin that exposes the audit-relevant +columns and prevents accidental editing of server-derived fields. +""" + +from django.contrib import admin + + +class AttachmentAdminMixin: + """Mixin for ``ModelAdmin`` classes managing :class:`Attachment` subclasses.""" + + list_display = ( + "id", + "attachment_uid", + "version", + "is_current", + "filename", + "content_type", + "byte_size", + "uploaded_by", + "created_at", + ) + list_filter = ("is_current", "content_type") + search_fields = ("attachment_uid", "filename", "file_hash") + readonly_fields = ( + "attachment_uid", + "version", + "is_current", + "filename", + "content_type", + "byte_size", + "file_hash", + "hash_algorithm", + "created_at", + "updated_at", + ) + ordering = ("-created_at", "-id") + + +__all__ = ["AttachmentAdminMixin", "admin"] diff --git a/django/nside_wefa/attachments/apps.py b/django/nside_wefa/attachments/apps.py new file mode 100644 index 00000000..ee2d1bcd --- /dev/null +++ b/django/nside_wefa/attachments/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class AttachmentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "nside_wefa.attachments" + verbose_name = "WeFa Attachments" + + def ready(self) -> None: + # Import checks so Django registers them during app initialization. + from . import checks # noqa: F401 diff --git a/django/nside_wefa/attachments/checks.py b/django/nside_wefa/attachments/checks.py new file mode 100644 index 00000000..4a22e2cb --- /dev/null +++ b/django/nside_wefa/attachments/checks.py @@ -0,0 +1,106 @@ +"""Django system checks for the attachments app. + +Validates ``NSIDE_WEFA.ATTACHMENTS`` and the order of installed apps. Per- +subclass validation (each concrete model declaring ``allowed_content_types`` +etc.) lives in the registration factory so it runs against models known +to the URL surface. +""" + +import hashlib +from typing import Any, List + +from django.conf import settings +from django.core.checks import Error, register + +from nside_wefa.common.apps import CommonConfig +from nside_wefa.utils.checks import ( + check_apps_dependencies_order, + check_nside_wefa_settings, + validate_optional_positive_int, +) + +from .apps import AttachmentsConfig + + +def _validate_storage_alias(value: Any) -> List[Error]: + if not isinstance(value, str) or not value: + return [ + Error( + "NSIDE_WEFA.ATTACHMENTS.STORAGE must be a non-empty string alias " + f"into settings.STORAGES, got {value!r}.", + ) + ] + storages_setting = getattr(settings, "STORAGES", None) or {} + if value not in storages_setting: + return [ + Error( + f"NSIDE_WEFA.ATTACHMENTS.STORAGE alias '{value}' is not declared " + "in settings.STORAGES.", + ) + ] + return [] + + +def _validate_hash_algorithm(value: Any) -> List[Error]: + if not isinstance(value, str) or value not in hashlib.algorithms_guaranteed: + return [ + Error( + "NSIDE_WEFA.ATTACHMENTS.HASH_ALGORITHM must be one of " + f"{sorted(hashlib.algorithms_guaranteed)}, got {value!r}.", + ) + ] + return [] + + +def _validate_positive_int(setting_path: str): + def _validator(value: Any) -> List[Error]: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + return [ + Error( + f"{setting_path} must be a positive integer, got {value!r}.", + ) + ] + return [] + + return _validator + + +def _validate_upload_path_prefix(value: Any) -> List[Error]: + if not isinstance(value, str): + return [ + Error( + "NSIDE_WEFA.ATTACHMENTS.UPLOAD_PATH_PREFIX must be a string, " + f"got {type(value).__name__}.", + ) + ] + return [] + + +@register() +def attachments_settings_check(app_configs, **kwargs) -> List[Error]: + """Validate the ``NSIDE_WEFA.ATTACHMENTS`` settings section. + + All keys are optional — defaults live in :class:`AttachmentsSettings`. + When a key is provided, it must be the right shape. + """ + return check_nside_wefa_settings( + section_name="ATTACHMENTS", + required_keys=[], + custom_validators={ + "STORAGE": _validate_storage_alias, + "MAX_FILE_SIZE": validate_optional_positive_int( + "NSIDE_WEFA.ATTACHMENTS.MAX_FILE_SIZE" + ), + "UPLOAD_PATH_PREFIX": _validate_upload_path_prefix, + "HASH_ALGORITHM": _validate_hash_algorithm, + "CONTENT_TYPE_SNIFF_BYTES": _validate_positive_int( + "NSIDE_WEFA.ATTACHMENTS.CONTENT_TYPE_SNIFF_BYTES" + ), + }, + ) + + +@register() +def attachments_apps_dependencies_check(app_configs, **kwargs) -> List[Error]: + """Ensure ``nside_wefa.common`` is installed before ``nside_wefa.attachments``.""" + return check_apps_dependencies_order([CommonConfig.name, AttachmentsConfig.name]) diff --git a/django/nside_wefa/attachments/migrations/__init__.py b/django/nside_wefa/attachments/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/nside_wefa/attachments/models/__init__.py b/django/nside_wefa/attachments/models/__init__.py new file mode 100644 index 00000000..d358ca1c --- /dev/null +++ b/django/nside_wefa/attachments/models/__init__.py @@ -0,0 +1,5 @@ +"""Public model surface for the attachments app.""" + +from .attachment import Attachment, AttachmentManager + +__all__ = ["Attachment", "AttachmentManager"] diff --git a/django/nside_wefa/attachments/models/attachment.py b/django/nside_wefa/attachments/models/attachment.py new file mode 100644 index 00000000..90d0b72d --- /dev/null +++ b/django/nside_wefa/attachments/models/attachment.py @@ -0,0 +1,485 @@ +"""Abstract :class:`Attachment` base model. + +Concrete subclasses gain: + +- A versioned blob anchored by ``attachment_uid``. +- python-magic content-type sniffing on every save. +- Whitelist-only content-type validation (``allowed_content_types``). +- Streaming size enforcement and content hashing. +- Pluggable storage via ``storage_alias``. + +See ``nside_wefa/attachments/README.md`` for usage. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any, Optional, Type +from uuid import UUID + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.core.files.base import File +from django.core.files.uploadedfile import UploadedFile +from django.db import models, transaction + +from ..settings import AttachmentsSettings +from ..storage import get_attachment_storage +from ..validators import ( + compute_file_hash, + sanitise_filename, + sniff_content_type, + validate_content_type, + validate_size, +) + +logger = logging.getLogger("nside_wefa.attachments") + + +def _resolve_storage_for(model_class: Type["Attachment"]): + """Return a callable suitable for ``FileField(storage=...)``. + + Resolves at call time so subclasses overriding ``storage_alias`` are + honoured. Django invokes the callable each time it needs the storage, + so the resolution stays lazy and respects settings overrides in tests. + """ + + def _factory(): + alias = getattr(model_class, "storage_alias", None) + return get_attachment_storage(alias) + + return _factory + + +def _concrete_fk_values(instance: "Attachment") -> dict[str, Any]: + """Return non-Attachment FK column values from ``instance``. + + Used when bumping a version so the new row carries the same owning + FK(s) as the prior row (e.g. ``contract_id``). ``uploaded_by`` is + excluded — that is set by the caller for the new revision. + """ + from django.db.models import ForeignKey + + out: dict[str, Any] = {} + for field in instance._meta.get_fields(): + if not isinstance(field, ForeignKey): + continue + if field.name == "uploaded_by": + continue + attname = field.attname # e.g. "contract_id" + value = getattr(instance, attname, None) + if value is not None: + out[attname] = value + return out + + +def _attachment_upload_to(instance: "Attachment", filename: str) -> str: + """Compute the storage key for an attachment. + + The path is ``{prefix}{uid}/{version}/{sanitised_filename}``. Both the + uid and version come from server-side state, so a hostile client cannot + influence the storage key beyond the trailing filename component. + """ + cls = type(instance) + settings_obj = AttachmentsSettings.load() + prefix = getattr(cls, "upload_path_prefix", None) or settings_obj.upload_path_prefix + if prefix and not prefix.endswith("/"): + prefix = prefix + "/" + safe_name = sanitise_filename(filename) + return f"{prefix}{instance.attachment_uid}/{instance.version}/{safe_name}" + + +class AttachmentManager(models.Manager): + """Manager exposing the version-aware query helpers. + + ``current()`` filters to the latest row per logical attachment; + ``history(uid)`` returns every revision for a given uid in chronological + order. + """ + + def current(self) -> models.QuerySet: + return self.filter(is_current=True) + + def history(self, attachment_uid: UUID) -> models.QuerySet: + return self.filter(attachment_uid=attachment_uid).order_by("version") + + +class Attachment(models.Model): + """Abstract attachment record. + + Subclasses MUST declare :attr:`allowed_content_types` (a non-empty list + of MIME strings). They MAY override: + + - :attr:`versioning_enabled` (default ``True``) + - :attr:`max_size` (bytes; falls back to ``NSIDE_WEFA.ATTACHMENTS.MAX_FILE_SIZE``) + - :attr:`storage_alias` (key into ``settings.STORAGES``) + - :attr:`upload_path_prefix` (prepended to the storage key) + """ + + # --- Subclass configuration knobs (override as class attributes) --- + + #: Whether re-uploads create a new versioned row (True) or replace the + #: existing row in place (False). When False, ``add_version()`` raises + #: and ``replace_in_place()`` is the supported re-upload path. + versioning_enabled: bool = True + + #: Whitelist of MIME strings accepted for this subclass. Required. + allowed_content_types: list[str] = [] + + #: Per-subclass max upload size in bytes. ``None`` (the default) defers + #: to ``NSIDE_WEFA.ATTACHMENTS.MAX_FILE_SIZE``. + max_size: Optional[int] = None + + #: Storage alias from ``settings.STORAGES``. ``None`` defers to + #: ``NSIDE_WEFA.ATTACHMENTS.STORAGE``. + storage_alias: Optional[str] = None + + #: Override of the global ``UPLOAD_PATH_PREFIX``. ``None`` defers. + upload_path_prefix: Optional[str] = None + + # --- Persisted columns --- + + attachment_uid = models.UUIDField( + default=uuid.uuid4, + db_index=True, + editable=False, + help_text="Stable identifier shared across all versions of one logical attachment.", + ) + version = models.PositiveIntegerField( + default=1, + help_text="Monotonically increasing revision counter within an attachment_uid.", + ) + is_current = models.BooleanField( + default=True, + db_index=True, + help_text="True for the most recent revision of this attachment_uid.", + ) + file = models.FileField( + storage=None, # populated in __init_subclass__ + upload_to=_attachment_upload_to, + max_length=512, + help_text="The stored file blob.", + ) + filename = models.CharField( + max_length=255, + help_text="Sanitised original filename, for display.", + ) + content_type = models.CharField( + max_length=128, + help_text="MIME type as detected from the file bytes (not the client header).", + ) + byte_size = models.PositiveBigIntegerField( + help_text="Verified size in bytes after streaming.", + ) + file_hash = models.CharField( + max_length=128, + db_index=True, + help_text="Hex digest computed by streaming the file through `hash_algorithm`.", + ) + hash_algorithm = models.CharField( + max_length=32, + default="sha256", + help_text="Algorithm used for `file_hash`.", + ) + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + help_text="The user who performed the upload, when known.", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = AttachmentManager() + + class Meta: + abstract = True + ordering = ["-created_at", "-id"] + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + if cls._meta.abstract: + return + # Bind the FileField storage to the subclass at class-creation time + # so subclass-level ``storage_alias`` overrides are picked up. + try: + file_field = cls._meta.get_field("file") + except Exception: # pragma: no cover - defensive, shouldn't happen + return + if isinstance(file_field, models.FileField): + file_field.storage = get_attachment_storage( + getattr(cls, "storage_alias", None) + ) + + # --- Validation helpers --- + + @classmethod + def _check_subclass_configuration(cls) -> None: + """Enforce that the subclass has declared the required knobs. + + Called from ``clean()``, ``add_version()``, ``replace_in_place()``, + and the registration factory so the failure mode is consistent + regardless of how the subclass is exercised. + """ + if ( + not isinstance(cls.allowed_content_types, list) + or not cls.allowed_content_types + ): + raise ImproperlyConfigured( + f"{cls.__module__}.{cls.__name__} must declare a non-empty " + "`allowed_content_types` list. Whitelist-only content-type " + "validation is mandatory." + ) + if cls.max_size is None and AttachmentsSettings.load().max_file_size is None: + raise ImproperlyConfigured( + f"{cls.__module__}.{cls.__name__} must declare `max_size` " + "(class attribute) or set NSIDE_WEFA.ATTACHMENTS.MAX_FILE_SIZE." + ) + + @classmethod + def effective_max_size(cls) -> int: + cls._check_subclass_configuration() + return cls.max_size or AttachmentsSettings.load().max_file_size # type: ignore[return-value] + + # --- Public API --- + + @classmethod + def upload( + cls, + *, + file: UploadedFile | File, + uploaded_by: Any | None = None, + attachment_uid: UUID | None = None, + parent: Optional["Attachment"] = None, + **extra: Any, + ) -> "Attachment": + """Create the first version of a logical attachment. + + Use :meth:`add_version` to bump an existing logical attachment, or + :meth:`replace_in_place` when ``versioning_enabled`` is False. + + ``extra`` is forwarded to the model constructor so subclass FKs + (e.g. ``contract=...``) can be supplied here. + """ + cls._check_subclass_configuration() + if parent is not None: + raise ValueError( + "upload() creates a fresh logical attachment; pass `parent` to " + "add_version() / replace_in_place() instead." + ) + instance = cls( + attachment_uid=attachment_uid or uuid.uuid4(), + version=1, + is_current=True, + uploaded_by=uploaded_by, + **extra, + ) + instance._ingest_file(file) + instance.save() + return instance + + @classmethod + def add_version( + cls, + *, + file: UploadedFile | File, + parent: Optional["Attachment"] = None, + attachment_uid: UUID | None = None, + uploaded_by: Any | None = None, + **extra: Any, + ) -> "Attachment": + """Append a new revision and flip prior rows to ``is_current=False``. + + Either ``parent`` (an existing row) or ``attachment_uid`` must be + supplied. The operation runs inside an atomic block with row-level + locks so concurrent calls serialise. + """ + cls._check_subclass_configuration() + if not cls.versioning_enabled: + raise NotImplementedError( + f"{cls.__name__}.versioning_enabled is False — use replace_in_place()." + ) + if parent is None and attachment_uid is None: + raise ValueError( + "add_version() requires either `parent` or `attachment_uid`." + ) + uid = attachment_uid or parent.attachment_uid # type: ignore[union-attr] + + with transaction.atomic(): + siblings = ( + cls.objects.select_for_update() + .filter(attachment_uid=uid) + .order_by("-version") + ) + siblings_list = list(siblings) + if not siblings_list: + next_version = 1 + fk_inheritance: dict[str, Any] = {} + else: + next_version = siblings_list[0].version + 1 + # Inherit subclass FKs (e.g. ``owner_id``, ``contract_id``) + # from the most recent sibling so the new row satisfies the + # same constraints. Caller can override via ``extra``. + fk_inheritance = _concrete_fk_values(siblings_list[0]) + # Flip every prior current row in this uid scope. + cls.objects.filter(attachment_uid=uid, is_current=True).update( + is_current=False + ) + merged = {**fk_inheritance, **extra} + instance = cls( + attachment_uid=uid, + version=next_version, + is_current=True, + uploaded_by=uploaded_by, + **merged, + ) + instance._ingest_file(file) + instance.save() + return instance + + def replace_in_place( + self, + *, + file: UploadedFile | File, + uploaded_by: Any | None = None, + ) -> "Attachment": + """Overwrite this row's file/hash/content/size/timestamp atomically. + + Only valid when ``versioning_enabled`` is False. The previous blob + is removed from storage on success. + """ + cls = type(self) + cls._check_subclass_configuration() + if cls.versioning_enabled: + raise NotImplementedError( + f"{cls.__name__}.versioning_enabled is True — use add_version()." + ) + old_storage_name = self.file.name if self.file else None + with transaction.atomic(): + locked = cls.objects.select_for_update().get(pk=self.pk) + self._ingest_file(file) + if uploaded_by is not None: + self.uploaded_by = uploaded_by + self.save() + # Unlink the old blob after a successful save. Do this last so + # a failure earlier in the transaction leaves storage intact. + if old_storage_name and old_storage_name != self.file.name: + try: + locked.file.storage.delete(old_storage_name) + except Exception: + logger.exception( + "Failed to delete previous attachment blob %s for %s pk=%s", + old_storage_name, + cls.__name__, + self.pk, + ) + return self + + def hard_delete_with_blob(self) -> None: + """Delete this row and its underlying blob. + + Provided as an explicit method because Django's default ``delete()`` + does not remove the file from storage. + """ + storage_name = self.file.name if self.file else None + storage = self.file.storage if self.file else None + super_delete = super().delete + super_delete() + if storage_name and storage is not None: + try: + storage.delete(storage_name) + except Exception: + logger.exception( + "Failed to delete attachment blob %s for pk=%s", + storage_name, + self.pk, + ) + + @classmethod + def hard_delete_logical(cls, attachment_uid: UUID) -> int: + """Delete every row sharing ``attachment_uid`` and their blobs. + + Returns the number of rows deleted. + """ + with transaction.atomic(): + rows = list( + cls.objects.select_for_update().filter(attachment_uid=attachment_uid) + ) + for row in rows: + row.hard_delete_with_blob() + return len(rows) + + # --- Internal: validate, sniff, hash, sanitise --- + + def clean(self) -> None: + super().clean() + type(self)._check_subclass_configuration() + + def _ingest_file(self, file: UploadedFile | File) -> None: + """Run the validation + sniff + hash pipeline and populate fields. + + After this call, ``self.file``, ``self.filename``, ``self.content_type``, + ``self.byte_size``, and ``self.file_hash`` are set. Caller is + responsible for ``save()``. + """ + cls = type(self) + cls._check_subclass_configuration() + settings_obj = AttachmentsSettings.load() + max_size = cls.effective_max_size() + + # Eager size check: trust the declared size as a fast first gate. + declared_size = getattr(file, "size", None) + if isinstance(declared_size, int): + validate_size(declared_size, max_size) + + # Sniff MIME from the head of the stream. Both UploadedFile and File + # support seek(); refuse the upload if the stream cannot be rewound, + # since we'd otherwise hand a partially-consumed file to storage. + try: + file.seek(0) + except (AttributeError, OSError) as exc: + raise ValidationError( + "Uploaded stream is not seekable; refusing the upload.", + code="attachments_stream_not_seekable", + ) from exc + + sniff = sniff_content_type(file, settings_obj.content_type_sniff_bytes) + validate_content_type(sniff.content_type, cls.allowed_content_types) + + client_declared = getattr(file, "content_type", None) + if client_declared and client_declared != sniff.content_type: + logger.info( + "Attachment client header '%s' disagrees with sniffed '%s' " + "for %s; trusting sniffed value.", + client_declared, + sniff.content_type, + cls.__name__, + ) + + # Streaming hash + authoritative size, with the running counter as a + # second size gate. + digest, byte_count = compute_file_hash( + file, + algorithm=settings_obj.hash_algorithm, + max_size=max_size, + ) + + raw_filename = getattr(file, "name", "") or "file" + safe_name = sanitise_filename(raw_filename) + + self.filename = safe_name + self.content_type = sniff.content_type + self.byte_size = byte_count + self.file_hash = digest + self.hash_algorithm = settings_obj.hash_algorithm + # Re-attach the file handle (rewound by compute_file_hash). Django + # will pass `file.name` to the upload_to callable on save, where + # sanitise_filename runs again — so the storage key stays clean + # regardless of what the client sent. + self.file = file + + +__all__ = ["Attachment", "AttachmentManager"] diff --git a/django/nside_wefa/attachments/serializers.py b/django/nside_wefa/attachments/serializers.py new file mode 100644 index 00000000..7052ca6f --- /dev/null +++ b/django/nside_wefa/attachments/serializers.py @@ -0,0 +1,83 @@ +"""Serializer factory for concrete :class:`Attachment` subclasses. + +Consumer apps rarely need to write a serializer by hand — +:func:`build_attachment_serializer` produces a ``ModelSerializer`` that +exposes the safe, server-controlled fields and accepts only ``file`` (and +any subclass FK fields the consumer chooses to add) on input. +""" + +from __future__ import annotations + +from typing import Iterable, Type + +from drf_spectacular.utils import extend_schema_serializer +from rest_framework import serializers + +from .models import Attachment + + +# Fields that are always exposed on read; never accepted on write. +READ_ONLY_FIELDS: tuple[str, ...] = ( + "id", + "attachment_uid", + "version", + "is_current", + "filename", + "content_type", + "byte_size", + "file_hash", + "hash_algorithm", + "uploaded_by", + "created_at", + "updated_at", +) + + +def build_attachment_serializer( + model_class: Type[Attachment], + *, + extra_read_only: Iterable[str] = (), + extra_writable: Iterable[str] = (), +) -> Type[serializers.ModelSerializer]: + """Return a ``ModelSerializer`` class scoped to ``model_class``. + + :param extra_read_only: Names of subclass-specific fields to expose + read-only (e.g. a ``contract_id`` FK). + :param extra_writable: Names of subclass-specific fields to accept on + input (e.g. a description column the consumer wants editable). + """ + extra_read_only_tuple = tuple(extra_read_only) + extra_writable_tuple = tuple(extra_writable) + + fields_list = list( + dict.fromkeys( + READ_ONLY_FIELDS + ("file",) + extra_read_only_tuple + extra_writable_tuple + ) + ) + read_only_list = list(dict.fromkeys(READ_ONLY_FIELDS + extra_read_only_tuple)) + + Meta = type( + "Meta", + (), + { + "model": model_class, + "fields": fields_list, + "read_only_fields": read_only_list, + "extra_kwargs": { + "file": { + "write_only": True, + "help_text": ( + "Binary upload. Sniffed by python-magic; the client " + "Content-Type header is ignored." + ), + }, + }, + }, + ) + + serializer_cls = type( + f"{model_class.__name__}Serializer", + (serializers.ModelSerializer,), + {"Meta": Meta}, + ) + return extend_schema_serializer(component_name=model_class.__name__)(serializer_cls) diff --git a/django/nside_wefa/attachments/settings.py b/django/nside_wefa/attachments/settings.py new file mode 100644 index 00000000..3c54ba2f --- /dev/null +++ b/django/nside_wefa/attachments/settings.py @@ -0,0 +1,44 @@ +"""Typed accessor for the ``NSIDE_WEFA.ATTACHMENTS`` settings section. + +Reads from :func:`nside_wefa.common.settings.get_section` and applies +documented defaults. All callers in this app should go through +:class:`AttachmentsSettings` rather than touching ``settings.NSIDE_WEFA`` +directly, so defaults stay in one place. +""" + +from dataclasses import dataclass + +from nside_wefa.common.settings import get_section + + +DEFAULT_STORAGE_ALIAS = "default" +DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024 +DEFAULT_UPLOAD_PATH_PREFIX = "attachments/" +DEFAULT_HASH_ALGORITHM = "sha256" +DEFAULT_CONTENT_TYPE_SNIFF_BYTES = 2048 + + +@dataclass(frozen=True) +class AttachmentsSettings: + """Resolved configuration for the attachments app.""" + + storage: str + max_file_size: int | None + upload_path_prefix: str + hash_algorithm: str + content_type_sniff_bytes: int + + @classmethod + def load(cls) -> "AttachmentsSettings": + section = get_section("ATTACHMENTS", default={}) + return cls( + storage=section.get("STORAGE", DEFAULT_STORAGE_ALIAS), + max_file_size=section.get("MAX_FILE_SIZE", DEFAULT_MAX_FILE_SIZE), + upload_path_prefix=section.get( + "UPLOAD_PATH_PREFIX", DEFAULT_UPLOAD_PATH_PREFIX + ), + hash_algorithm=section.get("HASH_ALGORITHM", DEFAULT_HASH_ALGORITHM), + content_type_sniff_bytes=section.get( + "CONTENT_TYPE_SNIFF_BYTES", DEFAULT_CONTENT_TYPE_SNIFF_BYTES + ), + ) diff --git a/django/nside_wefa/attachments/storage.py b/django/nside_wefa/attachments/storage.py new file mode 100644 index 00000000..c7640ebe --- /dev/null +++ b/django/nside_wefa/attachments/storage.py @@ -0,0 +1,23 @@ +"""Storage resolution for the attachments app. + +Looks up a Django 5+ ``STORAGES`` alias and returns the underlying +:class:`~django.core.files.storage.Storage` instance. Subclasses of +:class:`~nside_wefa.attachments.models.Attachment` may override the +``storage_alias`` class attribute to use a non-default backend. +""" + +from typing import Optional + +from django.core.files.storage import Storage, storages + +from .settings import AttachmentsSettings + + +def get_attachment_storage(alias: Optional[str] = None) -> Storage: + """Return the configured storage backend for attachments. + + :param alias: Explicit ``STORAGES`` alias. When ``None`` (the default), + falls back to ``NSIDE_WEFA.ATTACHMENTS.STORAGE``. + """ + resolved_alias = alias or AttachmentsSettings.load().storage + return storages[resolved_alias] diff --git a/django/nside_wefa/attachments/tests/__init__.py b/django/nside_wefa/attachments/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/nside_wefa/attachments/tests/conftest.py b/django/nside_wefa/attachments/tests/conftest.py new file mode 100644 index 00000000..835abea2 --- /dev/null +++ b/django/nside_wefa/attachments/tests/conftest.py @@ -0,0 +1,88 @@ +"""Shared fixtures for the attachments test suite. + +Redirects ``MEDIA_ROOT`` to a per-test temp directory so the suite never +writes to the demo's persistent media folder, and exposes small byte-level +fixtures for each MIME we exercise (PDF, PNG, JPEG). +""" + +from __future__ import annotations + +from typing import Callable + +import pytest +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile + + +# Real magic-byte headers, padded enough to make libmagic happy. +PNG_HEADER = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\rIDATx\x9cc\xfa\xcf\x00\x00\x00\x02\x00\x01\xe2!\xbc\x33" + b"\x00\x00\x00\x00IEND\xaeB`\x82" +) + +JPEG_HEADER = ( + b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + b"\xff\xdb\x00C\x00" + (b"\x08" * 64) + b"\xff\xd9" +) + +PDF_HEADER = ( + b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" + b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + b"2 0 obj\n<< /Type /Pages /Count 0 /Kids [] >>\nendobj\n" + b"xref\n0 3\n0000000000 65535 f \n" + b"0000000015 00000 n \n0000000060 00000 n \n" + b"trailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n110\n%%EOF\n" +) + + +@pytest.fixture(autouse=True) +def isolated_media(tmp_path, settings): + """Point ``MEDIA_ROOT`` at a tmp directory for every test.""" + settings.MEDIA_ROOT = str(tmp_path / "media") + return tmp_path / "media" + + +@pytest.fixture +def png_bytes() -> bytes: + return PNG_HEADER + + +@pytest.fixture +def jpeg_bytes() -> bytes: + return JPEG_HEADER + + +@pytest.fixture +def pdf_bytes() -> bytes: + return PDF_HEADER + + +@pytest.fixture +def upload_file() -> Callable[..., SimpleUploadedFile]: + """Return a builder for ``SimpleUploadedFile`` values.""" + + def _build( + name: str, + content: bytes, + content_type: str = "application/octet-stream", + ) -> SimpleUploadedFile: + return SimpleUploadedFile(name=name, content=content, content_type=content_type) + + return _build + + +@pytest.fixture +def user(db): + return get_user_model().objects.create_user( + username="alice", email="alice@example.com", password="x" + ) + + +@pytest.fixture +def other_user(db): + return get_user_model().objects.create_user( + username="bob", email="bob@example.com", password="x" + ) diff --git a/django/nside_wefa/attachments/tests/settings.py b/django/nside_wefa/attachments/tests/settings.py new file mode 100644 index 00000000..4af0819c --- /dev/null +++ b/django/nside_wefa/attachments/tests/settings.py @@ -0,0 +1,14 @@ +"""Pytest settings module for the attachments test suite. + +Extends ``demo.settings`` with test-only apps so the demo project itself +stays free of test scaffolding. Pointed to by ``pytest.ini`` via +``DJANGO_SETTINGS_MODULE``. +""" + +from demo.settings import * # noqa: F401,F403 +from demo.settings import INSTALLED_APPS as _DEMO_INSTALLED_APPS + +INSTALLED_APPS = [ + *_DEMO_INSTALLED_APPS, + "nside_wefa.attachments.tests.test_app", +] diff --git a/django/nside_wefa/attachments/tests/test_app/__init__.py b/django/nside_wefa/attachments/tests/test_app/__init__.py new file mode 100644 index 00000000..ba8c277f --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_app/__init__.py @@ -0,0 +1,3 @@ +default_app_config = ( + "nside_wefa.attachments.tests.test_app.apps.AttachmentsTestAppConfig" +) diff --git a/django/nside_wefa/attachments/tests/test_app/apps.py b/django/nside_wefa/attachments/tests/test_app/apps.py new file mode 100644 index 00000000..3fb4ba5e --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_app/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class AttachmentsTestAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "nside_wefa.attachments.tests.test_app" + label = "attachments_test_app" + verbose_name = "WeFa Attachments Test App" diff --git a/django/nside_wefa/attachments/tests/test_app/migrations/0001_initial.py b/django/nside_wefa/attachments/tests/test_app/migrations/0001_initial.py new file mode 100644 index 00000000..2dfe991a --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_app/migrations/0001_initial.py @@ -0,0 +1,339 @@ +# Generated by Django 6.0.4 on 2026-04-29 07:55 + +import django.db.models.deletion +import nside_wefa.attachments.models.attachment +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AvatarAttachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "attachment_uid", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + help_text="Stable identifier shared across all versions of one logical attachment.", + ), + ), + ( + "version", + models.PositiveIntegerField( + default=1, + help_text="Monotonically increasing revision counter within an attachment_uid.", + ), + ), + ( + "is_current", + models.BooleanField( + db_index=True, + default=True, + help_text="True for the most recent revision of this attachment_uid.", + ), + ), + ( + "file", + models.FileField( + help_text="The stored file blob.", + max_length=512, + upload_to=nside_wefa.attachments.models.attachment._attachment_upload_to, + ), + ), + ( + "filename", + models.CharField( + help_text="Sanitised original filename, for display.", + max_length=255, + ), + ), + ( + "content_type", + models.CharField( + help_text="MIME type as detected from the file bytes (not the client header).", + max_length=128, + ), + ), + ( + "byte_size", + models.PositiveBigIntegerField( + help_text="Verified size in bytes after streaming." + ), + ), + ( + "file_hash", + models.CharField( + db_index=True, + help_text="Hex digest computed by streaming the file through `hash_algorithm`.", + max_length=128, + ), + ), + ( + "hash_algorithm", + models.CharField( + default="sha256", + help_text="Algorithm used for `file_hash`.", + max_length=32, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "owner", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="test_avatar", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "uploaded_by", + models.ForeignKey( + blank=True, + help_text="The user who performed the upload, when known.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at", "-id"], + "abstract": False, + }, + ), + migrations.CreateModel( + name="MultiImageAttachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "attachment_uid", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + help_text="Stable identifier shared across all versions of one logical attachment.", + ), + ), + ( + "version", + models.PositiveIntegerField( + default=1, + help_text="Monotonically increasing revision counter within an attachment_uid.", + ), + ), + ( + "is_current", + models.BooleanField( + db_index=True, + default=True, + help_text="True for the most recent revision of this attachment_uid.", + ), + ), + ( + "file", + models.FileField( + help_text="The stored file blob.", + max_length=512, + upload_to=nside_wefa.attachments.models.attachment._attachment_upload_to, + ), + ), + ( + "filename", + models.CharField( + help_text="Sanitised original filename, for display.", + max_length=255, + ), + ), + ( + "content_type", + models.CharField( + help_text="MIME type as detected from the file bytes (not the client header).", + max_length=128, + ), + ), + ( + "byte_size", + models.PositiveBigIntegerField( + help_text="Verified size in bytes after streaming." + ), + ), + ( + "file_hash", + models.CharField( + db_index=True, + help_text="Hex digest computed by streaming the file through `hash_algorithm`.", + max_length=128, + ), + ), + ( + "hash_algorithm", + models.CharField( + default="sha256", + help_text="Algorithm used for `file_hash`.", + max_length=32, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "uploaded_by", + models.ForeignKey( + blank=True, + help_text="The user who performed the upload, when known.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at", "-id"], + "abstract": False, + }, + ), + migrations.CreateModel( + name="SingletonPdfAttachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "attachment_uid", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + help_text="Stable identifier shared across all versions of one logical attachment.", + ), + ), + ( + "version", + models.PositiveIntegerField( + default=1, + help_text="Monotonically increasing revision counter within an attachment_uid.", + ), + ), + ( + "is_current", + models.BooleanField( + db_index=True, + default=True, + help_text="True for the most recent revision of this attachment_uid.", + ), + ), + ( + "file", + models.FileField( + help_text="The stored file blob.", + max_length=512, + upload_to=nside_wefa.attachments.models.attachment._attachment_upload_to, + ), + ), + ( + "filename", + models.CharField( + help_text="Sanitised original filename, for display.", + max_length=255, + ), + ), + ( + "content_type", + models.CharField( + help_text="MIME type as detected from the file bytes (not the client header).", + max_length=128, + ), + ), + ( + "byte_size", + models.PositiveBigIntegerField( + help_text="Verified size in bytes after streaming." + ), + ), + ( + "file_hash", + models.CharField( + db_index=True, + help_text="Hex digest computed by streaming the file through `hash_algorithm`.", + max_length=128, + ), + ), + ( + "hash_algorithm", + models.CharField( + default="sha256", + help_text="Algorithm used for `file_hash`.", + max_length=32, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "uploaded_by", + models.ForeignKey( + blank=True, + help_text="The user who performed the upload, when known.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at", "-id"], + "abstract": False, + }, + ), + ] diff --git a/django/nside_wefa/attachments/tests/test_app/migrations/__init__.py b/django/nside_wefa/attachments/tests/test_app/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/nside_wefa/attachments/tests/test_app/models.py b/django/nside_wefa/attachments/tests/test_app/models.py new file mode 100644 index 00000000..300dd1f9 --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_app/models.py @@ -0,0 +1,58 @@ +"""Concrete :class:`Attachment` subclasses used by the attachments test suite. + +Three flavours, each exercising a different configuration: + +- :class:`SingletonPdfAttachment` — singleton + versioned (contract-style). +- :class:`MultiImageAttachment` — multi + versioned (issue-style). +- :class:`AvatarAttachment` — singleton + non-versioned (avatar-style). +""" + +from django.conf import settings +from django.db import models + +from nside_wefa.attachments.models import Attachment + + +class SingletonPdfAttachment(Attachment): + """Singleton, versioned PDF — exercises the contract path.""" + + versioning_enabled = True + allowed_content_types = ["application/pdf"] + max_size = 5 * 1024 * 1024 + upload_path_prefix = "test-singleton/" + + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="+", + ) + + +class MultiImageAttachment(Attachment): + """Multi, versioned image — exercises the issue path.""" + + versioning_enabled = True + allowed_content_types = ["image/png", "image/jpeg"] + max_size = 2 * 1024 * 1024 + upload_path_prefix = "test-multi/" + + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="+", + ) + + +class AvatarAttachment(Attachment): + """Singleton, non-versioned image — exercises replace-in-place.""" + + versioning_enabled = False + allowed_content_types = ["image/png", "image/jpeg"] + max_size = 2 * 1024 * 1024 + upload_path_prefix = "test-avatars/" + + owner = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="test_avatar", + ) diff --git a/django/nside_wefa/attachments/tests/test_app/urls.py b/django/nside_wefa/attachments/tests/test_app/urls.py new file mode 100644 index 00000000..84b14919 --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_app/urls.py @@ -0,0 +1,66 @@ +"""URL set used by the attachments test suite. + +Exercises both modes (singleton + multi) and both versioning settings via +real concrete subclasses. +""" + +from rest_framework.permissions import IsAuthenticated + +from nside_wefa.attachments.urls import register_attachment_endpoints + +from .models import ( + AvatarAttachment, + MultiImageAttachment, + SingletonPdfAttachment, +) + + +def _scope_to_owner(request, **kwargs): + return AvatarAttachment.objects.filter(owner=request.user) + + +def _bind_avatar_owner(request, instance, **kwargs): + instance.owner = request.user + + +def _scope_pdf_to_owner(request, **kwargs): + return SingletonPdfAttachment.objects.filter(owner=request.user) + + +def _bind_pdf_owner(request, instance, **kwargs): + instance.owner = request.user + + +def _scope_image_to_owner(request, **kwargs): + return MultiImageAttachment.objects.filter(owner=request.user) + + +def _bind_image_owner(request, instance, **kwargs): + instance.owner = request.user + + +urlpatterns = [ + *register_attachment_endpoints( + AvatarAttachment, + prefix="avatar", + singleton=True, + permissions=[IsAuthenticated], + queryset=_scope_to_owner, + on_create=_bind_avatar_owner, + ), + *register_attachment_endpoints( + SingletonPdfAttachment, + prefix="contract", + singleton=True, + permissions=[IsAuthenticated], + queryset=_scope_pdf_to_owner, + on_create=_bind_pdf_owner, + ), + *register_attachment_endpoints( + MultiImageAttachment, + prefix="images", + permissions=[IsAuthenticated], + queryset=_scope_image_to_owner, + on_create=_bind_image_owner, + ), +] diff --git a/django/nside_wefa/attachments/tests/test_checks.py b/django/nside_wefa/attachments/tests/test_checks.py new file mode 100644 index 00000000..cef69212 --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_checks.py @@ -0,0 +1,54 @@ +"""Tests for the attachments system checks.""" + +from __future__ import annotations + +from django.test import override_settings + +from nside_wefa.attachments.checks import ( + attachments_apps_dependencies_check, + attachments_settings_check, +) + + +class TestAttachmentsSettingsCheck: + def test_default_settings_pass(self): + # The demo settings declare a valid ATTACHMENTS section. + assert attachments_settings_check(None) == [] + + @override_settings(NSIDE_WEFA={"ATTACHMENTS": {"STORAGE": "missing-alias"}}) + def test_unknown_storage_alias_errors(self): + errors = attachments_settings_check(None) + assert any("missing-alias" in e.msg for e in errors) + + @override_settings(NSIDE_WEFA={"ATTACHMENTS": {"HASH_ALGORITHM": "rot13"}}) + def test_unknown_hash_algorithm_errors(self): + errors = attachments_settings_check(None) + assert any("HASH_ALGORITHM" in e.msg for e in errors) + + @override_settings(NSIDE_WEFA={"ATTACHMENTS": {"CONTENT_TYPE_SNIFF_BYTES": 0}}) + def test_non_positive_sniff_bytes_errors(self): + errors = attachments_settings_check(None) + assert any("CONTENT_TYPE_SNIFF_BYTES" in e.msg for e in errors) + + @override_settings(NSIDE_WEFA={"ATTACHMENTS": {"MAX_FILE_SIZE": -1}}) + def test_non_positive_max_size_errors(self): + errors = attachments_settings_check(None) + assert any("MAX_FILE_SIZE" in e.msg for e in errors) + + +class TestAppsDependencyCheck: + def test_correct_order_passes(self): + # demo/settings.py installs common before attachments. + assert attachments_apps_dependencies_check(None) == [] + + @override_settings( + INSTALLED_APPS=[ + "django.contrib.contenttypes", + "django.contrib.auth", + "nside_wefa.attachments", + "nside_wefa.common", + ] + ) + def test_reversed_order_errors(self): + errors = attachments_apps_dependencies_check(None) + assert errors diff --git a/django/nside_wefa/attachments/tests/test_models.py b/django/nside_wefa/attachments/tests/test_models.py new file mode 100644 index 00000000..95735ac6 --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_models.py @@ -0,0 +1,228 @@ +"""Model-level tests: ingest pipeline, versioning, manager helpers.""" + +from __future__ import annotations + +import hashlib + +import pytest +from django.core.exceptions import ImproperlyConfigured, ValidationError + +from nside_wefa.attachments.tests.test_app.models import ( + AvatarAttachment, + SingletonPdfAttachment, +) + + +pytestmark = pytest.mark.django_db + + +class TestUpload: + def test_first_upload_populates_all_derived_fields( + self, user, pdf_bytes, upload_file + ): + instance = SingletonPdfAttachment.upload( + file=upload_file("contract.pdf", pdf_bytes, "application/pdf"), + uploaded_by=user, + owner=user, + ) + assert instance.version == 1 + assert instance.is_current is True + assert instance.attachment_uid is not None + assert instance.filename == "contract.pdf" + assert instance.content_type == "application/pdf" + assert instance.byte_size == len(pdf_bytes) + assert instance.file_hash == hashlib.sha256(pdf_bytes).hexdigest() + assert instance.hash_algorithm == "sha256" + assert instance.uploaded_by == user + assert instance.file.name.startswith("test-singleton/") + assert str(instance.attachment_uid) in instance.file.name + assert "/1/" in instance.file.name + + def test_upload_rejects_unwhitelisted_mime(self, user, png_bytes, upload_file): + with pytest.raises(ValidationError): + SingletonPdfAttachment.upload( + file=upload_file("img.png", png_bytes, "image/png"), + owner=user, + ) + + def test_upload_ignores_lying_client_header(self, user, pdf_bytes, upload_file): + """Client claims `image/png` but bytes are PDF — sniffer wins, accepted.""" + instance = SingletonPdfAttachment.upload( + file=upload_file("evil.png", pdf_bytes, "image/png"), + owner=user, + ) + assert instance.content_type == "application/pdf" + + def test_upload_rejects_oversize(self, user, upload_file): + # max_size on SingletonPdfAttachment = 5MB + big = b"%PDF-1.4\n" + b"\x00" * (6 * 1024 * 1024) + with pytest.raises(ValidationError): + SingletonPdfAttachment.upload( + file=upload_file("big.pdf", big, "application/pdf"), + owner=user, + ) + + def test_upload_sanitises_filename(self, user, pdf_bytes, upload_file): + instance = SingletonPdfAttachment.upload( + file=upload_file("../../etc/passwd.pdf", pdf_bytes, "application/pdf"), + owner=user, + ) + assert instance.filename == "passwd.pdf" + assert ".." not in instance.file.name + + +class TestAddVersion: + def test_add_version_bumps_and_flips_prior(self, user, pdf_bytes, upload_file): + v1 = SingletonPdfAttachment.upload( + file=upload_file("v1.pdf", pdf_bytes, "application/pdf"), + owner=user, + ) + v2 = SingletonPdfAttachment.add_version( + file=upload_file("v2.pdf", pdf_bytes + b"x", "application/pdf"), + parent=v1, + ) + v1.refresh_from_db() + assert v2.version == 2 + assert v2.is_current is True + assert v2.attachment_uid == v1.attachment_uid + assert v1.is_current is False + assert v2.file_hash != v1.file_hash + + def test_add_version_by_uid_only(self, user, pdf_bytes, upload_file): + v1 = SingletonPdfAttachment.upload( + file=upload_file("v1.pdf", pdf_bytes, "application/pdf"), + owner=user, + ) + v2 = SingletonPdfAttachment.add_version( + file=upload_file("v2.pdf", pdf_bytes + b"x", "application/pdf"), + attachment_uid=v1.attachment_uid, + ) + assert v2.version == 2 + + def test_add_version_requires_parent_or_uid(self, user, pdf_bytes, upload_file): + with pytest.raises(ValueError): + SingletonPdfAttachment.add_version( + file=upload_file("v.pdf", pdf_bytes, "application/pdf"), + ) + + def test_add_version_disallowed_when_versioning_disabled( + self, user, png_bytes, upload_file + ): + avatar = AvatarAttachment.upload( + file=upload_file("a.png", png_bytes, "image/png"), + owner=user, + ) + with pytest.raises(NotImplementedError): + AvatarAttachment.add_version( + file=upload_file("a2.png", png_bytes + b"x", "image/png"), + parent=avatar, + ) + + +class TestReplaceInPlace: + def test_replace_in_place_overwrites_fields(self, user, png_bytes, upload_file): + v1 = AvatarAttachment.upload( + file=upload_file("a.png", png_bytes, "image/png"), + owner=user, + ) + original_hash = v1.file_hash + v1.replace_in_place( + file=upload_file("a2.png", png_bytes + b"\x00", "image/png"), + ) + v1.refresh_from_db() + assert v1.file_hash != original_hash + assert v1.version == 1 + assert v1.is_current is True + # Only one row in the table for this scope. + assert AvatarAttachment.objects.filter(owner=user).count() == 1 + + def test_replace_in_place_disallowed_when_versioning_enabled( + self, user, pdf_bytes, upload_file + ): + v1 = SingletonPdfAttachment.upload( + file=upload_file("v.pdf", pdf_bytes, "application/pdf"), + owner=user, + ) + with pytest.raises(NotImplementedError): + v1.replace_in_place( + file=upload_file("v2.pdf", pdf_bytes, "application/pdf") + ) + + +class TestManager: + def test_current_returns_only_current_rows(self, user, pdf_bytes, upload_file): + v1 = SingletonPdfAttachment.upload( + file=upload_file("v1.pdf", pdf_bytes, "application/pdf"), + owner=user, + ) + SingletonPdfAttachment.add_version( + file=upload_file("v2.pdf", pdf_bytes + b"x", "application/pdf"), + parent=v1, + ) + currents = list(SingletonPdfAttachment.objects.current()) + assert len(currents) == 1 + assert currents[0].version == 2 + + def test_history_orders_by_version(self, user, pdf_bytes, upload_file): + v1 = SingletonPdfAttachment.upload( + file=upload_file("v1.pdf", pdf_bytes, "application/pdf"), + owner=user, + ) + SingletonPdfAttachment.add_version( + file=upload_file("v2.pdf", pdf_bytes + b"x", "application/pdf"), + parent=v1, + ) + SingletonPdfAttachment.add_version( + file=upload_file("v3.pdf", pdf_bytes + b"xx", "application/pdf"), + parent=v1, + ) + history = list(SingletonPdfAttachment.objects.history(v1.attachment_uid)) + assert [row.version for row in history] == [1, 2, 3] + + +class TestSubclassConfigurationCheck: + def test_missing_whitelist_raises_improperly_configured( + self, user, pdf_bytes, upload_file + ): + original = SingletonPdfAttachment.allowed_content_types + SingletonPdfAttachment.allowed_content_types = [] + try: + with pytest.raises(ImproperlyConfigured): + SingletonPdfAttachment.upload( + file=upload_file("v.pdf", pdf_bytes, "application/pdf"), + owner=user, + ) + finally: + SingletonPdfAttachment.allowed_content_types = original + + +class TestHardDelete: + def test_hard_delete_removes_blob(self, user, pdf_bytes, upload_file): + v1 = SingletonPdfAttachment.upload( + file=upload_file("v.pdf", pdf_bytes, "application/pdf"), + owner=user, + ) + storage_name = v1.file.name + storage = v1.file.storage + v1.hard_delete_with_blob() + assert not storage.exists(storage_name) + + def test_hard_delete_logical_removes_all_versions( + self, user, pdf_bytes, upload_file + ): + v1 = SingletonPdfAttachment.upload( + file=upload_file("v.pdf", pdf_bytes, "application/pdf"), + owner=user, + ) + SingletonPdfAttachment.add_version( + file=upload_file("v2.pdf", pdf_bytes + b"x", "application/pdf"), + parent=v1, + ) + deleted = SingletonPdfAttachment.hard_delete_logical(v1.attachment_uid) + assert deleted == 2 + assert ( + SingletonPdfAttachment.objects.filter( + attachment_uid=v1.attachment_uid + ).count() + == 0 + ) diff --git a/django/nside_wefa/attachments/tests/test_storage.py b/django/nside_wefa/attachments/tests/test_storage.py new file mode 100644 index 00000000..f6256917 --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_storage.py @@ -0,0 +1,38 @@ +"""Storage alias resolution tests.""" + +from __future__ import annotations + +import pytest +from django.core.files.storage import FileSystemStorage + +from nside_wefa.attachments.storage import get_attachment_storage + + +def test_default_alias_resolves(settings): + storage = get_attachment_storage(None) + assert isinstance(storage, FileSystemStorage) + + +def test_explicit_alias_overrides_default(settings, tmp_path): + settings.STORAGES = { + **settings.STORAGES, + "alt": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + "OPTIONS": {"location": str(tmp_path / "alt")}, + }, + } + # Reload the storages registry so the new alias is visible. + from django.core.files.storage import storages + + storages._storages.clear() + storage = get_attachment_storage("alt") + assert isinstance(storage, FileSystemStorage) + assert storage.location.endswith("/alt") + + +def test_unknown_alias_raises(settings): + from django.core.files.storage import storages + + storages._storages.clear() + with pytest.raises(Exception): + get_attachment_storage("does-not-exist") diff --git a/django/nside_wefa/attachments/tests/test_validators.py b/django/nside_wefa/attachments/tests/test_validators.py new file mode 100644 index 00000000..500697df --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_validators.py @@ -0,0 +1,119 @@ +"""Validator-level tests: MIME sniffing, whitelist, size, hash, sanitisation.""" + +from __future__ import annotations + +import hashlib +import io + +import pytest +from django.core.exceptions import ValidationError + +from nside_wefa.attachments.validators import ( + compute_file_hash, + sanitise_filename, + sniff_content_type, + validate_content_type, + validate_size, +) + + +class TestSniffContentType: + def test_png_is_detected(self, png_bytes): + stream = io.BytesIO(png_bytes) + result = sniff_content_type(stream, sniff_bytes=2048) + assert result.content_type == "image/png" + + def test_pdf_is_detected(self, pdf_bytes): + stream = io.BytesIO(pdf_bytes) + result = sniff_content_type(stream, sniff_bytes=2048) + assert result.content_type == "application/pdf" + + def test_stream_is_rewound(self, png_bytes): + stream = io.BytesIO(png_bytes) + sniff_content_type(stream, sniff_bytes=2048) + assert stream.tell() == 0 + + +class TestValidateContentType: + def test_passes_when_in_whitelist(self): + validate_content_type("image/png", ["image/png", "image/jpeg"]) + + def test_rejects_when_outside_whitelist(self): + with pytest.raises(ValidationError) as exc_info: + validate_content_type("application/x-msdownload", ["application/pdf"]) + assert exc_info.value.code == "attachments_content_type_not_allowed" + + def test_rejects_when_whitelist_is_empty(self): + with pytest.raises(ValidationError) as exc_info: + validate_content_type("image/png", []) + assert exc_info.value.code == "attachments_no_whitelist" + + def test_no_partial_or_wildcard_match(self): + """`image/*` is not a wildcard — explicit MIMEs only.""" + with pytest.raises(ValidationError): + validate_content_type("image/png", ["image/*"]) + + +class TestValidateSize: + def test_passes_under_limit(self): + validate_size(1024, 2048) + + def test_passes_when_no_limit(self): + validate_size(10**9, None) + + def test_rejects_over_limit(self): + with pytest.raises(ValidationError) as exc_info: + validate_size(2049, 2048) + assert exc_info.value.code == "attachments_size_exceeded" + + +class TestComputeFileHash: + def test_hash_matches_hashlib_sha256(self, png_bytes): + stream = io.BytesIO(png_bytes) + digest, count = compute_file_hash(stream, algorithm="sha256") + assert digest == hashlib.sha256(png_bytes).hexdigest() + assert count == len(png_bytes) + + def test_streaming_size_check_rejects_oversize(self): + stream = io.BytesIO(b"x" * 200) + with pytest.raises(ValidationError) as exc_info: + compute_file_hash(stream, algorithm="sha256", max_size=100) + assert exc_info.value.code == "attachments_size_exceeded" + + def test_unknown_algorithm_raises_value_error(self): + with pytest.raises(ValueError): + compute_file_hash(io.BytesIO(b"x"), algorithm="not-a-real-algo") + + def test_one_byte_change_changes_hash(self): + a, _ = compute_file_hash(io.BytesIO(b"hello"), algorithm="sha256") + b, _ = compute_file_hash(io.BytesIO(b"hellp"), algorithm="sha256") + assert a != b + + def test_stream_is_rewound(self, png_bytes): + stream = io.BytesIO(png_bytes) + compute_file_hash(stream, algorithm="sha256") + assert stream.tell() == 0 + + +class TestSanitiseFilename: + @pytest.mark.parametrize( + "raw,expected", + [ + ("", "file"), + ("normal.pdf", "normal.pdf"), + ("../../etc/passwd", "passwd"), + ("\\\\share\\evil.exe", "evil.exe"), + ("a/b/c.txt", "c.txt"), + ("foo\x00bar.png", "foobar.png"), + (".", "file"), + ("..", "file"), + ], + ) + def test_path_traversal_and_control_chars(self, raw, expected): + assert sanitise_filename(raw) == expected + + def test_clamps_long_names_preserving_extension(self): + long = "x" * 500 + ".png" + out = sanitise_filename(long) + assert out.endswith(".png") + assert len(out) <= 200 diff --git a/django/nside_wefa/attachments/tests/test_views/__init__.py b/django/nside_wefa/attachments/tests/test_views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/nside_wefa/attachments/tests/test_views/test_multi.py b/django/nside_wefa/attachments/tests/test_views/test_multi.py new file mode 100644 index 00000000..ddb86fbb --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_views/test_multi.py @@ -0,0 +1,128 @@ +"""HTTP-level tests for multi-mode endpoints.""" + +from __future__ import annotations + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from nside_wefa.attachments.tests.test_app.models import MultiImageAttachment + + +pytestmark = [ + pytest.mark.django_db, + pytest.mark.urls("nside_wefa.attachments.tests.test_app.urls"), +] + + +@pytest.fixture +def client(user): + api = APIClient() + api.force_authenticate(user=user) + return api + + +def test_collection_starts_empty(client): + response = client.get("/images/") + assert response.status_code == status.HTTP_200_OK + assert response.data == [] + + +def test_post_creates_new_logical_attachment(client, user, png_bytes, upload_file): + r1 = client.post( + "/images/", + {"file": upload_file("a.png", png_bytes, "image/png")}, + format="multipart", + ) + r2 = client.post( + "/images/", + {"file": upload_file("b.png", png_bytes + b"\x00", "image/png")}, + format="multipart", + ) + assert r1.status_code == status.HTTP_201_CREATED + assert r2.status_code == status.HTTP_201_CREATED + assert r1.data["attachment_uid"] != r2.data["attachment_uid"] + assert r1.data["version"] == 1 and r2.data["version"] == 1 + + listing = client.get("/images/").data + assert len(listing) == 2 + + +def test_post_versions_bumps_specific_logical(client, user, png_bytes, upload_file): + create = client.post( + "/images/", + {"file": upload_file("a.png", png_bytes, "image/png")}, + format="multipart", + ) + pk = create.data["id"] + bump = client.post( + f"/images/{pk}/versions/", + {"file": upload_file("a2.png", png_bytes + b"\x00", "image/png")}, + format="multipart", + ) + assert bump.status_code == status.HTTP_201_CREATED + assert bump.data["version"] == 2 + assert bump.data["attachment_uid"] == create.data["attachment_uid"] + + +def test_versions_endpoint_returns_history(client, user, png_bytes, upload_file): + create = client.post( + "/images/", + {"file": upload_file("a.png", png_bytes, "image/png")}, + format="multipart", + ) + pk = create.data["id"] + client.post( + f"/images/{pk}/versions/", + {"file": upload_file("a2.png", png_bytes + b"\x00", "image/png")}, + format="multipart", + ) + response = client.get(f"/images/{pk}/versions/") + assert response.status_code == status.HTTP_200_OK + assert [r["version"] for r in response.data] == [1, 2] + + +def test_detail_endpoint_returns_specific_row(client, user, png_bytes, upload_file): + create = client.post( + "/images/", + {"file": upload_file("a.png", png_bytes, "image/png")}, + format="multipart", + ) + pk = create.data["id"] + response = client.get(f"/images/{pk}/") + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == pk + + +def test_delete_removes_all_versions(client, user, png_bytes, upload_file): + create = client.post( + "/images/", + {"file": upload_file("a.png", png_bytes, "image/png")}, + format="multipart", + ) + pk = create.data["id"] + client.post( + f"/images/{pk}/versions/", + {"file": upload_file("a2.png", png_bytes + b"\x00", "image/png")}, + format="multipart", + ) + response = client.delete(f"/images/{pk}/") + assert response.status_code == status.HTTP_204_NO_CONTENT + assert MultiImageAttachment.objects.filter(owner=user).count() == 0 + + +def test_user_isolation(client, user, other_user, png_bytes, upload_file): + create = client.post( + "/images/", + {"file": upload_file("a.png", png_bytes, "image/png")}, + format="multipart", + ) + pk = create.data["id"] + + bob = APIClient() + bob.force_authenticate(user=other_user) + assert bob.get("/images/").data == [] + assert ( + bob.get(f"/images/{pk}/").status_code + == status.HTTP_404_NOT_FOUND + ) diff --git a/django/nside_wefa/attachments/tests/test_views/test_replace_in_place.py b/django/nside_wefa/attachments/tests/test_views/test_replace_in_place.py new file mode 100644 index 00000000..77da7549 --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_views/test_replace_in_place.py @@ -0,0 +1,81 @@ +"""HTTP-level tests for singleton-mode endpoints when ``versioning_enabled=False``. + +The avatar test fixture exercises this branch. +""" + +from __future__ import annotations + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from nside_wefa.attachments.tests.test_app.models import AvatarAttachment + + +pytestmark = [ + pytest.mark.django_db, + pytest.mark.urls("nside_wefa.attachments.tests.test_app.urls"), +] + + +@pytest.fixture +def client(user): + api = APIClient() + api.force_authenticate(user=user) + return api + + +def test_post_creates_avatar(client, user, png_bytes, upload_file): + response = client.post( + "/avatar/", + {"file": upload_file("a.png", png_bytes, "image/png")}, + format="multipart", + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.data["version"] == 1 + assert AvatarAttachment.objects.filter(owner=user).count() == 1 + + +def test_successive_post_replaces_in_place(client, user, png_bytes, upload_file): + client.post( + "/avatar/", + {"file": upload_file("a1.png", png_bytes, "image/png")}, + format="multipart", + ) + response = client.post( + "/avatar/", + {"file": upload_file("a2.png", png_bytes + b"\x00", "image/png")}, + format="multipart", + ) + assert response.status_code in { + status.HTTP_200_OK, + status.HTTP_201_CREATED, + } + # Single row regardless of how many uploads happened. + assert AvatarAttachment.objects.filter(owner=user).count() == 1 + + +def test_versions_endpoint_is_not_registered(client): + """When versioning_enabled=False the URL set omits /versions/.""" + response = client.get("/avatar/versions/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_get_returns_current(client, user, png_bytes, upload_file): + client.post( + "/avatar/", + {"file": upload_file("a.png", png_bytes, "image/png")}, + format="multipart", + ) + response = client.get("/avatar/") + assert response.status_code == status.HTTP_200_OK + assert response.data["content_type"] == "image/png" + + +def test_pdf_rejected_for_image_avatar(client, pdf_bytes, upload_file): + response = client.post( + "/avatar/", + {"file": upload_file("a.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/django/nside_wefa/attachments/tests/test_views/test_singleton.py b/django/nside_wefa/attachments/tests/test_views/test_singleton.py new file mode 100644 index 00000000..7553a9a6 --- /dev/null +++ b/django/nside_wefa/attachments/tests/test_views/test_singleton.py @@ -0,0 +1,164 @@ +"""HTTP-level tests for singleton-mode endpoints (versioned).""" + +from __future__ import annotations + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from nside_wefa.attachments.tests.test_app.models import SingletonPdfAttachment + + +pytestmark = [ + pytest.mark.django_db, + pytest.mark.urls("nside_wefa.attachments.tests.test_app.urls"), +] + + +@pytest.fixture +def client(user): + api = APIClient() + api.force_authenticate(user=user) + return api + + +def test_get_returns_404_when_no_attachment(client): + response = client.get("/contract/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_post_creates_v1(client, user, pdf_bytes, upload_file): + response = client.post( + "/contract/", + {"file": upload_file("contract.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.data["version"] == 1 + assert response.data["is_current"] is True + assert response.data["content_type"] == "application/pdf" + + +def test_successive_post_bumps_version(client, user, pdf_bytes, upload_file): + client.post( + "/contract/", + {"file": upload_file("v1.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + response = client.post( + "/contract/", + {"file": upload_file("v2.pdf", pdf_bytes + b"\x00", "application/pdf")}, + format="multipart", + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["version"] == 2 + assert response.data["is_current"] is True + + rows = list(SingletonPdfAttachment.objects.filter(owner=user).order_by("version")) + assert [r.version for r in rows] == [1, 2] + assert rows[0].is_current is False + assert rows[1].is_current is True + + +def test_versions_endpoint_returns_history(client, user, pdf_bytes, upload_file): + for i in range(3): + client.post( + "/contract/", + { + "file": upload_file( + f"v{i}.pdf", pdf_bytes + bytes([i]), "application/pdf" + ) + }, + format="multipart", + ) + response = client.get("/contract/versions/") + assert response.status_code == status.HTTP_200_OK + versions = [row["version"] for row in response.data] + assert versions == [1, 2, 3] + + +def test_version_detail_endpoint(client, user, pdf_bytes, upload_file): + client.post( + "/contract/", + {"file": upload_file("v1.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + client.post( + "/contract/", + {"file": upload_file("v2.pdf", pdf_bytes + b"x", "application/pdf")}, + format="multipart", + ) + response = client.get("/contract/versions/1/") + assert response.status_code == status.HTTP_200_OK + assert response.data["version"] == 1 + + +def test_download_streams_current_bytes(client, user, pdf_bytes, upload_file): + client.post( + "/contract/", + {"file": upload_file("v1.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + response = client.get("/contract/download/") + assert response.status_code == status.HTTP_200_OK + assert response["Content-Type"].startswith("application/pdf") + assert "attachment" in response["Content-Disposition"] + body = b"".join(response.streaming_content) + assert body == pdf_bytes + + +def test_delete_removes_all_versions(client, user, pdf_bytes, upload_file): + for i in range(2): + client.post( + "/contract/", + { + "file": upload_file( + f"v{i}.pdf", pdf_bytes + bytes([i]), "application/pdf" + ) + }, + format="multipart", + ) + response = client.delete("/contract/") + assert response.status_code == status.HTTP_204_NO_CONTENT + assert SingletonPdfAttachment.objects.filter(owner=user).count() == 0 + + +def test_post_without_file_returns_400(client): + response = client.post("/contract/", {}, format="multipart") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_unauthenticated_request_blocked(pdf_bytes, upload_file): + api = APIClient() + response = api.post( + "/contract/", + {"file": upload_file("v.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + assert response.status_code in { + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + } + + +def test_user_isolation(client, user, other_user, pdf_bytes, upload_file): + """Alice's contract is not visible to Bob via the queryset scope.""" + client.post( + "/contract/", + {"file": upload_file("v.pdf", pdf_bytes, "application/pdf")}, + format="multipart", + ) + + bob_client = APIClient() + bob_client.force_authenticate(user=other_user) + response = bob_client.get("/contract/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_unwhitelisted_mime_rejected(client, png_bytes, upload_file): + response = client.post( + "/contract/", + {"file": upload_file("evil.pdf", png_bytes, "image/png")}, + format="multipart", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/django/nside_wefa/attachments/urls.py b/django/nside_wefa/attachments/urls.py new file mode 100644 index 00000000..52199cd3 --- /dev/null +++ b/django/nside_wefa/attachments/urls.py @@ -0,0 +1,129 @@ +"""Public URL helper for attachment subclasses. + +Consumers call :func:`register_attachment_endpoints` from their app's +``urls.py`` to wire a generic CRUD surface over a concrete +:class:`Attachment` subclass. +""" + +from __future__ import annotations + +from typing import Any, Callable, Iterable, Optional, Type + +from django.urls import path +from rest_framework.permissions import BasePermission + +from .models import Attachment +from .views import build_views + + +def _normalise_prefix(prefix: str) -> str: + """Strip leading and trailing slashes for predictable concatenation.""" + return prefix.strip("/") + + +def register_attachment_endpoints( + model_class: Type[Attachment], + *, + prefix: str, + permissions: Iterable[Type[BasePermission]] = (), + queryset: Optional[Callable[..., Any]] = None, + on_create: Optional[Callable[..., None]] = None, + serializer_class=None, + singleton: bool = False, +) -> list: + """Return URL patterns implementing the attachment CRUD surface. + + :param model_class: A concrete subclass of :class:`Attachment`. + :param prefix: URL prefix relative to the app's ``include()``. May + contain Django URL converters (e.g. + ``"contracts//attachment"``). + :param permissions: DRF permission classes applied to every view. + :param queryset: Optional callable that scopes the base queryset to + the requesting user / parent. Receives ``(request, **url_kwargs)``. + :param on_create: Optional callable invoked on POST before the file + ingest pipeline runs. Receives ``(request, instance, **url_kwargs)`` + and may set FK fields on ``instance`` in place. + :param serializer_class: Override the default + :func:`build_attachment_serializer` output. + :param singleton: When ``True``, generate the singleton URL set + (no ```` segments). Otherwise, generate the multi URL set. + """ + base = _normalise_prefix(prefix) + suffix = "/" if base else "" + base_route = f"{base}{suffix}" + + views = build_views( + model_class, + permissions=permissions, + queryset=queryset, + on_create=on_create, + serializer_class=serializer_class, + singleton=singleton, + ) + + name_root = model_class.__name__.lower() + + patterns: list = [] + + if singleton: + patterns.append( + path( + base_route, + views["object"].as_view(), + name=f"{name_root}-object", + ) + ) + if "versions" in views: + patterns.append( + path( + f"{base}/versions/", + views["versions"].as_view(), + name=f"{name_root}-versions", + ) + ) + if "version_detail" in views: + patterns.append( + path( + f"{base}/versions//", + views["version_detail"].as_view(), + name=f"{name_root}-version-detail", + ) + ) + patterns.append( + path( + f"{base}/download/", + views["download"].as_view(), + name=f"{name_root}-download", + ) + ) + return patterns + + # Multi mode + patterns.extend( + [ + path( + base_route, + views["collection"].as_view(), + name=f"{name_root}-list", + ), + path( + f"{base}//", + views["detail"].as_view(), + name=f"{name_root}-detail", + ), + path( + f"{base}//versions/", + views["versions"].as_view(), + name=f"{name_root}-versions", + ), + path( + f"{base}//download/", + views["download"].as_view(), + name=f"{name_root}-download", + ), + ] + ) + return patterns + + +__all__ = ["register_attachment_endpoints"] diff --git a/django/nside_wefa/attachments/validators.py b/django/nside_wefa/attachments/validators.py new file mode 100644 index 00000000..08eb3ba7 --- /dev/null +++ b/django/nside_wefa/attachments/validators.py @@ -0,0 +1,178 @@ +"""File validation, MIME sniffing, hashing, and filename sanitisation. + +This module is the security perimeter of the attachments app. All upload +inputs flow through it before any storage write happens. + +Design choices: + +- **Whitelist-only content types.** Each subclass declares + ``allowed_content_types`` and uploads whose sniffed MIME is not in the + list are rejected. There is no blacklist. +- **MIME comes from the file bytes**, not the client header. python-magic + reads the first few KB and returns the actual type. +- **Size is enforced twice**: once eagerly via ``UploadedFile.size`` and + again as a streaming counter while the hash is computed, so a hostile + client lying about size still hits a ceiling. +- **Filename is sanitised** but the storage key is derived from the + attachment uid + version, so user-controlled values never become + storage paths. +""" + +from __future__ import annotations + +import hashlib +import logging +import re +import unicodedata +from dataclasses import dataclass +from typing import IO, Iterable + +import magic +from django.core.exceptions import ValidationError + +from .settings import AttachmentsSettings + +logger = logging.getLogger("nside_wefa.attachments") + +# Filenames are clamped to this many characters after sanitisation. The +# Attachment model's ``filename`` field stores up to 255, but we leave a +# little headroom for storage backends that prepend their own segments. +MAX_FILENAME_LENGTH = 200 + +# Characters disallowed in stored filenames. Path separators and control +# characters (including NUL) are stripped outright; the result is then +# trimmed against MAX_FILENAME_LENGTH. +_FILENAME_DISALLOWED_PATTERN = re.compile(r"[\x00-\x1f/\\]") + + +@dataclass(frozen=True) +class SniffResult: + """Outcome of MIME sniffing for a single file stream.""" + + content_type: str + sniffed_bytes: int + + +def sniff_content_type(stream: IO[bytes], sniff_bytes: int) -> SniffResult: + """Return the MIME type as detected by libmagic from the head of ``stream``. + + Rewinds the stream when done so callers can re-read the full content. + """ + head = stream.read(sniff_bytes) + try: + stream.seek(0) + except (AttributeError, OSError): + # Streams that don't support seek shouldn't reach this layer; log + # loudly so the failure surfaces during development. + logger.exception("Attachment stream is not seekable; cannot rewind after sniff") + raise + detected = magic.from_buffer(head, mime=True) + return SniffResult(content_type=detected, sniffed_bytes=len(head)) + + +def validate_content_type( + content_type: str, + allowed_content_types: Iterable[str], +) -> None: + """Raise :class:`~django.core.exceptions.ValidationError` when the sniffed + MIME is not in the whitelist. + """ + allowed = list(allowed_content_types) + if not allowed: + raise ValidationError( + "This attachment subclass has no `allowed_content_types` configured; " + "uploads are refused.", + code="attachments_no_whitelist", + ) + if content_type not in allowed: + raise ValidationError( + f"Content type '{content_type}' is not allowed. " + f"Allowed types: {sorted(allowed)}.", + code="attachments_content_type_not_allowed", + ) + + +def validate_size(byte_size: int, max_size: int | None) -> None: + """Raise when a known size exceeds ``max_size`` (when set).""" + if max_size is None: + return + if byte_size > max_size: + raise ValidationError( + f"File is {byte_size} bytes which exceeds the maximum of {max_size}.", + code="attachments_size_exceeded", + ) + + +def compute_file_hash( + stream: IO[bytes], + *, + algorithm: str, + max_size: int | None = None, + chunk_size: int = 64 * 1024, +) -> tuple[str, int]: + """Stream ``stream`` through ``hashlib.`` and return + ``(hex_digest, byte_count)``. + + Enforces ``max_size`` while streaming so we don't trust an upfront size + declaration. Rewinds the stream when done. + """ + if algorithm not in hashlib.algorithms_guaranteed: + raise ValueError( + f"Hash algorithm '{algorithm}' is not in hashlib.algorithms_guaranteed." + ) + hasher = hashlib.new(algorithm) + total = 0 + while True: + chunk = stream.read(chunk_size) + if not chunk: + break + total += len(chunk) + if max_size is not None and total > max_size: + raise ValidationError( + f"File exceeds the maximum size of {max_size} bytes while streaming.", + code="attachments_size_exceeded", + ) + hasher.update(chunk) + try: + stream.seek(0) + except (AttributeError, OSError): + logger.exception( + "Attachment stream is not seekable; cannot rewind after hashing" + ) + raise + return hasher.hexdigest(), total + + +def sanitise_filename(raw: str) -> str: + """Return a filename safe to display and to embed in a storage path. + + Strips path separators, control characters, NUL bytes, normalises + Unicode, and clamps to :data:`MAX_FILENAME_LENGTH`. The result is *only* + used for display and as the trailing component of the storage key; the + leading components (attachment uid, version) come from server-side + state. + """ + if not raw: + return "file" + # Take the basename in case a client sent a full path. We do this twice + # — once with POSIX separators and once with Windows separators — so + # neither shape leaks through. + basename = raw.replace("\\", "/").rsplit("/", 1)[-1] + normalised = unicodedata.normalize("NFKC", basename) + cleaned = _FILENAME_DISALLOWED_PATTERN.sub("", normalised).strip() + # Reject names that resolve to traversal even after sanitisation. + if cleaned in ("", ".", ".."): + cleaned = "file" + if len(cleaned) > MAX_FILENAME_LENGTH: + # Preserve the extension when clamping. + head, dot, ext = cleaned.rpartition(".") + if dot and len(ext) <= 16: + cleaned = head[: MAX_FILENAME_LENGTH - len(ext) - 1] + "." + ext + else: + cleaned = cleaned[:MAX_FILENAME_LENGTH] + return cleaned + + +def load_settings() -> AttachmentsSettings: + """Return the resolved settings object for the attachments app.""" + return AttachmentsSettings.load() diff --git a/django/nside_wefa/attachments/views/__init__.py b/django/nside_wefa/attachments/views/__init__.py new file mode 100644 index 00000000..eac2668d --- /dev/null +++ b/django/nside_wefa/attachments/views/__init__.py @@ -0,0 +1,3 @@ +from .attachment_views import build_views + +__all__ = ["build_views"] diff --git a/django/nside_wefa/attachments/views/attachment_views.py b/django/nside_wefa/attachments/views/attachment_views.py new file mode 100644 index 00000000..48472ca9 --- /dev/null +++ b/django/nside_wefa/attachments/views/attachment_views.py @@ -0,0 +1,530 @@ +"""View factory for the attachments app. + +Generates concrete :class:`~rest_framework.views.APIView` subclasses bound +to a consumer's :class:`Attachment` subclass. Two modes: + +- **Singleton** — one logical attachment per scope (avatar, contract PDF). + POST creates v1 or bumps v+1; URLs do not contain ````. +- **Multi** — many logical attachments per scope. POST creates a new + logical attachment; ``POST //versions/`` bumps a specific one. + +The factory returns a dict of ``{role: view_class}`` consumed by +:mod:`nside_wefa.attachments.urls`. +""" + +from __future__ import annotations + +from typing import Any, Callable, Iterable, Optional, Type + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.http import FileResponse +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import status +from rest_framework.exceptions import NotFound +from rest_framework.exceptions import ValidationError as DRFValidationError +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from ..models import Attachment +from ..serializers import build_attachment_serializer + +# Sentinel used as "no permission override" so we can distinguish from +# an explicit empty list. +_DEFAULT_PERMISSIONS: list[Type[BasePermission]] = [] + +QuerysetCallable = Callable[..., Any] +OnCreateCallable = Callable[..., None] + + +def _resolve_queryset( + model_class: Type[Attachment], + queryset_factory: Optional[QuerysetCallable], + request: Request, + **url_kwargs: Any, +): + """Return the base queryset, scoped if a factory was provided.""" + if queryset_factory is None: + return model_class.objects.all() + qs = queryset_factory(request, **url_kwargs) + return qs + + +def _extract_file(request: Request): + """Pull the uploaded file from ``request.FILES['file']`` or raise 400.""" + upload = request.FILES.get("file") + if upload is None: + raise DRFValidationError({"file": "Missing required upload field 'file'."}) + return upload + + +def _to_drf_validation_error(exc: DjangoValidationError) -> DRFValidationError: + """Render a Django ValidationError as a DRF ValidationError.""" + if hasattr(exc, "message_dict"): + return DRFValidationError(exc.message_dict) + if hasattr(exc, "messages"): + return DRFValidationError({"file": exc.messages}) + return DRFValidationError({"file": [str(exc)]}) + + +def _stream_file_response(instance: Attachment) -> FileResponse: + """Return a streamed response for the attachment blob.""" + file_handle = instance.file.open("rb") + response = FileResponse( + file_handle, + content_type=instance.content_type, + as_attachment=True, + filename=instance.filename, + ) + return response + + +def build_views( + model_class: Type[Attachment], + *, + permissions: Iterable[Type[BasePermission]] = (), + queryset: Optional[QuerysetCallable] = None, + on_create: Optional[OnCreateCallable] = None, + serializer_class=None, + singleton: bool = False, +) -> dict[str, Type[APIView]]: + """Build a dict of view classes for ``model_class``. + + Returns the views the URL helper expects: + + - ``"singleton"`` mode: ``{"object", "versions", "version_detail", + "download"}``. ``"versions"`` and ``"version_detail"`` are omitted + when the model has ``versioning_enabled=False``. + - ``"multi"`` mode: ``{"collection", "detail", "versions", + "download"}``. + """ + # Validate the subclass declares the required knobs *before* the + # server starts servicing requests. + model_class._check_subclass_configuration() + + _perms = list(permissions) or _DEFAULT_PERMISSIONS + serializer = serializer_class or build_attachment_serializer(model_class) + versioned = model_class.versioning_enabled + + schema_tag = model_class.__name__ + + # ------------------------------------------------------------------ + # Helpers shared by both modes + # ------------------------------------------------------------------ + + def _create_or_replace_in_singleton_scope( + request: Request, **url_kwargs: Any + ) -> Attachment: + """The body of POST in singleton mode. + + Acquires a row-level lock over the scoped queryset, then either + inserts v1, bumps v+1, or replaces in place depending on the + configured versioning mode. + """ + from django.db import transaction + + upload = _extract_file(request) + base_qs = _resolve_queryset(model_class, queryset, request, **url_kwargs) + with transaction.atomic(): + existing_qs = base_qs.select_for_update().filter(is_current=True) + existing = list(existing_qs[:1]) + + if not existing: + instance = model_class( + is_current=True, + version=1, + uploaded_by=getattr(request.user, "is_authenticated", False) + and request.user + or None, + ) + if on_create is not None: + on_create(request, instance, **url_kwargs) + try: + instance._ingest_file(upload) + except DjangoValidationError as exc: + raise _to_drf_validation_error(exc) from exc + instance.save() + return instance + + current = existing[0] + if versioned: + try: + new_row = model_class.add_version( + file=upload, + parent=current, + uploaded_by=getattr(request.user, "is_authenticated", False) + and request.user + or None, + ) + except DjangoValidationError as exc: + raise _to_drf_validation_error(exc) from exc + return new_row + + try: + current.replace_in_place( + file=upload, + uploaded_by=getattr(request.user, "is_authenticated", False) + and request.user + or None, + ) + except DjangoValidationError as exc: + raise _to_drf_validation_error(exc) from exc + return current + + # ------------------------------------------------------------------ + # Singleton mode views + # ------------------------------------------------------------------ + + if singleton: + + class SingletonObjectView(APIView): + """``GET / POST / DELETE`` on the singleton scope.""" + + permission_classes = _perms + + @extend_schema( + operation_id=f"{schema_tag}_get_current", + tags=[schema_tag], + summary=f"Get current {schema_tag}", + responses={ + 200: OpenApiResponse(response=serializer), + 404: OpenApiResponse(description="No attachment in this scope."), + }, + ) + def get(self, request: Request, **kwargs: Any) -> Response: + qs = _resolve_queryset(model_class, queryset, request, **kwargs) + obj = qs.filter(is_current=True).first() + if obj is None: + raise NotFound("No attachment in this scope.") + return Response(serializer(obj).data) + + @extend_schema( + operation_id=f"{schema_tag}_upload", + tags=[schema_tag], + summary=( + f"Upload (or version) the {schema_tag}" + if versioned + else f"Upload (or replace) the {schema_tag}" + ), + request={ + "multipart/form-data": { + "type": "object", + "properties": {"file": {"type": "string", "format": "binary"}}, + "required": ["file"], + } + }, + responses={ + 200: OpenApiResponse(response=serializer), + 201: OpenApiResponse(response=serializer), + 400: OpenApiResponse(description="Validation error."), + }, + ) + def post(self, request: Request, **kwargs: Any) -> Response: + instance = _create_or_replace_in_singleton_scope(request, **kwargs) + http_status = ( + status.HTTP_201_CREATED + if instance.version == 1 + else status.HTTP_200_OK + ) + return Response(serializer(instance).data, status=http_status) + + @extend_schema( + operation_id=f"{schema_tag}_delete", + tags=[schema_tag], + summary=f"Delete the {schema_tag} (all versions)", + responses={ + 204: OpenApiResponse(description="Deleted."), + 404: OpenApiResponse(description="No attachment in this scope."), + }, + ) + def delete(self, request: Request, **kwargs: Any) -> Response: + qs = _resolve_queryset(model_class, queryset, request, **kwargs) + rows = list(qs.all()) + if not rows: + raise NotFound("No attachment in this scope.") + # Group by attachment_uid; delete each blob. + seen_uids: set = set() + for row in rows: + if row.attachment_uid in seen_uids: + continue + seen_uids.add(row.attachment_uid) + model_class.hard_delete_logical(row.attachment_uid) + return Response(status=status.HTTP_204_NO_CONTENT) + + class SingletonVersionsView(APIView): + """``GET`` history list for the singleton scope.""" + + permission_classes = _perms + + @extend_schema( + operation_id=f"{schema_tag}_versions", + tags=[schema_tag], + summary=f"List {schema_tag} versions", + responses={ + 200: OpenApiResponse(response=serializer(many=True)), + 404: OpenApiResponse(description="No attachment in this scope."), + }, + ) + def get(self, request: Request, **kwargs: Any) -> Response: + qs = _resolve_queryset(model_class, queryset, request, **kwargs) + rows = list(qs.order_by("version")) + if not rows: + raise NotFound("No attachment in this scope.") + return Response(serializer(rows, many=True).data) + + class SingletonVersionDetailView(APIView): + """``GET`` a specific version by version number.""" + + permission_classes = _perms + + @extend_schema( + operation_id=f"{schema_tag}_version_detail", + tags=[schema_tag], + summary=f"Get a specific {schema_tag} version", + responses={ + 200: OpenApiResponse(response=serializer), + 404: OpenApiResponse(description="No such version."), + }, + ) + def get(self, request: Request, version: int, **kwargs: Any) -> Response: + qs = _resolve_queryset(model_class, queryset, request, **kwargs) + obj = qs.filter(version=version).first() + if obj is None: + raise NotFound("No such version.") + return Response(serializer(obj).data) + + class SingletonDownloadView(APIView): + """``GET`` stream of the current version's bytes.""" + + permission_classes = _perms + + @extend_schema( + operation_id=f"{schema_tag}_download", + tags=[schema_tag], + summary=f"Download the current {schema_tag}", + responses={ + 200: OpenApiResponse(description="Binary stream."), + 404: OpenApiResponse(description="No attachment in this scope."), + }, + ) + def get(self, request: Request, **kwargs: Any) -> FileResponse: + qs = _resolve_queryset(model_class, queryset, request, **kwargs) + obj = qs.filter(is_current=True).first() + if obj is None: + raise NotFound("No attachment in this scope.") + return _stream_file_response(obj) + + out: dict[str, Type[APIView]] = { + "object": SingletonObjectView, + "download": SingletonDownloadView, + } + if versioned: + out["versions"] = SingletonVersionsView + out["version_detail"] = SingletonVersionDetailView + return out + + # ------------------------------------------------------------------ + # Multi-mode views + # ------------------------------------------------------------------ + + class CollectionView(APIView): + """``GET`` list of current rows; ``POST`` create new logical attachment.""" + + permission_classes = _perms + + @extend_schema( + operation_id=f"{schema_tag}_list", + tags=[schema_tag], + summary=f"List current {schema_tag}", + responses={200: OpenApiResponse(response=serializer(many=True))}, + ) + def get(self, request: Request, **kwargs: Any) -> Response: + qs = _resolve_queryset(model_class, queryset, request, **kwargs) + qs = qs.filter(is_current=True) + return Response(serializer(qs, many=True).data) + + @extend_schema( + operation_id=f"{schema_tag}_create", + tags=[schema_tag], + summary=f"Create a new {schema_tag} (v1)", + request={ + "multipart/form-data": { + "type": "object", + "properties": {"file": {"type": "string", "format": "binary"}}, + "required": ["file"], + } + }, + responses={ + 201: OpenApiResponse(response=serializer), + 400: OpenApiResponse(description="Validation error."), + }, + ) + def post(self, request: Request, **kwargs: Any) -> Response: + upload = _extract_file(request) + instance = model_class( + is_current=True, + version=1, + uploaded_by=getattr(request.user, "is_authenticated", False) + and request.user + or None, + ) + if on_create is not None: + on_create(request, instance, **kwargs) + try: + instance._ingest_file(upload) + except DjangoValidationError as exc: + raise _to_drf_validation_error(exc) from exc + instance.save() + return Response(serializer(instance).data, status=status.HTTP_201_CREATED) + + class DetailView(APIView): + """``GET`` retrieve a specific row; ``DELETE`` hard-delete the whole logical attachment.""" + + permission_classes = _perms + + @extend_schema( + operation_id=f"{schema_tag}_detail", + tags=[schema_tag], + summary=f"Retrieve a {schema_tag}", + responses={ + 200: OpenApiResponse(response=serializer), + 404: OpenApiResponse(description="Not found."), + }, + ) + def get(self, request: Request, pk: int, **kwargs: Any) -> Response: + qs = _resolve_queryset(model_class, queryset, request, **kwargs) + try: + obj = qs.get(pk=pk) + except model_class.DoesNotExist as exc: + raise NotFound() from exc + return Response(serializer(obj).data) + + @extend_schema( + operation_id=f"{schema_tag}_delete", + tags=[schema_tag], + summary=f"Delete a {schema_tag} (all versions)", + responses={ + 204: OpenApiResponse(description="Deleted."), + 404: OpenApiResponse(description="Not found."), + }, + ) + def delete(self, request: Request, pk: int, **kwargs: Any) -> Response: + qs = _resolve_queryset(model_class, queryset, request, **kwargs) + try: + obj = qs.get(pk=pk) + except model_class.DoesNotExist as exc: + raise NotFound() from exc + model_class.hard_delete_logical(obj.attachment_uid) + return Response(status=status.HTTP_204_NO_CONTENT) + + class VersionsView(APIView): + """``GET`` history; ``POST`` bump the logical attachment identified by ``pk``.""" + + permission_classes = _perms + + @extend_schema( + operation_id=f"{schema_tag}_versions", + tags=[schema_tag], + summary=f"List versions of a {schema_tag}", + responses={ + 200: OpenApiResponse(response=serializer(many=True)), + 404: OpenApiResponse(description="Not found."), + }, + ) + def get(self, request: Request, pk: int, **kwargs: Any) -> Response: + qs = _resolve_queryset(model_class, queryset, request, **kwargs) + try: + anchor = qs.get(pk=pk) + except model_class.DoesNotExist as exc: + raise NotFound() from exc + history = model_class.objects.history(anchor.attachment_uid) + return Response(serializer(history, many=True).data) + + @extend_schema( + operation_id=f"{schema_tag}_add_version", + tags=[schema_tag], + summary=f"Add a new version to a {schema_tag}", + request={ + "multipart/form-data": { + "type": "object", + "properties": {"file": {"type": "string", "format": "binary"}}, + "required": ["file"], + } + }, + responses={ + 201: OpenApiResponse(response=serializer), + 400: OpenApiResponse(description="Validation error."), + 404: OpenApiResponse(description="Not found."), + 409: OpenApiResponse( + description=( + "Versioning disabled for this attachment subclass; " + "use POST on the row to replace in place." + ) + ), + }, + ) + def post(self, request: Request, pk: int, **kwargs: Any) -> Response: + qs = _resolve_queryset(model_class, queryset, request, **kwargs) + try: + anchor = qs.get(pk=pk) + except model_class.DoesNotExist as exc: + raise NotFound() from exc + + upload = _extract_file(request) + if not versioned: + # When versioning is disabled, this endpoint replaces the + # row in place rather than 409-ing — the route exists in + # the URL set only when versioning is enabled, so reaching + # this branch means the registration helper allowed it. + try: + anchor.replace_in_place( + file=upload, + uploaded_by=getattr(request.user, "is_authenticated", False) + and request.user + or None, + ) + except DjangoValidationError as exc: + raise _to_drf_validation_error(exc) from exc + return Response(serializer(anchor).data, status=status.HTTP_200_OK) + + try: + new_row = model_class.add_version( + file=upload, + parent=anchor, + uploaded_by=getattr(request.user, "is_authenticated", False) + and request.user + or None, + ) + except DjangoValidationError as exc: + raise _to_drf_validation_error(exc) from exc + return Response(serializer(new_row).data, status=status.HTTP_201_CREATED) + + class DownloadView(APIView): + """``GET`` stream of a specific row's bytes.""" + + permission_classes = _perms + + @extend_schema( + operation_id=f"{schema_tag}_download", + tags=[schema_tag], + summary=f"Download a {schema_tag}", + responses={ + 200: OpenApiResponse(description="Binary stream."), + 404: OpenApiResponse(description="Not found."), + }, + ) + def get(self, request: Request, pk: int, **kwargs: Any) -> FileResponse: + qs = _resolve_queryset(model_class, queryset, request, **kwargs) + try: + obj = qs.get(pk=pk) + except model_class.DoesNotExist as exc: + raise NotFound() from exc + return _stream_file_response(obj) + + return { + "collection": CollectionView, + "detail": DetailView, + "versions": VersionsView, + "download": DownloadView, + } diff --git a/django/pyproject.toml b/django/pyproject.toml index 11eae55c..b84b5887 100644 --- a/django/pyproject.toml +++ b/django/pyproject.toml @@ -30,9 +30,11 @@ dependencies = [ "django>=6.0.4", "django-auditlog>=3.4.1", "django-cors-headers>=4.9.0", + "django-storages>=1.14", "djangorestframework>=3.14.0", "djangorestframework-simplejwt>=5.5.1", "drf-spectacular>=0.28.0", + "python-magic>=0.4.27", ] [project.urls] diff --git a/django/pytest.ini b/django/pytest.ini index 6b9c9b2b..3622a2f6 100644 --- a/django/pytest.ini +++ b/django/pytest.ini @@ -1,4 +1,4 @@ [pytest] -DJANGO_SETTINGS_MODULE = demo.settings +DJANGO_SETTINGS_MODULE = nside_wefa.attachments.tests.settings python_files = test_*.py pythonpath = . \ No newline at end of file diff --git a/django/uv.lock b/django/uv.lock index 1d2cf14b..51f6b358 100644 --- a/django/uv.lock +++ b/django/uv.lock @@ -227,6 +227,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, ] +[[package]] +name = "django-storages" +version = "1.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" }, +] + [[package]] name = "django-stubs" version = "6.0.2" @@ -612,9 +624,11 @@ dependencies = [ { name = "django" }, { name = "django-auditlog" }, { name = "django-cors-headers" }, + { name = "django-storages" }, { name = "djangorestframework" }, { name = "djangorestframework-simplejwt" }, { name = "drf-spectacular" }, + { name = "python-magic" }, ] [package.dev-dependencies] @@ -638,9 +652,11 @@ requires-dist = [ { name = "django", specifier = ">=6.0.4" }, { name = "django-auditlog", specifier = ">=3.4.1" }, { name = "django-cors-headers", specifier = ">=4.9.0" }, + { name = "django-storages", specifier = ">=1.14" }, { name = "djangorestframework", specifier = ">=3.14.0" }, { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, { name = "drf-spectacular", specifier = ">=0.28.0" }, + { name = "python-magic", specifier = ">=0.4.27" }, ] [package.metadata.requires-dev] @@ -772,6 +788,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-magic" +version = "0.4.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677, upload-time = "2022-06-07T20:16:59.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840, upload-time = "2022-06-07T20:16:57.763Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2"