Fichiers joints aux specs et raffinages (stockage local/S3) #9

Merged
Gilles merged 3 commits from feature/attachments into master 2026-10-25 09:23:49 +00:00
Owner

Closes #8

Contenu

  • Modèle : StoredFile (un fichier physique par projet, dédupliqué sur son SHA-256, immuable) et Attachment (lien vers une version de spec ou un raffinage, avec un nom propre au lien).
  • Héritage : Spec.reopen() recopie les liens (pas les fichiers) vers le nouveau brouillon.
  • Statut calculé, rien de stocké : comparaison par nom avec la version N-1 → inherited / replaced / new / deleted (entrée fantôme sans id, restaurable), renamed_from si le même fichier existait sous un autre nom. Raffinages : comparaison avec le raffinage de la version N-1 de la spec parente vers la même place enfant.
  • Usages calculés : exists_in_specs, exists_in_refinements, exists_in_versions(spec).
  • API
    • GET /api/attachments/?spec_version= ou ?refinement= : liste avec statuts (+ exists_in_versions pour une version).
    • POST /api/attachments/ (multipart file, ou stored_file pour réutiliser/restaurer ; name optionnel) → deduplicated dans la réponse ; 409 name_conflict / already_attached avec l'id du fichier joint concerné.
    • PATCH (renommer), DELETE, POST …/{id}/replace/, GET …/{id}/download/.
    • GET /api/files/?project=&sha256= (détection de doublon avant envoi), GET /api/files/{id}/ (usages), GET /api/files/{id}/versions/?spec=, GET /api/files/{id}/download/.
    • Écritures : droit lecture-écriture + version cible en brouillon. Non-membre : 404.
    • Téléchargement toujours via Django, attachment + application/octet-stream + nosniff, GetObject direct sur S3 (bug HeadObject de Garage).
    • Taille max ATTACHMENTS_MAX_SIZE_MB (20).
  • Nettoyage : un StoredFile sans lien est supprimé avec son fichier (on_commit) ; suppression d'un projet OK (RESTRICT).
  • Settings : ATTACHMENTS_STORAGE=local|s3, S3_* (path-style, région garage par défaut), MEDIA_ROOT explicite. Passage au dict STORAGES : STATICFILES_STORAGE n'était plus lu depuis Django 5.1 ; staticfiles reste sur StaticFilesStorage, ce qui était réellement en vigueur.

Tests

E2E (e2e/attachments.spec.ts, Firefox, vraie stack gunicorn + SPA) : envoi et téléchargement, gel à la validation, héritage après « Nouvelle version », suppression/restauration, remplacement sur conflit de nom, détection d'un doublon entre deux specs. Données d'amorçage : e2e/seed.py, lancé par le job e2e après migrate. Vérifié en local (5/5) avec la SPA construite depuis thierryade/frontend#6.

Unitaires : 24 nouveaux tests (apps/specs/test_attachments.py) : statuts, dédoublonnage, conflits, immuabilité, droits, héritage/remplacement/suppression/restauration/renommage, raffinages, nettoyage physique, en-têtes de téléchargement, lecture S3 par GetObject. 103 tests au total, pylint 10/10, bandit et autopep8 propres.

⚠️ À faire sur le serveur avant le merge

Le compose référence deux nouveaux secrets : sans eux, docker compose up échoue.

  1. garage bucket create thierryade-attachments, garage key new --name thierryade, garage bucket allow --read --write --key thierryade thierryade-attachments
  2. secrets/thierryadeS3AccessKey et secrets/thierryadeS3SecretKey dans /srv/docker/thierryade
  3. Copier le nouveau deploy/docker-compose.yml sur le serveur (sinon ATTACHMENTS_STORAGE reste à local et les fichiers sont perdus au redéploiement).

Ordre de merge

Merger thierryade/frontend#6 d'abord. Le job e2e (sur master uniquement) installe la dernière SPA publiée : si le backend passe en premier, le nouvel e2e échoue faute d'interface, et docker/deploy (qui en dépendent) sont sautés. Le merge du frontend seul ne déploie rien : la SPA n'arrive en prod qu'avec la prochaine image backend.

🤖 Generated with Claude Code

https://claude.ai/code/session_014BSmBtGWeFBD9z4mYm9SjA

Closes #8 ## Contenu - **Modèle** : `StoredFile` (un fichier physique par projet, dédupliqué sur son SHA-256, immuable) et `Attachment` (lien vers une version de spec **ou** un raffinage, avec un nom propre au lien). - **Héritage** : `Spec.reopen()` recopie les liens (pas les fichiers) vers le nouveau brouillon. - **Statut calculé, rien de stocké** : comparaison par nom avec la version N-1 → `inherited` / `replaced` / `new` / `deleted` (entrée fantôme sans `id`, restaurable), `renamed_from` si le même fichier existait sous un autre nom. Raffinages : comparaison avec le raffinage de la version N-1 de la spec parente vers la même place enfant. - **Usages calculés** : `exists_in_specs`, `exists_in_refinements`, `exists_in_versions(spec)`. - **API** - `GET /api/attachments/?spec_version=` ou `?refinement=` : liste avec statuts (+ `exists_in_versions` pour une version). - `POST /api/attachments/` (multipart `file`, ou `stored_file` pour réutiliser/restaurer ; `name` optionnel) → `deduplicated` dans la réponse ; `409` `name_conflict` / `already_attached` avec l'id du fichier joint concerné. - `PATCH` (renommer), `DELETE`, `POST …/{id}/replace/`, `GET …/{id}/download/`. - `GET /api/files/?project=&sha256=` (détection de doublon avant envoi), `GET /api/files/{id}/` (usages), `GET /api/files/{id}/versions/?spec=`, `GET /api/files/{id}/download/`. - Écritures : droit lecture-écriture + version cible en brouillon. Non-membre : 404. - Téléchargement toujours via Django, `attachment` + `application/octet-stream` + `nosniff`, GetObject direct sur S3 (bug HeadObject de Garage). - Taille max `ATTACHMENTS_MAX_SIZE_MB` (20). - **Nettoyage** : un `StoredFile` sans lien est supprimé avec son fichier (`on_commit`) ; suppression d'un projet OK (`RESTRICT`). - **Settings** : `ATTACHMENTS_STORAGE=local|s3`, `S3_*` (path-style, région `garage` par défaut), `MEDIA_ROOT` explicite. Passage au dict `STORAGES` : `STATICFILES_STORAGE` n'était plus lu depuis Django 5.1 ; `staticfiles` reste sur `StaticFilesStorage`, ce qui était réellement en vigueur. ## Tests **E2E** (`e2e/attachments.spec.ts`, Firefox, vraie stack gunicorn + SPA) : envoi et téléchargement, gel à la validation, héritage après « Nouvelle version », suppression/restauration, remplacement sur conflit de nom, détection d'un doublon entre deux specs. Données d'amorçage : `e2e/seed.py`, lancé par le job e2e après `migrate`. Vérifié en local (5/5) avec la SPA construite depuis thierryade/frontend#6. **Unitaires** : 24 nouveaux tests (`apps/specs/test_attachments.py`) : statuts, dédoublonnage, conflits, immuabilité, droits, héritage/remplacement/suppression/restauration/renommage, raffinages, nettoyage physique, en-têtes de téléchargement, lecture S3 par GetObject. 103 tests au total, pylint 10/10, bandit et autopep8 propres. ## ⚠️ À faire sur le serveur **avant** le merge Le compose référence deux nouveaux secrets : sans eux, `docker compose up` échoue. 1. `garage bucket create thierryade-attachments`, `garage key new --name thierryade`, `garage bucket allow --read --write --key thierryade thierryade-attachments` 2. `secrets/thierryadeS3AccessKey` et `secrets/thierryadeS3SecretKey` dans `/srv/docker/thierryade` 3. Copier le nouveau `deploy/docker-compose.yml` sur le serveur (sinon `ATTACHMENTS_STORAGE` reste à `local` et les fichiers sont perdus au redéploiement). ## Ordre de merge **Merger thierryade/frontend#6 d'abord.** Le job e2e (sur `master` uniquement) installe la dernière SPA publiée : si le backend passe en premier, le nouvel e2e échoue faute d'interface, et `docker`/`deploy` (qui en dépendent) sont sautés. Le merge du frontend seul ne déploie rien : la SPA n'arrive en prod qu'avec la prochaine image backend. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014BSmBtGWeFBD9z4mYm9SjA
Add attachments to spec versions and refinements (local or S3 storage)
Some checks failed
ci / test (pull_request) Failing after 2m12s
ci / e2e (pull_request) Has been skipped
ci / docker (pull_request) Has been skipped
ci / deploy (pull_request) Has been skipped
a475e6a9b5
- StoredFile (one physical file per project, deduplicated on SHA-256,
  immutable) and Attachment (link to a spec version XOR a refinement,
  with a per-link name).
- reopen() inherits attachments as links to the same stored files.
- Status (inherited / replaced / new / deleted, renamed detection) is
  computed against the previous version, never stored; refinements
  compare with the previous parent version's refinement on the same
  child place.
- exists_in_specs / exists_in_refinements / exists_in_versions(spec)
  computed by query.
- /api/attachments/ (diff listing, upload or reuse, rename, replace,
  delete, download) and /api/files/ (sha256 lookup, usages, versions,
  download). Downloads always go through Django (rights checked),
  as attachment + nosniff, via GetObject on S3 (Garage HeadObject bug).
- ATTACHMENTS_STORAGE=local|s3 settings; STORAGES replaces the
  STATICFILES_STORAGE setting Django no longer reads.
- Deploy: Garage bucket thierryade-attachments + secrets.

Closes #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BSmBtGWeFBD9z4mYm9SjA
Author
Owner
<!-- argus-link-bot:thierryade/backend --> 📊 [Rapports Argus pour cette PR](https://reports.stationmyr.net/thierryade/backend/pr/9)
Split AttachmentViewSet.create helpers (pylint too-many-locals in CI)
All checks were successful
ci / test (pull_request) Successful in 2m5s
ci / e2e (pull_request) Has been skipped
ci / docker (pull_request) Has been skipped
ci / deploy (pull_request) Has been skipped
ac71d2c050
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BSmBtGWeFBD9z4mYm9SjA
e2e: attachments flow through the real stack
All checks were successful
ci / test (pull_request) Successful in 2m3s
ci / e2e (pull_request) Has been skipped
ci / docker (pull_request) Has been skipped
ci / deploy (pull_request) Has been skipped
8b21dcbed7
e2e/seed.py (piped into manage.py shell by the e2e job) creates a staff
user, a project and two draft specs; e2e/attachments.spec.ts logs in via
/admin/login/ and covers upload + download, freeze on validation,
inheritance on reopen, delete/restore, replace on a name clash and
duplicate detection across specs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BSmBtGWeFBD9z4mYm9SjA
Gilles merged commit e1657bcaea into master 2026-10-25 09:23:49 +00:00
Gilles deleted branch feature/attachments 2026-10-25 09:23:49 +00:00
Sign in to join this conversation.
No description provided.