From f6b7374be77a402cb01015c278d8b575c09ec519 Mon Sep 17 00:00:00 2001
From: Alec Perkins
Date: Tue, 27 Oct 2026 10:15:30 -0400
Subject: [PATCH] Add feature files, image pins and implementations (Phase 4)
Files: prototype and documentation files on features, with versions.
Adding, replacing or removing a file is a feature revision with a
change note. Types come from the extension; uploads are capped at
25 MB and stored under opaque paths. Every file URL checks project
access, then redirects to a short-lived signed URL on the bucket, or
streams with nosniff and a CSP sandbox when no bucket is configured.
HTML prototypes run in a sandboxed iframe without same-origin access.
Threads: whole-file threads and pins at a point on an image, drawn as
an SVG overlay in percentages. Pins are placed with number fields, or
by clicking the image with JS.
Implementations: name, link and status, plus a per-feature status grid
stamped with the feature revision, marked behind when the feature
changes, and confirmable without changing the status.
Co-Authored-By: Claude Opus 5.5
---
config/settings/base.py | 5 +
config/urls.py | 8 +
docs/plan.md | 22 +-
...0003_remove_commentthread_thread_target.py | 17 ++
...d_file_version_commentthread_x_and_more.py | 44 +++
intent/comments/models.py | 52 +++-
intent/comments/presenters.py | 59 +++-
intent/comments/services.py | 28 ++
intent/comments/views.py | 13 +-
.../migrations/0003_revision_files_change.py | 18 ++
intent/features/models.py | 2 +
intent/features/services.py | 20 +-
intent/features/views.py | 47 +++-
intent/files/__init__.py | 0
intent/files/admin.py | 29 ++
intent/files/apps.py | 6 +
intent/files/forms.py | 30 ++
intent/files/migrations/0001_initial.py | 57 ++++
intent/files/migrations/__init__.py | 0
intent/files/models.py | 98 +++++++
intent/files/services.py | 103 +++++++
intent/files/urls.py | 15 +
intent/files/views.py | 236 ++++++++++++++++
intent/implementations/__init__.py | 0
intent/implementations/admin.py | 10 +
intent/implementations/apps.py | 6 +
intent/implementations/forms.py | 21 ++
.../migrations/0001_initial.py | 56 ++++
intent/implementations/migrations/__init__.py | 0
intent/implementations/models.py | 71 +++++
intent/implementations/services.py | 74 +++++
intent/implementations/urls.py | 14 +
intent/implementations/views.py | 155 ++++++++++
intent/issues/services.py | 5 +
static/css/intent.css | 57 ++++
static/js/intent.js | 51 ++++
templates/comments/_thread.html | 9 +-
templates/features/detail.html | 57 ++++
templates/features/revision.html | 11 +
templates/files/detail.html | 138 +++++++++
templates/implementations/_table.html | 19 ++
templates/implementations/detail.html | 97 +++++++
templates/implementations/form.html | 12 +
templates/implementations/list.html | 23 ++
templates/projects/base.html | 1 +
tests/test_a11y.py | 41 ++-
tests/test_files.py | 266 ++++++++++++++++++
tests/test_implementations.py | 128 +++++++++
tests/test_phase4_exit.py | 72 +++++
49 files changed, 2272 insertions(+), 31 deletions(-)
create mode 100644 intent/comments/migrations/0003_remove_commentthread_thread_target.py
create mode 100644 intent/comments/migrations/0004_commentthread_file_version_commentthread_x_and_more.py
create mode 100644 intent/features/migrations/0003_revision_files_change.py
create mode 100644 intent/files/__init__.py
create mode 100644 intent/files/admin.py
create mode 100644 intent/files/apps.py
create mode 100644 intent/files/forms.py
create mode 100644 intent/files/migrations/0001_initial.py
create mode 100644 intent/files/migrations/__init__.py
create mode 100644 intent/files/models.py
create mode 100644 intent/files/services.py
create mode 100644 intent/files/urls.py
create mode 100644 intent/files/views.py
create mode 100644 intent/implementations/__init__.py
create mode 100644 intent/implementations/admin.py
create mode 100644 intent/implementations/apps.py
create mode 100644 intent/implementations/forms.py
create mode 100644 intent/implementations/migrations/0001_initial.py
create mode 100644 intent/implementations/migrations/__init__.py
create mode 100644 intent/implementations/models.py
create mode 100644 intent/implementations/services.py
create mode 100644 intent/implementations/urls.py
create mode 100644 intent/implementations/views.py
create mode 100644 templates/files/detail.html
create mode 100644 templates/implementations/_table.html
create mode 100644 templates/implementations/detail.html
create mode 100644 templates/implementations/form.html
create mode 100644 templates/implementations/list.html
create mode 100644 tests/test_files.py
create mode 100644 tests/test_implementations.py
create mode 100644 tests/test_phase4_exit.py
diff --git a/config/settings/base.py b/config/settings/base.py
index 5fb9172..8a681f1 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -29,6 +29,8 @@ INSTALLED_APPS = [
"intent.orgs",
"intent.projects",
"intent.features",
+ "intent.files",
+ "intent.implementations",
"intent.issues",
"intent.comments",
]
@@ -78,6 +80,9 @@ DATABASES = {
}
}
+# Uploads up to 25 MB (see intent.files). Larger bodies spill to a temp file, not memory.
+FILE_UPLOAD_MAX_MEMORY_SIZE = 2_621_440
+
# Rate limits (allauth's and ours) live here, so the cache must be shared by every worker
# and survive restarts. The database is the only shared store on a one-machine deploy.
CACHES = {
diff --git a/config/urls.py b/config/urls.py
index 7d748d0..c50df45 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -31,7 +31,15 @@ urlpatterns = [
path("/new/", project_create, name="project_create"),
path("/", include("intent.orgs.urls")),
path("//", include("intent.projects.urls")),
+ path(
+ "//features//files/",
+ include("intent.files.urls"),
+ ),
path("//features/", include("intent.features.urls")),
path("//issues/", include("intent.issues.urls")),
+ path(
+ "//implementations/",
+ include("intent.implementations.urls"),
+ ),
path("//", include("intent.comments.urls")),
]
diff --git a/docs/plan.md b/docs/plan.md
index 63d25b6..f662b42 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -26,11 +26,12 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Issue → feature | From an issue, a collaborator follows "Create a feature from #N" or "Update INT-3 for #N". The feature form opens prefilled (title and description from the issue, or a change note naming it), ties the new revision to the issue, links the feature, and offers a checked "Mark #N accepted" box. Accept and Reject buttons also work on their own. |
| Feature statuses | `active`, `withdrawn`, `superseded` (requires `superseded_by` feature). |
| Implementation status | `active`, `archived`. |
-| Feature × implementation status | `n/a`, `not_started`, `in_progress`, `shipped`, `retired`; stamped with the feature revision it applies to. Missing row displays as `not_started @ —`. If stamped revision < current revision, display "behind". |
+| Feature × implementation status | `n/a`, `not_started`, `in_progress`, `shipped`, `retired`; stamped with the feature revision it applies to. Missing row displays as `not_started @ —`. If stamped revision < current revision, display "behind". Each implementation page is one form: a status select per feature, plus a "Still … at revision N" checkbox on rows that are behind, which re-stamps without changing the status. Only changed or confirmed rows are written. Implementation links must be http(s). Collaborators create, edit and archive implementations; project owners delete them (typed name to confirm). |
| Hierarchy | Single optional parent within the same project. Cycles rejected. Moving a feature records a revision. Statuses independent in V1. |
-| Files | Belong to a feature, typed `prototype` or `documentation`. Replacing a file creates a new file version and a feature revision; old versions stay (so old image-pin threads still resolve). Size cap 25 MB; allowed types: images, PDF, markdown/text, HTML, zip. |
+| Files | Belong to a feature, typed `prototype` or `documentation`. Adding, replacing or removing a file is a feature revision with a required change note; the revision records what happened ("Added prototype “a.png”") and shows it in history. Replacing creates a new file version; old versions stay (so old image-pin threads still resolve). Removed files stay too, so old revisions list the files they had. Size cap 25 MB; allowed types: images (png, jpg, gif, webp, avif, svg), PDF, Markdown/text, HTML, zip. The content type comes from the extension, never the browser. Stored under opaque paths (`files//`). Markdown files render on their page; text shows as text; PDFs and zips open or download. |
| Line-anchored threads | Anchor = feature revision + start/end source line of one top-level Markdown block (paragraph, list, heading, code block). Each block has its own `` comment form, so it works without JS. A thread goes "outdated" only when the description text has changed since its revision (a status-only revision doesn't outdate it); outdated threads move to "Comments on earlier revisions", quote their original lines, and link to the revision. No attempt to re-map lines. A line comment posted against a stale revision is refused and the draft is shown back. |
-| Image threads | Anchor = file version + x/y as fractions (0–1). |
+| Image threads | Anchor = file version + x/y as fractions (0–1). Pins are drawn as an SVG overlay using percentage coordinates (no inline styles), numbered per version, and each is a link to its thread. Placing a pin: two number fields (across %, down %) work without JS; clicking the image fills them in. New comments go on the latest version only; threads on other versions are listed separately with a link to that version. |
+| File threads | Anchor = file version, like image threads without a point. |
| Comments | Edits keep history (visible "edited" link to a history page). Author or project owner may delete; a deleted comment shows as a tombstone and its text and history are removed. Delete asks for confirmation in a disclosure, so it works without JS. Resolving a feature thread is allowed for the thread author or any collaborator; resolved threads start collapsed. An issue has exactly one thread, its discussion, which isn't resolvable (the issue status says that). |
| Thread → issue | Original commenter (while they can contribute) or any project collaborator. Thread gets `promoted_to` issue and is resolved; the issue is linked to the thread's feature, its body is prefilled with the quoted lines and comments, and its page links back to the thread. |
| Deleted users | Content remains, attributed to a "ghost" user. |
@@ -51,9 +52,9 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). Only superusers can grant staff or superuser status or permissions in the admin. |
| Cache | Django's database cache (`createcachetable` runs at start). It holds allauth's rate limits and the invite throttle, so it must be shared by all gunicorn workers and survive restarts; the database is the only shared store on a one-machine deploy. Dev and tests use the in-memory cache. |
| Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. |
-| Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `