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 <noreply@anthropic.com>
This commit is contained in:
Alec K2XAP 2026-10-27 10:15:30 -04:00
parent 5811e72a0c
commit f6b7374be7
49 changed files with 2272 additions and 31 deletions

View file

@ -29,6 +29,8 @@ INSTALLED_APPS = [
"intent.orgs", "intent.orgs",
"intent.projects", "intent.projects",
"intent.features", "intent.features",
"intent.files",
"intent.implementations",
"intent.issues", "intent.issues",
"intent.comments", "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 # 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. # and survive restarts. The database is the only shared store on a one-machine deploy.
CACHES = { CACHES = {

View file

@ -31,7 +31,15 @@ urlpatterns = [
path("<slug:org_slug>/new/", project_create, name="project_create"), path("<slug:org_slug>/new/", project_create, name="project_create"),
path("<slug:org_slug>/", include("intent.orgs.urls")), path("<slug:org_slug>/", include("intent.orgs.urls")),
path("<slug:org_slug>/<slug:project_slug>/", include("intent.projects.urls")), path("<slug:org_slug>/<slug:project_slug>/", include("intent.projects.urls")),
path(
"<slug:org_slug>/<slug:project_slug>/features/<str:feature_key>/files/",
include("intent.files.urls"),
),
path("<slug:org_slug>/<slug:project_slug>/features/", include("intent.features.urls")), path("<slug:org_slug>/<slug:project_slug>/features/", include("intent.features.urls")),
path("<slug:org_slug>/<slug:project_slug>/issues/", include("intent.issues.urls")), path("<slug:org_slug>/<slug:project_slug>/issues/", include("intent.issues.urls")),
path(
"<slug:org_slug>/<slug:project_slug>/implementations/",
include("intent.implementations.urls"),
),
path("<slug:org_slug>/<slug:project_slug>/", include("intent.comments.urls")), path("<slug:org_slug>/<slug:project_slug>/", include("intent.comments.urls")),
] ]

View file

@ -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. | | 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). | | Feature statuses | `active`, `withdrawn`, `superseded` (requires `superseded_by` feature). |
| Implementation status | `active`, `archived`. | | 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. | | 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/<ulid>/<name>`). 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 `<details>` 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. | | 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 `<details>` 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). | | 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. | | 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. | | 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. | | 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. | | 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. | | 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 `<iframe>` whose `src` is a signed URL on the Tigris origin, never rendered inline on the app origin. | | Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. Every file URL is an app view (`…/files/<id>/v<n>/<name>`) that checks project access first, then redirects to a 5-minute signed URL. Where the bucket isn't configured (dev), the view streams the file itself with `nosniff` and a `Content-Security-Policy: sandbox` header, so an uploaded SVG or HTML file can't act as the app. HTML prototypes are shown in an `<iframe sandbox="allow-scripts allow-modals allow-popups">` (no `allow-same-origin`): checked in Chromium, a prototype can't read cookies, reach the parent page, or make network requests. |
| Markdown | `markdown-it-py` with source line mapping (`data-source-lines` attributes on blocks) for line threads; output sanitized with `nh3`. Descriptions render block by block so each block can carry its threads. Diffs via `difflib` rendered server-side. | | Markdown | `markdown-it-py` with source line mapping (`data-source-lines` attributes on blocks) for line threads; output sanitized with `nh3`. Descriptions render block by block so each block can carry its threads. Diffs via `difflib` rendered server-side. |
| Frontend | Django templates, HTMX for partial updates, Alpine for small local state (image pin placement, thread forms). CodeMirror 6 as a progressive enhancement on the markdown textarea, optional. No build step beyond copying vendored JS into static files. | | Frontend | Django templates, HTMX for partial updates, and a few lines of plain JS (`static/js/intent.js`) for focus after swaps and pin placement. Alpine turned out unnecessary, and its standard build needs `unsafe-eval`, which the Phase 5 CSP would forbid. CodeMirror 6 as a progressive enhancement on the markdown textarea, optional. No build step beyond copying vendored JS into static files. |
| Email | `django-anymail` with the provider pluggable; console backend in development. **Provider TBD; launch blocker for magic links and email invites.** | | Email | `django-anymail` with the provider pluggable; console backend in development. **Provider TBD; launch blocker for magic links and email invites.** |
| Background work | None. Email is sent synchronously. | | Background work | None. Email is sent synchronously. |
| Observability | Sentry (free tier), Fly logs. | | Observability | Sentry (free tier), Fly logs. |
@ -91,15 +92,17 @@ Feature project, number, parent (nullable, same project), title, st
current_revision (int), created_by current_revision (int), created_by
FeatureRevision feature, revision, description_md, note, author, parent_at_revision, FeatureRevision feature, revision, description_md, note, author, parent_at_revision,
status_at_revision (issues via Issue.revisions) status_at_revision (issues via Issue.revisions)
FeatureFile feature, kind ∈ {prototype, documentation}, name, current_version FeatureFile feature, kind ∈ {prototype, documentation}, name, current_version,
FeatureFileVersion file, version, storage_key, content_type, size, created_in_revision added_in_revision, removed_in_revision
FeatureFileVersion file, version, blob, original_name, content_type, size,
created_in_revision, uploaded_by
Implementation project, name, url, status ∈ {active, archived} Implementation project, name, url, status ∈ {active, archived}
FeatureImplStatus feature, implementation, status, feature_revision, updated_by (unique feature+impl) FeatureImplStatus feature, implementation, status, feature_revision, updated_by (unique feature+impl)
Issue project, number, title, body_md, author, status, duplicate_of, closed_at, Issue project, number, title, body_md, author, status, duplicate_of, closed_at,
features (M2M), revisions (M2M FeatureRevision) features (M2M), revisions (M2M FeatureRevision)
IssueEvent issue, actor, kind ∈ {status, linked, unlinked, revision, edited}, status, IssueEvent issue, actor, kind ∈ {status, linked, unlinked, revision, edited}, status,
previous_status, duplicate_of, feature, revision, old_title, old_body previous_status, duplicate_of, feature, revision, old_title, old_body
CommentThread project, kind ∈ {feature, line, issue} (Phase 4 adds file, image point), CommentThread project, kind ∈ {feature, line, issue, file, image}, file_version, x, y,
feature, issue, revision, line_start, line_end, author, feature, issue, revision, line_start, line_end, author,
resolved_by, resolved_at, promoted_to (OneToOne issue) resolved_by, resolved_at, promoted_to (OneToOne issue)
Comment thread, author, body_md, edited_at, deleted_at, deleted_by Comment thread, author, body_md, edited_at, deleted_at, deleted_by
@ -183,10 +186,11 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
### Phase 4 — Files and implementations (medium) ### Phase 4 — Files and implementations (medium)
- File upload to Tigris, versions, signed URLs, sandboxed iframe for HTML, image display. - File upload to Tigris, versions, signed URLs, sandboxed iframe for HTML, image display.
- Image-coordinate threads (Alpine for pin placement, list fallback). - Image-coordinate threads (plain JS for pin placement, number-field fallback).
- File-level threads. - File-level threads.
- Implementations: CRUD, URL, status; per-feature status table on the implementation page and per-implementation status on the feature page; "behind" indicator against current revision. - Implementations: CRUD, URL, status; per-feature status table on the implementation page and per-implementation status on the feature page; "behind" indicator against current revision.
- **Exit:** a feature with a prototype image has a pinned thread; an implementation shows one feature shipped and one behind. - **Exit:** a feature with a prototype image has a pinned thread; an implementation shows one feature shipped and one behind.
- Done: `tests/test_phase4_exit.py` covers the exit through the views. Pin placement, pin alignment, keyboard access to pins, the prototype sandbox and the upload form were checked once in headless Chromium.
### Phase 5 — Launch hardening (small) ### Phase 5 — Launch hardening (small)

View file

@ -0,0 +1,17 @@
# Generated by Django 5.2.18 on 2026-10-27 13:24
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('comments', '0002_initial'),
]
operations = [
migrations.RemoveConstraint(
model_name='commentthread',
name='thread_target',
),
]

View file

@ -0,0 +1,44 @@
# Generated by Django 5.2.18 on 2026-10-27 13:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comments', '0003_remove_commentthread_thread_target'),
('features', '0003_revision_files_change'),
('files', '0001_initial'),
('issues', '0001_initial'),
('projects', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='commentthread',
name='file_version',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='threads', to='files.featurefileversion'),
),
migrations.AddField(
model_name='commentthread',
name='x',
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name='commentthread',
name='y',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='commentthread',
name='kind',
field=models.CharField(choices=[('feature', 'Feature'), ('line', 'Lines of a feature description'), ('issue', 'Issue'), ('file', 'File'), ('image', 'Point on an image')], max_length=20),
),
migrations.AddConstraint(
model_name='commentthread',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('kind', 'feature'), ('feature__isnull', False), ('issue__isnull', True), ('line_end__isnull', True), ('line_start__isnull', True), ('revision__isnull', True), ('file_version__isnull', True), ('x__isnull', True), ('y__isnull', True)), models.Q(('kind', 'line'), ('line_end__gte', models.F('line_start')), ('line_start__gte', 1), ('revision__isnull', False), ('feature__isnull', False), ('issue__isnull', True), ('file_version__isnull', True), ('x__isnull', True), ('y__isnull', True)), models.Q(('feature__isnull', True), ('issue__isnull', False), ('kind', 'issue'), ('line_end__isnull', True), ('line_start__isnull', True), ('revision__isnull', True), ('file_version__isnull', True), ('x__isnull', True), ('y__isnull', True)), models.Q(('file_version__isnull', False), ('kind', 'file'), ('x__isnull', True), ('y__isnull', True), ('feature__isnull', False), ('issue__isnull', True), ('line_end__isnull', True), ('line_start__isnull', True), ('revision__isnull', True)), models.Q(('file_version__isnull', False), ('kind', 'image'), ('x__gte', 0), ('x__lte', 1), ('y__gte', 0), ('y__lte', 1), ('feature__isnull', False), ('issue__isnull', True), ('line_end__isnull', True), ('line_start__isnull', True), ('revision__isnull', True)), _connector='OR'), name='thread_target'),
),
]

View file

@ -4,6 +4,7 @@ from django.db.models import F, Q
from intent.core.models import BaseModel from intent.core.models import BaseModel
from intent.features.models import Feature from intent.features.models import Feature
from intent.files.models import FeatureFileVersion
from intent.issues.models import Issue from intent.issues.models import Issue
from intent.projects.models import Project from intent.projects.models import Project
@ -12,26 +13,42 @@ class ThreadKind(models.TextChoices):
FEATURE = "feature", "Feature" FEATURE = "feature", "Feature"
LINE = "line", "Lines of a feature description" LINE = "line", "Lines of a feature description"
ISSUE = "issue", "Issue" ISSUE = "issue", "Issue"
# Phase 4 adds whole-file and image-point threads. FILE = "file", "File"
IMAGE = "image", "Point on an image"
def _target_is_consistent(): def _target_is_consistent():
no_lines = Q(revision__isnull=True, line_start__isnull=True, line_end__isnull=True) no_lines = Q(revision__isnull=True, line_start__isnull=True, line_end__isnull=True)
feature = Q(kind=ThreadKind.FEATURE, feature__isnull=False, issue__isnull=True) & no_lines no_file = Q(file_version__isnull=True, x__isnull=True, y__isnull=True)
line = Q( on_feature = Q(feature__isnull=False, issue__isnull=True)
kind=ThreadKind.LINE, feature = Q(kind=ThreadKind.FEATURE) & on_feature & no_lines & no_file
feature__isnull=False, line = (
issue__isnull=True, Q(
revision__isnull=False, kind=ThreadKind.LINE,
line_start__gte=1, revision__isnull=False,
line_end__gte=F("line_start"), line_start__gte=1,
line_end__gte=F("line_start"),
)
& on_feature
& no_file
) )
issue = Q(kind=ThreadKind.ISSUE, issue__isnull=False, feature__isnull=True) & no_lines issue = Q(kind=ThreadKind.ISSUE, issue__isnull=False, feature__isnull=True) & no_lines & no_file
return feature | line | issue file = (
Q(kind=ThreadKind.FILE, file_version__isnull=False, x__isnull=True, y__isnull=True)
& on_feature
& no_lines
)
image = (
Q(kind=ThreadKind.IMAGE, file_version__isnull=False, x__gte=0, x__lte=1, y__gte=0, y__lte=1)
& on_feature
& no_lines
)
return feature | line | issue | file | image
class CommentThread(BaseModel): class CommentThread(BaseModel):
"""A conversation attached to a feature, some lines of its description, or an issue. """A conversation attached to a feature, lines of its description, a file, a point on an
image, or an issue. File and image threads also point at the feature, for listing.
An issue has exactly one thread: its discussion. Features can have many. An issue has exactly one thread: its discussion. Features can have many.
""" """
@ -48,6 +65,17 @@ class CommentThread(BaseModel):
revision = models.PositiveIntegerField(null=True, blank=True) revision = models.PositiveIntegerField(null=True, blank=True)
line_start = models.PositiveIntegerField(null=True, blank=True) line_start = models.PositiveIntegerField(null=True, blank=True)
line_end = models.PositiveIntegerField(null=True, blank=True) line_end = models.PositiveIntegerField(null=True, blank=True)
# File and image threads: the file version, and for images the point as fractions (0–1)
# of its width and height, so pins stay put at any display size.
file_version = models.ForeignKey(
FeatureFileVersion,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="threads",
)
x = models.FloatField(null=True, blank=True)
y = models.FloatField(null=True, blank=True)
author = models.ForeignKey( author = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+" settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
) )

View file

@ -11,14 +11,20 @@ from intent.access import (
) )
from intent.markdown import render, render_blocks, source_lines from intent.markdown import render, render_blocks, source_lines
from .models import Comment, ThreadKind from .models import Comment, CommentThread, ThreadKind
def prepare_threads(threads, user, access) -> list: def prepare_threads(threads, user, access) -> list:
"""Load threads with their comments, and attach what the templates need to each.""" """Load threads with their comments, and attach what the templates need to each."""
threads = list( threads = list(
threads.select_related( threads.select_related(
"project__org", "feature__project", "issue", "author", "resolved_by", "promoted_to" "project__org",
"feature__project",
"issue",
"file_version__file",
"author",
"resolved_by",
"promoted_to",
).prefetch_related(Prefetch("comments", queryset=Comment.objects.select_related("author"))) ).prefetch_related(Prefetch("comments", queryset=Comment.objects.select_related("author")))
) )
for thread in threads: for thread in threads:
@ -73,12 +79,59 @@ def feature_discussion(feature, user, access) -> dict:
} }
FILE_KINDS = (ThreadKind.FILE, ThreadKind.IMAGE)
def mark_file_threads(threads) -> None:
"""Pin numbers and positions for image threads, and whether a thread is on an old version.
Pins are numbered per file version in the order they were made.
"""
file_threads = [t for t in threads if t.kind in FILE_KINDS]
version_ids = {t.file_version_id for t in file_threads}
order = CommentThread.objects.filter(
file_version_id__in=version_ids, kind=ThreadKind.IMAGE
).values_list("pk", "file_version_id")
numbers, counts = {}, {}
for pk, version_id in order:
counts[version_id] = counts.get(version_id, 0) + 1
numbers[pk] = counts[version_id]
for thread in file_threads:
version = thread.file_version
thread.earlier = version.version != version.file.current_version
if thread.kind == ThreadKind.IMAGE:
thread.pin_number = numbers.get(thread.pk)
thread.x_pct = round(thread.x * 100, 1)
thread.y_pct = round(thread.y * 100, 1)
def file_discussion(file, version, user, access) -> dict:
"""Context for a file page: threads on the version shown, and threads on other versions."""
threads = prepare_threads(CommentThread.objects.filter(file_version__file=file), user, access)
mark_file_threads(threads)
shown = [t for t in threads if t.file_version_id == version.pk]
return {
"pins": [t for t in shown if t.kind == ThreadKind.IMAGE],
"file_threads": shown,
"other_threads": [t for t in threads if t.file_version_id != version.pk],
}
def thread_url(thread, anchor=None) -> str: def thread_url(thread, anchor=None) -> str:
"""Where a thread lives: its feature's page, or its issue's page.""" """Where a thread lives: its issue's page, its file's page, or its feature's page."""
project = thread.project project = thread.project
kwargs = {"org_slug": project.org.slug, "project_slug": project.slug} kwargs = {"org_slug": project.org.slug, "project_slug": project.slug}
if thread.kind == ThreadKind.ISSUE: if thread.kind == ThreadKind.ISSUE:
url = reverse("issues:detail", kwargs={**kwargs, "number": thread.issue.number}) url = reverse("issues:detail", kwargs={**kwargs, "number": thread.issue.number})
elif thread.kind in FILE_KINDS:
url = reverse(
"files:detail",
kwargs={
**kwargs,
"feature_key": thread.feature.key,
"file_id": thread.file_version.file_id,
},
)
else: else:
url = reverse("features:detail", kwargs={**kwargs, "feature_key": thread.feature.key}) url = reverse("features:detail", kwargs={**kwargs, "feature_key": thread.feature.key})
return f"{url}#{anchor or thread.dom_id}" return f"{url}#{anchor or thread.dom_id}"

View file

@ -58,6 +58,34 @@ def start_feature_thread(
return thread return thread
def start_file_thread(version, by, *, body, x=None, y=None) -> CommentThread:
"""Start a thread on a file's current version, or on a point of an image (x, y in 0–1)."""
file = version.file
_require_contribute(by, file.feature.project)
body = clean_body(body)
if file.is_removed:
raise ValidationError("This file was removed, so it can't take new comments.")
if version.version != file.current_version:
raise ValidationError("Comment on the latest version of the file.")
fields = {
"project": file.feature.project,
"feature": file.feature,
"file_version": version,
"author": by,
"kind": ThreadKind.FILE,
}
if x is not None or y is not None:
if not version.is_image:
raise ValidationError("Only images take comments pinned to a point.")
if x is None or y is None or not (0 <= x <= 1 and 0 <= y <= 1):
raise ValidationError("Choose a point on the image.")
fields.update(kind=ThreadKind.IMAGE, x=x, y=y)
with transaction.atomic():
thread = CommentThread.objects.create(**fields)
Comment.objects.create(thread=thread, author=by, body=body)
return thread
def reply(thread, by, *, body) -> Comment: def reply(thread, by, *, body) -> Comment:
_require_contribute(by, thread.project) _require_contribute(by, thread.project)
return Comment.objects.create(thread=thread, author=by, body=clean_body(body)) return Comment.objects.create(thread=thread, author=by, body=clean_body(body))

View file

@ -18,7 +18,7 @@ from intent.projects.decorators import project_view
from . import services from . import services
from .forms import CommentForm, LineThreadForm, PromoteForm from .forms import CommentForm, LineThreadForm, PromoteForm
from .models import Comment, CommentThread, ThreadKind from .models import Comment, CommentThread, ThreadKind
from .presenters import mark_line_threads, prepare_threads, thread_url from .presenters import mark_file_threads, mark_line_threads, prepare_threads, thread_url
def _is_htmx(request) -> bool: def _is_htmx(request) -> bool:
@ -27,7 +27,9 @@ def _is_htmx(request) -> bool:
def _get_thread(project, thread_id) -> CommentThread: def _get_thread(project, thread_id) -> CommentThread:
return get_object_or_404( return get_object_or_404(
CommentThread.objects.select_related("project__org", "feature__project", "issue"), CommentThread.objects.select_related(
"project__org", "feature__project", "issue", "file_version__file"
),
project=project, project=project,
pk=thread_id, pk=thread_id,
) )
@ -36,7 +38,11 @@ def _get_thread(project, thread_id) -> CommentThread:
def _get_comment(project, comment_id) -> Comment: def _get_comment(project, comment_id) -> Comment:
return get_object_or_404( return get_object_or_404(
Comment.objects.select_related( Comment.objects.select_related(
"author", "thread__project__org", "thread__feature__project", "thread__issue" "author",
"thread__project__org",
"thread__feature__project",
"thread__issue",
"thread__file_version__file",
), ),
thread__project=project, thread__project=project,
pk=comment_id, pk=comment_id,
@ -55,6 +61,7 @@ def render_thread(request, project, access, thread, *, focus=None, error=None, d
(prepared,) = prepare_threads(CommentThread.objects.filter(pk=thread.pk), request.user, access) (prepared,) = prepare_threads(CommentThread.objects.filter(pk=thread.pk), request.user, access)
if prepared.kind == ThreadKind.LINE: if prepared.kind == ThreadKind.LINE:
mark_line_threads(prepared.feature, [prepared]) mark_line_threads(prepared.feature, [prepared])
mark_file_threads([prepared])
prepared.draft = draft prepared.draft = draft
context["thread"] = prepared context["thread"] = prepared
return render(request, "comments/_thread.html", context) return render(request, "comments/_thread.html", context)

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.18 on 2026-10-27 13:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('features', '0002_initial'),
]
operations = [
migrations.AddField(
model_name='featurerevision',
name='files_change',
field=models.CharField(blank=True, max_length=300),
),
]

View file

@ -76,6 +76,8 @@ class FeatureRevision(BaseModel):
Feature, null=True, blank=True, on_delete=models.SET_NULL, related_name="+" Feature, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
) )
note = models.CharField(max_length=500) note = models.CharField(max_length=500)
# Set when the revision added, replaced or removed a file, e.g. "Added prototype “a.png”".
files_change = models.CharField(max_length=300, blank=True)
author = models.ForeignKey( author = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+" settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
) )

View file

@ -64,8 +64,9 @@ def _validate(feature) -> None:
feature.superseded_by = None feature.superseded_by = None
def _record(feature, by, note) -> FeatureRevision: def _record(feature, by, note, files_change="") -> FeatureRevision:
return FeatureRevision.objects.create( return FeatureRevision.objects.create(
files_change=files_change,
feature=feature, feature=feature,
revision=feature.current_revision, revision=feature.current_revision,
title=feature.title, title=feature.title,
@ -113,6 +114,23 @@ def create_feature(
return feature return feature
def commit_file_change(feature, by, *, note, files_change, issues=()) -> FeatureRevision:
"""Record a revision for a file added, replaced or removed. Fields are unchanged.
Call inside the transaction that changes the files, so both commit or neither does.
"""
_require_edit(by, feature.project)
note = note.strip()
if not note:
raise ValidationError("Explain the change in the change note.")
feature.refresh_from_db()
feature.current_revision += 1
feature.save(update_fields=["current_revision", "updated_at"])
revision = _record(feature, by, note, files_change)
_tie_to_issues(revision, by, issues, False)
return revision
def update_feature( def update_feature(
feature, by, *, note, issues=(), accept_issues=False, **changes feature, by, *, note, issues=(), accept_issues=False, **changes
) -> FeatureRevision: ) -> FeatureRevision:

View file

@ -1,10 +1,11 @@
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Prefetch from django.db.models import F, Prefetch
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from intent.comments.presenters import feature_discussion from intent.comments.presenters import feature_discussion
from intent.files.services import files_at
from intent.markdown import render as render_markdown from intent.markdown import render as render_markdown
from intent.markdown import render_blocks from intent.markdown import render_blocks
from intent.projects.decorators import project_view from intent.projects.decorators import project_view
@ -109,11 +110,52 @@ def feature_detail(request, project, access, feature_key):
"children": feature.children.select_related("project"), "children": feature.children.select_related("project"),
"supersedes": feature.supersedes.select_related("project"), "supersedes": feature.supersedes.select_related("project"),
"issues": feature.issues.select_related("project"), "issues": feature.issues.select_related("project"),
**feature_files(feature),
"implementations": implementation_rows(feature),
**feature_discussion(feature, request.user, access), **feature_discussion(feature, request.user, access),
} }
return render(request, "features/detail.html", context) return render(request, "features/detail.html", context)
def feature_files(feature) -> dict:
"""Current files on a feature, by kind, with how many threads each version has."""
from django.db.models import Count, Q
from intent.files.forms import AddFileForm
from intent.files.models import FeatureFileVersion
versions = (
FeatureFileVersion.objects.filter(
file__feature=feature,
file__removed_in_revision__isnull=True,
version=F("file__current_version"),
)
.select_related("file")
.annotate(thread_count=Count("threads", filter=Q(threads__resolved_at__isnull=True)))
.order_by("file__kind", "file__name")
)
return {"files": list(versions), "add_file_form": AddFileForm()}
def implementation_rows(feature) -> list[dict]:
"""Each implementation's status for this feature; no row means not started."""
from intent.implementations.models import FeatureStatus
statuses = {s.implementation_id: s for s in feature.impl_statuses.all()}
rows = []
for impl in feature.project.implementations.all():
row = statuses.get(impl.pk)
rows.append(
{
"impl": impl,
"label": row.get_status_display() if row else FeatureStatus.NOT_STARTED.label,
"revision": row.feature_revision if row else None,
"behind": bool(row) and row.feature_revision < feature.current_revision,
}
)
return rows
def _ancestors(feature) -> list[Feature]: def _ancestors(feature) -> list[Feature]:
chain, node = [], feature.parent chain, node = [], feature.parent
while node: while node:
@ -187,6 +229,8 @@ def _changes(revision, previous) -> list[str]:
changes.append("Parent") changes.append("Parent")
if revision.status != previous.status or revision.superseded_by_id != previous.superseded_by_id: if revision.status != previous.status or revision.superseded_by_id != previous.superseded_by_id:
changes.append(f"Status: {revision.get_status_display()}") changes.append(f"Status: {revision.get_status_display()}")
if revision.files_change:
changes.append(revision.files_change)
return changes return changes
@ -224,6 +268,7 @@ def feature_revision(request, project, access, feature_key, revision):
"feature": feature, "feature": feature,
"snapshot": snapshot, "snapshot": snapshot,
"blocks": render_blocks(snapshot.description), "blocks": render_blocks(snapshot.description),
"files": files_at(feature, snapshot.revision),
} }
return render(request, "features/revision.html", context) return render(request, "features/revision.html", context)

0
intent/files/__init__.py Normal file
View file

29
intent/files/admin.py Normal file
View file

@ -0,0 +1,29 @@
from django.contrib import admin
from .models import FeatureFile, FeatureFileVersion
class VersionInline(admin.TabularInline):
model = FeatureFileVersion
extra = 0
can_delete = False
fields = ["version", "original_name", "content_type", "size", "created_in_revision"]
readonly_fields = fields
def has_add_permission(self, request, obj=None):
return False
@admin.register(FeatureFile)
class FeatureFileAdmin(admin.ModelAdmin):
"""Read-only: file changes go through the app so they're recorded as revisions."""
list_display = ["name", "feature", "kind", "current_version", "removed_in_revision"]
list_filter = ["kind"]
inlines = [VersionInline]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False

6
intent/files/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class FilesConfig(AppConfig):
name = "intent.files"
label = "files"

30
intent/files/forms.py Normal file
View file

@ -0,0 +1,30 @@
from django import forms
from .models import FileKind
from .services import ALLOWED_LIST
NOTE_HELP = "Why is this changing? Kept in the feature's history."
class AddFileForm(forms.Form):
kind = forms.ChoiceField(
choices=FileKind.choices, widget=forms.RadioSelect, initial=FileKind.PROTOTYPE
)
upload = forms.FileField(label="File", help_text=f"Up to 25 MB: {ALLOWED_LIST}.")
note = forms.CharField(label="Change note", max_length=500, help_text=NOTE_HELP)
class ReplaceFileForm(forms.Form):
upload = forms.FileField(label="New version", help_text=f"Up to 25 MB: {ALLOWED_LIST}.")
note = forms.CharField(label="Change note", max_length=500, help_text=NOTE_HELP)
class RemoveFileForm(forms.Form):
note = forms.CharField(label="Change note", max_length=500, help_text=NOTE_HELP)
class FileThreadForm(forms.Form):
body = forms.CharField(widget=forms.Textarea)
# Percentages for people; stored as fractions.
across = forms.FloatField(min_value=0, max_value=100, required=False)
down = forms.FloatField(min_value=0, max_value=100, required=False)

View file

@ -0,0 +1,57 @@
# Generated by Django 5.2.18 on 2026-10-27 13:24
import django.db.models.deletion
import intent.core.models
import intent.files.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('features', '0003_revision_files_change'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='FeatureFile',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('kind', models.CharField(choices=[('prototype', 'Prototype'), ('documentation', 'Documentation')], max_length=20)),
('name', models.CharField(max_length=200)),
('current_version', models.PositiveIntegerField(default=1)),
('added_in_revision', models.PositiveIntegerField()),
('removed_in_revision', models.PositiveIntegerField(blank=True, null=True)),
('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='features.feature')),
],
options={
'ordering': ['kind', 'name', 'created_at'],
},
),
migrations.CreateModel(
name='FeatureFileVersion',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('version', models.PositiveIntegerField()),
('blob', models.FileField(max_length=300, upload_to=intent.files.models._upload_to)),
('original_name', models.CharField(max_length=200)),
('content_type', models.CharField(max_length=100)),
('size', models.PositiveBigIntegerField()),
('created_in_revision', models.PositiveIntegerField()),
('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='files.featurefile')),
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['file', 'version'],
'constraints': [models.UniqueConstraint(fields=('file', 'version'), name='fileversion_unique')],
},
),
]

View file

98
intent/files/models.py Normal file
View file

@ -0,0 +1,98 @@
from pathlib import PurePath
from django.conf import settings
from django.db import models
from django.utils.text import get_valid_filename
from intent.core.models import BaseModel, new_ulid
from intent.features.models import Feature
# Extension → content type. The type is decided here, never taken from the browser.
ALLOWED_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".avif": "image/avif",
".svg": "image/svg+xml",
".pdf": "application/pdf",
".md": "text/markdown",
".markdown": "text/markdown",
".txt": "text/plain",
".html": "text/html",
".htm": "text/html",
".zip": "application/zip",
}
MAX_BYTES = 25 * 1024 * 1024
class FileKind(models.TextChoices):
PROTOTYPE = "prototype", "Prototype"
DOCUMENTATION = "documentation", "Documentation"
class FeatureFile(BaseModel):
"""A file attached to a feature. Replacing it adds a version; old versions are kept."""
feature = models.ForeignKey(Feature, on_delete=models.CASCADE, related_name="files")
kind = models.CharField(max_length=20, choices=FileKind.choices)
name = models.CharField(max_length=200)
current_version = models.PositiveIntegerField(default=1)
added_in_revision = models.PositiveIntegerField()
# Removed files stay so old revisions and their threads still make sense.
removed_in_revision = models.PositiveIntegerField(null=True, blank=True)
class Meta:
ordering = ["kind", "name", "created_at"]
def __str__(self):
return f"{self.name} on {self.feature.key}"
@property
def is_removed(self) -> bool:
return self.removed_in_revision is not None
def _upload_to(instance, filename):
# An opaque folder per version, so names never collide and URLs reveal nothing.
return f"files/{new_ulid()}/{get_valid_filename(PurePath(filename).name)}"
class FeatureFileVersion(BaseModel):
file = models.ForeignKey(FeatureFile, on_delete=models.CASCADE, related_name="versions")
version = models.PositiveIntegerField()
blob = models.FileField(upload_to=_upload_to, max_length=300)
original_name = models.CharField(max_length=200)
content_type = models.CharField(max_length=100)
size = models.PositiveBigIntegerField()
created_in_revision = models.PositiveIntegerField()
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
)
class Meta:
constraints = [
models.UniqueConstraint(fields=["file", "version"], name="fileversion_unique"),
]
ordering = ["file", "version"]
def __str__(self):
return f"{self.original_name} v{self.version}"
@property
def is_image(self) -> bool:
return self.content_type.startswith("image/")
@property
def is_html(self) -> bool:
return self.content_type == "text/html"
@property
def is_text(self) -> bool:
return self.content_type in ("text/markdown", "text/plain")
@property
def opens_inline(self) -> bool:
"""Shown in the browser rather than downloaded."""
return self.content_type != "application/zip"

103
intent/files/services.py Normal file
View file

@ -0,0 +1,103 @@
"""File rules. Every add, replace or remove is also a feature revision with a change note."""
from pathlib import PurePath
from django.core.exceptions import ValidationError
from django.db import transaction
from intent.features.services import commit_file_change
from .models import ALLOWED_TYPES, MAX_BYTES, FeatureFile, FeatureFileVersion
ALLOWED_LIST = ", ".join(sorted({ext.lstrip(".") for ext in ALLOWED_TYPES}))
def check_upload(upload) -> tuple[str, str]:
"""Validate an upload. Returns its display name and content type."""
name = PurePath(upload.name or "").name.strip()[:200]
content_type = ALLOWED_TYPES.get(PurePath(name).suffix.lower())
if not name or content_type is None:
raise ValidationError(f"That type of file isn't allowed. Use one of: {ALLOWED_LIST}.")
if upload.size > MAX_BYTES:
raise ValidationError(f"Files can be up to {MAX_BYTES // (1024 * 1024)} MB.")
if upload.size == 0:
raise ValidationError("That file is empty.")
return name, content_type
def _add_version(file, by, upload, name, content_type, revision) -> FeatureFileVersion:
return FeatureFileVersion.objects.create(
file=file,
version=file.current_version,
blob=upload,
original_name=name,
content_type=content_type,
size=upload.size,
created_in_revision=revision.revision,
uploaded_by=by,
)
def add_file(feature, by, *, kind, upload, note, issues=()) -> FeatureFile:
name, content_type = check_upload(upload)
with transaction.atomic():
revision = commit_file_change(
feature,
by,
note=note,
files_change=f"Added {kind} “{name}”",
issues=issues,
)
file = FeatureFile.objects.create(
feature=feature, kind=kind, name=name, added_in_revision=revision.revision
)
_add_version(file, by, upload, name, content_type, revision)
return file
def replace_file(file, by, *, upload, note, issues=()) -> FeatureFileVersion:
"""A new version of the file. Earlier versions, and threads on them, are kept."""
name, content_type = check_upload(upload)
if file.is_removed:
raise ValidationError("This file was removed. Add it again as a new file.")
with transaction.atomic():
revision = commit_file_change(
file.feature,
by,
note=note,
files_change=f"Replaced “{file.name}” with version {file.current_version + 1}",
issues=issues,
)
file.current_version += 1
file.name = name
file.save(update_fields=["current_version", "name", "updated_at"])
return _add_version(file, by, upload, name, content_type, revision)
def remove_file(file, by, *, note) -> None:
if file.is_removed:
raise ValidationError("This file was already removed.")
with transaction.atomic():
revision = commit_file_change(
file.feature, by, note=note, files_change=f"Removed “{file.name}”"
)
file.removed_in_revision = revision.revision
file.save(update_fields=["removed_in_revision", "updated_at"])
def files_at(feature, revision: int) -> list[FeatureFileVersion]:
"""The version of each file that was current at a feature revision."""
versions = (
FeatureFileVersion.objects.filter(
file__feature=feature,
file__added_in_revision__lte=revision,
created_in_revision__lte=revision,
)
.exclude(file__removed_in_revision__lte=revision)
.select_related("file")
.order_by("file__kind", "file__name", "file_id", "-version")
)
latest = {}
for version in versions:
latest.setdefault(version.file_id, version)
return list(latest.values())

15
intent/files/urls.py Normal file
View file

@ -0,0 +1,15 @@
from django.urls import path
from . import views
app_name = "files"
# Mounted at /<org>/<project>/features/<feature_key>/files/.
urlpatterns = [
path("", views.file_add, name="add"),
path("<str:file_id>/", views.file_detail, name="detail"),
path("<str:file_id>/replace/", views.file_replace, name="replace"),
path("<str:file_id>/remove/", views.file_remove, name="remove"),
path("<str:file_id>/threads/", views.file_thread_create, name="thread_create"),
path("<str:file_id>/v<int:version>/<str:name>", views.file_raw, name="raw"),
]

236
intent/files/views.py Normal file
View file

@ -0,0 +1,236 @@
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.http import FileResponse, Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.http import content_disposition_header
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.http import require_POST
from intent.comments import services as comment_services
from intent.comments.presenters import file_discussion, thread_url
from intent.features.views import get_feature
from intent.markdown import render as render_markdown
from intent.projects.decorators import project_view
from . import services
from .forms import AddFileForm, FileThreadForm, RemoveFileForm, ReplaceFileForm
from .models import FeatureFile, FeatureFileVersion
# Uploaded HTML runs in a sandbox with an opaque origin: it can't read the app's cookies or
# storage, call the app, submit forms, or navigate the page. Other files can't run scripts.
HTML_CSP = (
"sandbox allow-scripts allow-modals allow-popups; default-src 'none'; "
"script-src 'unsafe-inline' https:; style-src 'unsafe-inline' https:; "
"img-src data: blob: https:; font-src data: https:; media-src data: blob: https:; "
"connect-src 'none'; form-action 'none'"
)
FILE_CSP = "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"
TEXT_PREVIEW_BYTES = 1_000_000
def _get_file(project, feature_key, file_id) -> FeatureFile:
feature = get_feature(project, feature_key)
return get_object_or_404(
FeatureFile.objects.select_related("feature__project__org"), feature=feature, pk=file_id
)
def _file_redirect(file):
feature = file.feature
return redirect(
"files:detail",
org_slug=feature.project.org.slug,
project_slug=feature.project.slug,
feature_key=feature.key,
file_id=file.pk,
)
def _feature_redirect(feature, anchor="files"):
response = redirect(
"features:detail",
org_slug=feature.project.org.slug,
project_slug=feature.project.slug,
feature_key=feature.key,
)
response["Location"] += f"#{anchor}"
return response
def _errors(request, exc_or_form):
if isinstance(exc_or_form, ValidationError):
messages_ = exc_or_form.messages
else:
messages_ = [e for errors in exc_or_form.errors.values() for e in errors]
for message in messages_:
messages.error(request, message)
@project_view(require="can_edit")
@require_POST
def file_add(request, project, access, feature_key):
feature = get_feature(project, feature_key)
form = AddFileForm(request.POST, request.FILES)
if not form.is_valid():
_errors(request, form)
return _feature_redirect(feature)
try:
file = services.add_file(
feature,
request.user,
kind=form.cleaned_data["kind"],
upload=form.cleaned_data["upload"],
note=form.cleaned_data["note"],
)
except ValidationError as exc:
_errors(request, exc)
return _feature_redirect(feature)
messages.success(request, f"Added {file.name} in revision {file.added_in_revision}.")
return _file_redirect(file)
def _text_preview(version) -> tuple[str, bool]:
with version.blob.open("rb") as f:
data = f.read(TEXT_PREVIEW_BYTES + 1)
return data[:TEXT_PREVIEW_BYTES].decode("utf-8", "replace"), len(data) > TEXT_PREVIEW_BYTES
@project_view()
def file_detail(request, project, access, feature_key, file_id):
file = _get_file(project, feature_key, file_id)
versions = list(file.versions.select_related("uploaded_by").order_by("-version"))
number = request.GET.get("version", "")
if number.isdigit():
version = next((v for v in versions if v.version == int(number)), None)
if version is None:
raise Http404
else:
version = versions[0]
preview = truncated = None
if version.is_text:
text, truncated = _text_preview(version)
if version.content_type == "text/markdown":
preview = render_markdown(text)
else:
preview = text
is_current = version.version == file.current_version and not file.is_removed
context = {
"project": project,
"access": access,
"feature": file.feature,
"file": file,
"version": version,
"versions": versions,
"is_current": is_current,
"preview": preview,
"truncated": truncated,
"replace_form": ReplaceFileForm(),
"remove_form": RemoveFileForm(),
**file_discussion(file, version, request.user, access),
}
return render(request, "files/detail.html", context)
@project_view(require="can_edit")
@require_POST
def file_replace(request, project, access, feature_key, file_id):
file = _get_file(project, feature_key, file_id)
form = ReplaceFileForm(request.POST, request.FILES)
if not form.is_valid():
_errors(request, form)
return _file_redirect(file)
try:
version = services.replace_file(file, request.user, **form.cleaned_data)
except ValidationError as exc:
_errors(request, exc)
else:
messages.success(request, f"Uploaded version {version.version} of {file.name}.")
return _file_redirect(file)
@project_view(require="can_edit")
@require_POST
def file_remove(request, project, access, feature_key, file_id):
file = _get_file(project, feature_key, file_id)
form = RemoveFileForm(request.POST)
if not form.is_valid():
_errors(request, form)
return _file_redirect(file)
try:
services.remove_file(file, request.user, note=form.cleaned_data["note"])
except ValidationError as exc:
_errors(request, exc)
return _file_redirect(file)
messages.success(request, f"Removed {file.name}.")
return _feature_redirect(file.feature)
@project_view(require="can_contribute")
@require_POST
def file_thread_create(request, project, access, feature_key, file_id):
file = _get_file(project, feature_key, file_id)
version = get_object_or_404(file.versions, version=file.current_version)
form = FileThreadForm(request.POST)
draft = request.POST.get("body", "")
try:
if not form.is_valid():
raise ValidationError([e for errors in form.errors.values() for e in errors])
data = form.cleaned_data
point = {}
if data["across"] is not None or data["down"] is not None:
point = {
"x": None if data["across"] is None else data["across"] / 100,
"y": None if data["down"] is None else data["down"] / 100,
}
thread = comment_services.start_file_thread(
version, request.user, body=data["body"], **point
)
except ValidationError as exc:
context = {
"project": project,
"access": access,
"error": " ".join(exc.messages),
"draft": draft,
"back": _file_redirect(file)["Location"],
}
return render(request, "comments/not_posted.html", context, status=400)
return redirect(thread_url(thread))
def _served_type(version) -> str:
# Markdown is shown as plain text when opened directly; it's rendered on the file page.
if version.is_text:
return "text/plain; charset=utf-8"
return version.content_type
@project_view()
@xframe_options_sameorigin # HTML prototypes are framed by the file page.
def file_raw(request, project, access, feature_key, file_id, version, name):
"""The file itself, after the same permission check as the project's pages.
On S3-compatible storage this hands off to a short-lived signed URL on the bucket's
origin. Otherwise the app streams it, with a CSP sandbox so it can't act as the app.
"""
file = _get_file(project, feature_key, file_id)
v = get_object_or_404(FeatureFileVersion, file=file, version=version)
disposition = content_disposition_header(not v.opens_inline, v.original_name)
storage = v.blob.storage
if getattr(storage, "querystring_auth", False):
url = storage.url(
v.blob.name,
parameters={
"ResponseContentType": _served_type(v),
"ResponseContentDisposition": disposition,
},
)
return redirect(url)
response = FileResponse(v.blob.open("rb"), content_type=_served_type(v))
response["Content-Disposition"] = disposition
response["X-Content-Type-Options"] = "nosniff"
response["Cache-Control"] = "private, max-age=300"
if v.is_html:
response["Content-Security-Policy"] = HTML_CSP
elif v.content_type != "application/pdf": # Browsers won't show PDFs in a sandbox.
response["Content-Security-Policy"] = FILE_CSP
return response

View file

View file

@ -0,0 +1,10 @@
from django.contrib import admin
from .models import Implementation
@admin.register(Implementation)
class ImplementationAdmin(admin.ModelAdmin):
list_display = ["name", "project", "status", "url"]
list_filter = ["status"]
search_fields = ["name"]

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ImplementationsConfig(AppConfig):
name = "intent.implementations"
label = "implementations"

View file

@ -0,0 +1,21 @@
from django import forms
from .models import ImplementationStatus
class ImplementationForm(forms.Form):
name = forms.CharField(max_length=100)
url = forms.URLField(
label="Link",
max_length=500,
assume_scheme="https",
help_text="The repository or project page, e.g. on Forgejo.",
)
class ImplementationEditForm(ImplementationForm):
status = forms.ChoiceField(
choices=ImplementationStatus.choices,
widget=forms.RadioSelect,
help_text="Archived implementations keep their history but are listed separately.",
)

View file

@ -0,0 +1,56 @@
# Generated by Django 5.2.18 on 2026-10-27 13:24
import django.db.models.deletion
import intent.core.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('features', '0003_revision_files_change'),
('projects', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Implementation',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=100)),
('url', models.URLField(max_length=500)),
('status', models.CharField(choices=[('active', 'Active'), ('archived', 'Archived')], default='active', max_length=20)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='implementations', to='projects.project')),
],
options={
'ordering': ['status', 'name'],
},
),
migrations.CreateModel(
name='FeatureImplStatus',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('status', models.CharField(choices=[('na', 'N/A'), ('not_started', 'Not started'), ('in_progress', 'In progress'), ('shipped', 'Shipped'), ('retired', 'Retired')], max_length=20)),
('feature_revision', models.PositiveIntegerField()),
('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='impl_statuses', to='features.feature')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('implementation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_statuses', to='implementations.implementation')),
],
),
migrations.AddConstraint(
model_name='implementation',
constraint=models.UniqueConstraint(fields=('project', 'name'), name='implementation_unique_name'),
),
migrations.AddConstraint(
model_name='featureimplstatus',
constraint=models.UniqueConstraint(fields=('feature', 'implementation'), name='featureimplstatus_unique'),
),
]

View file

@ -0,0 +1,71 @@
from django.conf import settings
from django.db import models
from intent.core.models import BaseModel
from intent.features.models import Feature
from intent.projects.models import Project
class ImplementationStatus(models.TextChoices):
ACTIVE = "active", "Active"
ARCHIVED = "archived", "Archived"
class Implementation(BaseModel):
"""An app or package that implements the project. In V1 just a name and a link."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="implementations")
name = models.CharField(max_length=100)
url = models.URLField(max_length=500)
status = models.CharField(
max_length=20, choices=ImplementationStatus.choices, default=ImplementationStatus.ACTIVE
)
class Meta:
constraints = [
models.UniqueConstraint(fields=["project", "name"], name="implementation_unique_name"),
]
ordering = ["status", "name"]
def __str__(self):
return self.name
class FeatureStatus(models.TextChoices):
NOT_APPLICABLE = "na", "N/A"
NOT_STARTED = "not_started", "Not started"
IN_PROGRESS = "in_progress", "In progress"
SHIPPED = "shipped", "Shipped"
RETIRED = "retired", "Retired"
class FeatureImplStatus(BaseModel):
"""Where one implementation stands on one feature, as of a feature revision.
No row means "not started". A row stamped with an older revision than the feature's
current one is "behind": the feature changed since the status was last confirmed.
"""
feature = models.ForeignKey(Feature, on_delete=models.CASCADE, related_name="impl_statuses")
implementation = models.ForeignKey(
Implementation, on_delete=models.CASCADE, related_name="feature_statuses"
)
status = models.CharField(max_length=20, choices=FeatureStatus.choices)
feature_revision = models.PositiveIntegerField()
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["feature", "implementation"], name="featureimplstatus_unique"
),
]
def __str__(self):
return f"{self.feature.key} in {self.implementation}: {self.status}"
@property
def is_behind(self) -> bool:
return self.feature_revision < self.feature.current_revision

View file

@ -0,0 +1,74 @@
"""Implementation rules. Same conventions as intent.orgs.services."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.validators import URLValidator
from django.db import transaction
from intent.access import project_access
from .models import FeatureImplStatus, FeatureStatus, Implementation
_validate_url = URLValidator(schemes=["http", "https"])
def _require_edit(by, project):
if not project_access(by, project).can_edit:
raise PermissionDenied
def _clean(project, name, url, exclude_pk=None):
name, url = name.strip(), url.strip()
if not name:
raise ValidationError("Give the implementation a name.")
_validate_url(url)
taken = project.implementations.filter(name__iexact=name).exclude(pk=exclude_pk)
if taken.exists():
raise ValidationError(f"There's already an implementation called {name}.")
return name, url
def create_implementation(project, by, *, name, url) -> Implementation:
_require_edit(by, project)
name, url = _clean(project, name, url)
return Implementation.objects.create(project=project, name=name, url=url)
def update_implementation(impl, by, *, name, url, status) -> None:
_require_edit(by, impl.project)
impl.name, impl.url = _clean(impl.project, name, url, exclude_pk=impl.pk)
impl.status = status
impl.save(update_fields=["name", "url", "status", "updated_at"])
def delete_implementation(impl, by) -> None:
if not project_access(by, impl.project).can_admin:
raise PermissionDenied
impl.delete()
def set_statuses(impl, by, changes) -> int:
"""Apply {feature: (status, confirm)} for one implementation. Returns rows changed.
A changed status is stamped with the feature's current revision. `confirm` re-stamps an
unchanged status, meaning "still true as of the current revision".
"""
_require_edit(by, impl.project)
existing = {s.feature_id: s for s in impl.feature_statuses.all()}
changed = 0
with transaction.atomic():
for feature, (status, confirm) in changes.items():
if feature.project_id != impl.project_id:
raise ValidationError("Features must be in the implementation's project.")
row = existing.get(feature.pk)
current = row.status if row else FeatureStatus.NOT_STARTED
behind = row is not None and row.feature_revision < feature.current_revision
if status == current and not (confirm and behind):
continue
if row is None:
row = FeatureImplStatus(feature=feature, implementation=impl)
row.status = status
row.feature_revision = feature.current_revision
row.updated_by = by
row.save()
changed += 1
return changed

View file

@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = "implementations"
urlpatterns = [
path("", views.implementation_list, name="list"),
path("new/", views.implementation_create, name="create"),
path("<str:impl_id>/", views.implementation_detail, name="detail"),
path("<str:impl_id>/edit/", views.implementation_edit, name="edit"),
path("<str:impl_id>/statuses/", views.implementation_statuses, name="statuses"),
path("<str:impl_id>/delete/", views.implementation_delete, name="delete"),
]

View file

@ -0,0 +1,155 @@
from collections import Counter
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from intent.projects.decorators import project_view
from . import services
from .forms import ImplementationEditForm, ImplementationForm
from .models import FeatureStatus, Implementation, ImplementationStatus
def _get(project, impl_id) -> Implementation:
return get_object_or_404(
Implementation.objects.select_related("project__org"), project=project, pk=impl_id
)
def _detail_redirect(impl, anchor=""):
response = redirect(
"implementations:detail",
org_slug=impl.project.org.slug,
project_slug=impl.project.slug,
impl_id=impl.pk,
)
if anchor:
response["Location"] += f"#{anchor}"
return response
def status_rows(impl, features) -> list[dict]:
"""One row per feature: its status in this implementation and whether it's behind."""
rows_by_feature = {s.feature_id: s for s in impl.feature_statuses.select_related("updated_by")}
rows = []
for feature in features:
row = rows_by_feature.get(feature.pk)
rows.append(
{
"feature": feature,
"status": row.status if row else FeatureStatus.NOT_STARTED,
"label": row.get_status_display() if row else FeatureStatus.NOT_STARTED.label,
"revision": row.feature_revision if row else None,
"behind": bool(row) and row.feature_revision < feature.current_revision,
"updated_by": row.updated_by if row else None,
}
)
return rows
def _summary(rows) -> list[tuple[str, int]]:
counts = Counter(r["status"] for r in rows)
summary = [(label, counts[value]) for value, label in FeatureStatus.choices if counts[value]]
behind = sum(r["behind"] for r in rows)
if behind:
summary.append(("Behind the current revision", behind))
return summary
@project_view()
def implementation_list(request, project, access):
impls = list(project.implementations.all())
features = list(project.features.select_related("project"))
for impl in impls:
impl.summary = _summary(status_rows(impl, features))
context = {
"project": project,
"access": access,
"active": [i for i in impls if i.status == ImplementationStatus.ACTIVE],
"archived": [i for i in impls if i.status == ImplementationStatus.ARCHIVED],
}
return render(request, "implementations/list.html", context)
@project_view(require="can_edit")
def implementation_create(request, project, access):
form = ImplementationForm(request.POST or None)
if request.method == "POST" and form.is_valid():
try:
impl = services.create_implementation(project, request.user, **form.cleaned_data)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, f"Added {impl.name}.")
return _detail_redirect(impl)
context = {"project": project, "access": access, "form": form}
return render(request, "implementations/form.html", context)
@project_view()
def implementation_detail(request, project, access, impl_id):
impl = _get(project, impl_id)
features = list(project.features.select_related("project"))
rows = status_rows(impl, features)
context = {
"project": project,
"access": access,
"impl": impl,
"rows": rows,
"summary": _summary(rows),
"choices": FeatureStatus.choices,
}
return render(request, "implementations/detail.html", context)
@project_view(require="can_edit")
@require_POST
def implementation_statuses(request, project, access, impl_id):
impl = _get(project, impl_id)
valid = set(FeatureStatus.values)
changes = {}
for feature in project.features.select_related("project"):
status = request.POST.get(f"status-{feature.pk}")
if status is None:
continue
if status not in valid:
messages.error(request, f"Choose a status for {feature.key}.")
return _detail_redirect(impl)
changes[feature] = (status, request.POST.get(f"confirm-{feature.pk}") == "on")
changed = services.set_statuses(impl, request.user, changes)
if changed:
messages.success(request, f"Updated {changed} status{'es' if changed != 1 else ''}.")
else:
messages.info(request, "Nothing changed.")
return _detail_redirect(impl)
@project_view(require="can_edit")
def implementation_edit(request, project, access, impl_id):
impl = _get(project, impl_id)
initial = {"name": impl.name, "url": impl.url, "status": impl.status}
form = ImplementationEditForm(request.POST or None, initial=initial)
if request.method == "POST" and form.is_valid():
try:
services.update_implementation(impl, request.user, **form.cleaned_data)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, f"Saved {impl.name}.")
return _detail_redirect(impl)
context = {"project": project, "access": access, "impl": impl, "form": form}
return render(request, "implementations/form.html", context)
@project_view(require="can_admin")
@require_POST
def implementation_delete(request, project, access, impl_id):
impl = _get(project, impl_id)
if request.POST.get("confirm") != impl.name:
messages.error(request, f"Type “{impl.name}” exactly to delete it.")
return _detail_redirect(impl, "delete")
services.delete_implementation(impl, request.user)
messages.success(request, f"Deleted {impl.name}.")
return redirect("implementations:list", org_slug=project.org.slug, project_slug=project.slug)

View file

@ -164,6 +164,11 @@ def promotion_text(thread) -> tuple[str, str]:
where = thread.feature.key where = thread.feature.key
if thread.kind == ThreadKind.LINE: if thread.kind == ThreadKind.LINE:
where += f", lines {thread.line_start}–{thread.line_end} of revision {thread.revision}" where += f", lines {thread.line_start}–{thread.line_end} of revision {thread.revision}"
elif thread.kind in (ThreadKind.FILE, ThreadKind.IMAGE):
version = thread.file_version
where += f", file “{version.original_name}” version {version.version}"
if thread.kind == ThreadKind.IMAGE:
where += f", at {thread.x:.0%} across and {thread.y:.0%} down"
parts = [f"From a comment thread on {where}."] parts = [f"From a comment thread on {where}."]
if thread.kind == ThreadKind.LINE: if thread.kind == ThreadKind.LINE:
snapshot = thread.feature.revisions.filter(revision=thread.revision).first() snapshot = thread.feature.revisions.filter(revision=thread.revision).first()

View file

@ -146,6 +146,63 @@ form.inline {
outline-offset: 4px; outline-offset: 4px;
} }
/* Files. Pins are an SVG overlay positioned in percentages, so they track the image. */
.pinboard {
position: relative;
display: inline-block;
margin: 0;
}
.pinboard img {
display: block;
}
.pinboard.placing img {
cursor: crosshair;
}
.pins {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.pins a {
pointer-events: auto;
}
.pins circle {
fill: Mark;
stroke: MarkText;
stroke-width: 2;
}
.pins circle.pending {
fill: none;
stroke-dasharray: 4 3;
}
.pins text {
fill: MarkText;
font: bold 12px sans-serif;
text-anchor: middle;
dominant-baseline: central;
}
.pins a:focus-visible circle {
stroke-width: 4;
}
.prototype {
width: 100%;
height: 70vh;
border: 1px solid;
}
pre.quote { pre.quote {
white-space: pre-wrap; white-space: pre-wrap;
padding-inline-start: 0.75rem; padding-inline-start: 0.75rem;

View file

@ -7,3 +7,54 @@ document.addEventListener("htmx:afterSettle", function () {
target.removeAttribute("data-autofocus"); target.removeAttribute("data-autofocus");
target.focus(); target.focus();
}); });
// Image pins. The across/down number fields are the real controls and work without this;
// clicking the image just fills them in and shows where the pin will go.
(function () {
var SVG = "http://www.w3.org/2000/svg";
function fields() {
return {
across: document.getElementById("pin-across"),
down: document.getElementById("pin-down"),
};
}
function showPending(board, across, down) {
var svg = board.querySelector("[data-pins]");
var pin = svg.querySelector(".pending");
if (across === "" || down === "") {
if (pin) pin.remove();
return;
}
if (!pin) {
pin = document.createElementNS(SVG, "circle");
pin.setAttribute("class", "pending");
pin.setAttribute("r", "12");
svg.appendChild(pin);
}
pin.setAttribute("cx", across + "%");
pin.setAttribute("cy", down + "%");
}
document.addEventListener("click", function (event) {
var board = event.target.closest(".pinboard.placing");
if (!board || event.target.closest("a")) return;
var f = fields();
if (!f.across || !f.down) return;
var box = board.querySelector("img").getBoundingClientRect();
var clamp = function (v) { return Math.min(100, Math.max(0, v)); };
f.across.value = clamp(((event.clientX - box.left) / box.width) * 100).toFixed(1);
f.down.value = clamp(((event.clientY - box.top) / box.height) * 100).toFixed(1);
showPending(board, f.across.value, f.down.value);
var comment = document.getElementById("file-comment");
if (comment) comment.focus();
});
document.addEventListener("input", function (event) {
if (event.target.id !== "pin-across" && event.target.id !== "pin-down") return;
var board = document.querySelector(".pinboard.placing");
var f = fields();
if (board) showPending(board, f.across.value, f.down.value);
});
})();

View file

@ -6,7 +6,7 @@
{% if error %}<p role="alert"><strong>Error:</strong> {{ error }}</p>{% endif %} {% if error %}<p role="alert"><strong>Error:</strong> {{ error }}</p>{% endif %}
<details{% if not thread.is_resolved or error or focus %} open{% endif %}> <details{% if not thread.is_resolved or error or focus %} open{% endif %}>
<summary> <summary>
Thread by {{ thread.author.username|default:"deleted user" }}, {% if thread.pin_number %}Pin {{ thread.pin_number }}: {% endif %}Thread by {{ thread.author.username|default:"deleted user" }},
{{ thread.live_count }} comment{{ thread.live_count|pluralize }}{% if thread.promoted_to %}, became issue {{ thread.promoted_to.key }}{% elif thread.is_resolved %}, resolved{% endif %} {{ thread.live_count }} comment{{ thread.live_count|pluralize }}{% if thread.promoted_to %}, became issue {{ thread.promoted_to.key }}{% elif thread.is_resolved %}, resolved{% endif %}
</summary> </summary>
@ -19,6 +19,13 @@
<pre class="quote">{{ thread.quoted }}</pre> <pre class="quote">{{ thread.quoted }}</pre>
{% endif %} {% endif %}
{% if thread.kind == "image" or thread.kind == "file" %}
<p class="muted">
{% if thread.kind == "image" %}Pinned at {{ thread.x_pct }}% across, {{ thread.y_pct }}% down{% else %}About the whole file{% endif %}{% if thread.earlier %},
on <a href="{% url 'files:detail' org_slug=project.org.slug project_slug=project.slug feature_key=thread.feature.key file_id=thread.file_version.file_id %}?version={{ thread.file_version.version }}">version {{ thread.file_version.version }}</a>{% endif %}.
</p>
{% endif %}
{% if thread.promoted_to %} {% if thread.promoted_to %}
<p>Continued in issue <a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=thread.promoted_to.number %}">{{ thread.promoted_to.key }} {{ thread.promoted_to.title }}</a>.</p> <p>Continued in issue <a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=thread.promoted_to.number %}">{{ thread.promoted_to.key }} {{ thread.promoted_to.title }}</a>.</p>
{% endif %} {% endif %}

View file

@ -94,6 +94,63 @@
</section> </section>
{% endif %} {% endif %}
<section id="files" aria-labelledby="files-heading">
<h2 id="files-heading">Files</h2>
{% if files %}
<ul>
{% for v in files %}
<li>
<a href="{% url 'files:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=v.file_id %}">{{ v.original_name }}</a>
<span class="muted">({{ v.file.get_kind_display|lower }}, version {{ v.version }}{% if v.thread_count %}, {{ v.thread_count }} open thread{{ v.thread_count|pluralize }}{% endif %})</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No files.</p>
{% endif %}
{% if access.can_edit %}
<details>
<summary>Add a file</summary>
<form method="post" enctype="multipart/form-data" action="{% url 'files:add' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">
{% csrf_token %}
{{ add_file_form.as_p }}
<button type="submit">Add file</button>
</form>
</details>
{% endif %}
</section>
<section id="implementations" aria-labelledby="implementations-heading">
<h2 id="implementations-heading">Implementations</h2>
{% if implementations %}
<table>
<thead>
<tr>
<th scope="col">Implementation</th>
<th scope="col">Status</th>
<th scope="col">As of revision</th>
</tr>
</thead>
<tbody>
{% for row in implementations %}
<tr>
<th scope="row">
<a href="{% url 'implementations:detail' org_slug=project.org.slug project_slug=project.slug impl_id=row.impl.pk %}#{{ feature.key }}">{{ row.impl.name }}</a>
{% if row.impl.status == "archived" %}<span class="muted">(archived)</span>{% endif %}
</th>
<td>{{ row.label }}</td>
<td>
{% if row.revision %}{{ row.revision }}{% if row.behind %} <strong>behind</strong> <span class="visually-hidden">the current revision, {{ feature.current_revision }}</span>{% endif %}{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No implementations yet. <a href="{% url 'implementations:list' org_slug=project.org.slug project_slug=project.slug %}">Add one</a>.</p>
{% endif %}
</section>
<section id="issues" aria-labelledby="issues-heading"> <section id="issues" aria-labelledby="issues-heading">
<h2 id="issues-heading">Issues</h2> <h2 id="issues-heading">Issues</h2>
{% if issues %} {% if issues %}

View file

@ -31,4 +31,15 @@
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
{% if files %}
<section aria-labelledby="files-heading">
<h2 id="files-heading">Files at this revision</h2>
<ul>
{% for v in files %}
<li><a href="{% url 'files:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=v.file_id %}?version={{ v.version }}">{{ v.original_name }}</a> <span class="muted">({{ v.file.get_kind_display|lower }}, version {{ v.version }})</span></li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endblock %} {% endblock %}

138
templates/files/detail.html Normal file
View file

@ -0,0 +1,138 @@
{% extends "projects/base.html" %}
{% block page_title %}{{ version.original_name }} · {{ feature.key }}{% endblock %}
{% block project_content %}
{% url 'files:raw' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=file.pk version=version.version name=version.original_name as raw_url %}
{% url 'files:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=file.pk as file_url %}
<p><a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}#files">Back to {{ feature.key }} {{ feature.title }}</a></p>
<h1>{{ version.original_name }} <span class="muted">version {{ version.version }}</span></h1>
<p>
{{ file.get_kind_display }} for {{ feature.key }}.
Uploaded by {{ version.uploaded_by.username|default:"deleted user" }}
on <time datetime="{{ version.created_at|date:'c' }}">{{ version.created_at|date:"j M Y" }}</time>,
{{ version.size|filesizeformat }}.
<a href="{{ raw_url }}"{% if not version.opens_inline %} download{% endif %}>{% if version.opens_inline %}Open{% else %}Download{% endif %}<span class="visually-hidden"> {{ version.original_name }}</span></a>
</p>
{% if file.is_removed %}
<p role="note"><strong>Removed</strong> from the feature in revision {{ file.removed_in_revision }}. Its versions and comments are kept.</p>
{% elif not is_current %}
<p role="note"><strong>Older version.</strong> <a href="{{ file_url }}">See the latest, version {{ file.current_version }}</a>.</p>
{% endif %}
{% if version.is_image %}
<figure class="pinboard{% if is_current and access.can_contribute %} placing{% endif %}" data-pinboard>
<img src="{{ raw_url }}" alt="{{ version.original_name }}, version {{ version.version }}.{% if pins %} Pinned comments are listed below the image.{% endif %}">
<svg class="pins" role="group" aria-label="Pins" data-pins>
{% for t in pins %}
<a href="#thread-{{ t.pk }}" aria-label="Pin {{ t.pin_number }}, thread by {{ t.author.username|default:'deleted user' }}">
<circle cx="{{ t.x_pct }}%" cy="{{ t.y_pct }}%" r="12"></circle>
<text x="{{ t.x_pct }}%" y="{{ t.y_pct }}%">{{ t.pin_number }}</text>
</a>
{% endfor %}
</svg>
</figure>
{% elif version.is_html %}
{# Sandboxed without allow-same-origin: the prototype runs in an opaque origin. #}
<iframe class="prototype" src="{{ raw_url }}" sandbox="allow-scripts allow-modals allow-popups" title="Prototype: {{ version.original_name }}"></iframe>
{% elif version.is_text %}
{% if version.content_type == "text/markdown" %}
<div class="prose">{{ preview }}</div>
{% else %}
<pre class="quote">{{ preview }}</pre>
{% endif %}
{% if truncated %}<p class="muted">Only the start of the file is shown. Open it to see everything.</p>{% endif %}
{% endif %}
<section id="comments" aria-labelledby="comments-heading">
<h2 id="comments-heading">Comments on this version</h2>
{% for thread in file_threads %}
{% include "comments/_thread.html" %}
{% empty %}
<p class="muted">No comments yet.</p>
{% endfor %}
{% if is_current and access.can_contribute %}
<form method="post" action="{% url 'files:thread_create' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=file.pk %}">
{% csrf_token %}
{% if version.is_image %}
<fieldset>
<legend>Pin to a point (optional)</legend>
<p class="muted" id="pin-help">Click the image to place a pin, or enter its position.</p>
<label for="pin-across">Across (%)</label>
<input type="number" id="pin-across" name="across" min="0" max="100" step="0.1" aria-describedby="pin-help">
<label for="pin-down">Down (%)</label>
<input type="number" id="pin-down" name="down" min="0" max="100" step="0.1" aria-describedby="pin-help">
</fieldset>
{% endif %}
<label for="file-comment">Comment on {{ version.original_name }}</label>
<textarea id="file-comment" name="body" rows="4" required></textarea>
<button type="submit">Comment</button>
</form>
{% elif not user.is_authenticated and project.open_contribution %}
<p><a href="{% url 'account_login' %}?next={{ request.path|urlencode }}">Sign in</a> to comment.</p>
{% endif %}
</section>
{% if other_threads %}
<section aria-labelledby="other-heading">
<h2 id="other-heading">Comments on other versions</h2>
{% for thread in other_threads %}
{% include "comments/_thread.html" %}
{% endfor %}
</section>
{% endif %}
<section aria-labelledby="versions-heading">
<h2 id="versions-heading">Versions</h2>
<table>
<thead>
<tr>
<th scope="col">Version</th>
<th scope="col">Name</th>
<th scope="col">Uploaded</th>
<th scope="col">Feature revision</th>
<th scope="col">Size</th>
</tr>
</thead>
<tbody>
{% for v in versions %}
<tr>
<th scope="row">
{% if v.version == version.version %}{{ v.version }} (shown){% else %}<a href="{{ file_url }}?version={{ v.version }}">{{ v.version }}</a>{% endif %}
</th>
<td>{{ v.original_name }}</td>
<td><time datetime="{{ v.created_at|date:'c' }}">{{ v.created_at|date:"j M Y" }}</time> by {{ v.uploaded_by.username|default:"deleted user" }}</td>
<td><a href="{% url 'features:history' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">{{ v.created_in_revision }}</a></td>
<td>{{ v.size|filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if access.can_edit and not file.is_removed %}
<section aria-labelledby="change-heading">
<h2 id="change-heading">Change this file</h2>
<p>Each change is a new revision of {{ feature.key }}. Comments on earlier versions are kept.</p>
<details>
<summary>Upload a new version</summary>
<form method="post" enctype="multipart/form-data" action="{% url 'files:replace' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=file.pk %}">
{% csrf_token %}
{{ replace_form.as_p }}
<button type="submit">Upload version {{ file.current_version|add:1 }}</button>
</form>
</details>
<details>
<summary>Remove from the feature</summary>
<form method="post" action="{% url 'files:remove' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key file_id=file.pk %}">
{% csrf_token %}
<p>
<label for="remove-note">Change note</label>
<input type="text" id="remove-note" name="note" maxlength="500" required>
</p>
<button type="submit">Remove {{ file.name }}</button>
</form>
</details>
</section>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,19 @@
<table>
<caption class="visually-hidden">{{ caption }} implementations</caption>
<thead>
<tr>
<th scope="col">Implementation</th>
<th scope="col">Link</th>
<th scope="col">Features</th>
</tr>
</thead>
<tbody>
{% for impl in impls %}
<tr>
<th scope="row"><a href="{% url 'implementations:detail' org_slug=project.org.slug project_slug=project.slug impl_id=impl.pk %}">{{ impl.name }}</a></th>
<td><a href="{{ impl.url }}" rel="noopener noreferrer">{{ impl.url }}</a></td>
<td>{% for label, count in impl.summary %}{{ label }}: {{ count }}{% if not forloop.last %}; {% endif %}{% empty %}No features yet{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,97 @@
{% extends "projects/base.html" %}
{% block page_title %}{{ impl.name }}{% endblock %}
{% block project_content %}
<h1>{{ impl.name }}{% if impl.status == "archived" %} <span class="muted">(archived)</span>{% endif %}</h1>
<p><a href="{{ impl.url }}" rel="noopener noreferrer">{{ impl.url }}</a></p>
{% if access.can_edit %}
<p><a href="{% url 'implementations:edit' org_slug=project.org.slug project_slug=project.slug impl_id=impl.pk %}">Edit implementation</a></p>
{% endif %}
{% if summary %}
<ul>
{% for label, count in summary %}<li>{{ label }}: {{ count }}</li>{% endfor %}
</ul>
{% endif %}
<section aria-labelledby="features-heading">
<h2 id="features-heading">Features</h2>
<p class="muted">
A status applies to the feature revision it was set at. When the feature changes after that, the status is marked behind until someone updates or confirms it.
</p>
{% if rows %}
<form method="post" action="{% url 'implementations:statuses' org_slug=project.org.slug project_slug=project.slug impl_id=impl.pk %}">
{% csrf_token %}
<table>
<thead>
<tr>
<th scope="col">Feature</th>
<th scope="col">Status</th>
<th scope="col">As of revision</th>
{% if access.can_edit %}<th scope="col">Still true now</th>{% endif %}
</tr>
</thead>
<tbody>
{% for row in rows %}
{% with f=row.feature %}
<tr id="{{ f.key }}">
<th scope="row">
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=f.key %}">{{ f.key }} {{ f.title }}</a>
{% if f.status != "active" %}<span class="muted">({{ f.get_status_display|lower }})</span>{% endif %}
</th>
<td>
{% if access.can_edit %}
<label for="status-{{ f.pk }}" class="visually-hidden">Status of {{ f.key }}</label>
<select id="status-{{ f.pk }}" name="status-{{ f.pk }}">
{% for value, label in choices %}
<option value="{{ value }}"{% if value == row.status %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% else %}
{{ row.label }}
{% endif %}
</td>
<td>
{% if row.revision %}
{{ row.revision }}{% if row.behind %}, <strong>behind</strong> (now {{ f.current_revision }}){% endif %}
{% else %}—{% endif %}
</td>
{% if access.can_edit %}
<td>
{% if row.behind %}
<input type="checkbox" id="confirm-{{ f.pk }}" name="confirm-{{ f.pk }}">
<label for="confirm-{{ f.pk }}">Still {{ row.label|lower }} at revision {{ f.current_revision }}</label>
{% endif %}
</td>
{% endif %}
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
{% if access.can_edit %}<p><button type="submit">Save statuses</button></p>{% endif %}
</form>
{% else %}
<p>This project has no features yet.</p>
{% endif %}
</section>
{% if access.can_admin %}
<section id="delete" aria-labelledby="delete-heading">
<h2 id="delete-heading">Delete</h2>
<details>
<summary>Delete {{ impl.name }}</summary>
<form method="post" action="{% url 'implementations:delete' org_slug=project.org.slug project_slug=project.slug impl_id=impl.pk %}">
{% csrf_token %}
<p>This removes the implementation and every status recorded for it. To keep the history, archive it instead.</p>
<p>
<label for="confirm-delete">Type “{{ impl.name }}” to confirm</label>
<input type="text" id="confirm-delete" name="confirm" required>
</p>
<button type="submit">Delete implementation</button>
</form>
</details>
</section>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "projects/base.html" %}
{% block page_title %}{% if impl %}Edit {{ impl.name }}{% else %}New implementation{% endif %}{% endblock %}
{% block project_content %}
<h1>{% if impl %}Edit {{ impl.name }}{% else %}New implementation{% endif %}</h1>
<form method="post">
{% csrf_token %}
{{ form }}
<p><button type="submit">{% if impl %}Save{% else %}Add implementation{% endif %}</button></p>
</form>
{% endblock %}

View file

@ -0,0 +1,23 @@
{% extends "projects/base.html" %}
{% block page_title %}Implementations{% endblock %}
{% block project_content %}
<h1>Implementations</h1>
<p>Apps and packages that implement {{ project.name }}, and where each stands on every feature.</p>
{% if access.can_edit %}
<p><a href="{% url 'implementations:create' org_slug=project.org.slug project_slug=project.slug %}">Add an implementation</a></p>
{% endif %}
{% if active %}
{% include "implementations/_table.html" with impls=active caption="Active" %}
{% else %}
<p>No active implementations.</p>
{% endif %}
{% if archived %}
<section aria-labelledby="archived-heading">
<h2 id="archived-heading">Archived</h2>
{% include "implementations/_table.html" with impls=archived caption="Archived" %}
</section>
{% endif %}
{% endblock %}

View file

@ -18,6 +18,7 @@
<li><a href="{% url 'projects:home' org_slug=project.org.slug project_slug=project.slug %}">Feature tree</a></li> <li><a href="{% url 'projects:home' org_slug=project.org.slug project_slug=project.slug %}">Feature tree</a></li>
<li><a href="{% url 'features:list' org_slug=project.org.slug project_slug=project.slug %}">All features</a></li> <li><a href="{% url 'features:list' org_slug=project.org.slug project_slug=project.slug %}">All features</a></li>
<li><a href="{% url 'issues:list' org_slug=project.org.slug project_slug=project.slug %}">Issues</a></li> <li><a href="{% url 'issues:list' org_slug=project.org.slug project_slug=project.slug %}">Issues</a></li>
<li><a href="{% url 'implementations:list' org_slug=project.org.slug project_slug=project.slug %}">Implementations</a></li>
{% if access.can_edit %} {% if access.can_edit %}
<li><a href="{% url 'projects:members' org_slug=project.org.slug project_slug=project.slug %}">Members</a></li> <li><a href="{% url 'projects:members' org_slug=project.org.slug project_slug=project.slug %}">Members</a></li>
{% endif %} {% endif %}

View file

@ -1,8 +1,16 @@
import pytest import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse from django.urls import reverse
from intent.comments.services import edit_comment, reply, start_feature_thread from intent.comments.services import (
edit_comment,
reply,
start_feature_thread,
start_file_thread,
)
from intent.features.services import create_feature, update_feature from intent.features.services import create_feature, update_feature
from intent.files.services import add_file, replace_file
from intent.implementations.services import create_implementation, set_statuses
from intent.issues.services import create_issue, set_status, update_issue from intent.issues.services import create_issue, set_status, update_issue
from intent.orgs.services import invite from intent.orgs.services import invite
@ -29,6 +37,23 @@ def populated(project, owner, make_user, org):
reply(issue.threads.get(), owner, body="### Deep heading") reply(issue.threads.get(), owner, body="### Deep heading")
update_issue(issue, owner, title="Bug!", body="## New") update_issue(issue, owner, title="Bug!", body="## New")
set_status(issue, owner, "rejected") set_status(issue, owner, "rejected")
# Files: an image with pins on two versions, an HTML prototype, a Markdown doc.
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
image = add_file(child, owner, kind="prototype", upload=SimpleUploadedFile("a.png", png),
note="Mockup") # fmt: skip
start_file_thread(image.versions.get(), owner, body="Old pin", x=0.1, y=0.1)
replace_file(image, owner, upload=SimpleUploadedFile("b.png", png), note="v2")
start_file_thread(image.versions.get(version=2), owner, body="New pin", x=0.5, y=0.5)
start_file_thread(image.versions.get(version=2), owner, body="## Whole file")
add_file(child, owner, kind="prototype", upload=SimpleUploadedFile("p.html", b"<p>x</p>"),
note="Proto") # fmt: skip
add_file(child, owner, kind="documentation",
upload=SimpleUploadedFile("d.md", b"## Doc\n\n### Deeper"), note="Doc") # fmt: skip
impl = create_implementation(project, owner, name="Web", url="https://forge.example/web")
set_statuses(impl, owner, {child: ("shipped", False)})
update_feature(child, owner, note="Later", title="Sign in, revised")
return child return child
@ -53,6 +78,7 @@ def _pages(org, project, feature):
reverse("features:edit", kwargs=fk), reverse("features:edit", kwargs=fk),
reverse("features:history", kwargs=fk), reverse("features:history", kwargs=fk),
reverse("features:revision", kwargs={**fk, "revision": 1}), reverse("features:revision", kwargs={**fk, "revision": 1}),
reverse("features:revision", kwargs={**fk, "revision": 5}),
reverse("features:compare", kwargs=fk), reverse("features:compare", kwargs=fk),
reverse("features:create", kwargs=pk) + "?issue=1", reverse("features:create", kwargs=pk) + "?issue=1",
reverse("features:edit", kwargs=fk) + "?issue=1", reverse("features:edit", kwargs=fk) + "?issue=1",
@ -63,9 +89,22 @@ def _pages(org, project, feature):
reverse("issues:edit", kwargs={**pk, "number": 1}), reverse("issues:edit", kwargs={**pk, "number": 1}),
reverse("comments:promote", kwargs={**pk, "thread_id": _thread_id(feature)}), reverse("comments:promote", kwargs={**pk, "thread_id": _thread_id(feature)}),
reverse("comments:history", kwargs={**pk, "comment_id": _edited_comment_id(feature)}), reverse("comments:history", kwargs={**pk, "comment_id": _edited_comment_id(feature)}),
*[
reverse("files:detail", kwargs={**fk, "file_id": f.pk}) + query
for f in feature.files.all()
for query in ("", "?version=1")
],
reverse("implementations:list", kwargs=pk),
reverse("implementations:create", kwargs=pk),
reverse("implementations:detail", kwargs={**pk, "impl_id": _impl_id(project)}),
reverse("implementations:edit", kwargs={**pk, "impl_id": _impl_id(project)}),
] ]
def _impl_id(project):
return project.implementations.get().pk
def _thread_id(feature): def _thread_id(feature):
return feature.threads.filter(kind="feature").get().pk return feature.threads.filter(kind="feature").get().pk

266
tests/test_files.py Normal file
View file

@ -0,0 +1,266 @@
import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from intent.comments import services as comment_services
from intent.comments.models import CommentThread, ThreadKind
from intent.features.services import create_feature
from intent.files import services
from intent.files.models import FeatureFileVersion, FileKind
from intent.issues.services import promotion_text
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
def upload(name="mockup.png", data=PNG):
return SimpleUploadedFile(name, data, content_type="application/octet-stream")
def furl(route, file=None, feature=None, **kwargs):
feature = feature or file.feature
kwargs = {
"org_slug": "catbriar",
"project_slug": "intent",
"feature_key": feature.key,
**kwargs,
}
if file is not None:
kwargs["file_id"] = file.pk
return reverse(f"files:{route}", kwargs=kwargs)
def raw_url(version):
return furl("raw", version.file, version=version.version, name=version.original_name)
@pytest.fixture
def feature(open_project, owner):
return create_feature(open_project, owner, title="Sign in")
@pytest.fixture
def image(feature, owner):
return services.add_file(
feature, owner, kind=FileKind.PROTOTYPE, upload=upload(), note="First mockup"
)
def current(file):
return file.versions.get(version=file.current_version)
def test_adding_a_file_is_a_revision(feature, image):
feature.refresh_from_db()
revision = feature.revisions.get(revision=2)
assert feature.current_revision == 2 and image.added_in_revision == 2
assert revision.files_change == "Added prototype “mockup.png”"
assert revision.note == "First mockup"
assert current(image).content_type == "image/png"
def test_type_is_decided_by_extension_and_checked(feature, owner):
with pytest.raises(ValidationError, match="isn't allowed"):
services.add_file(feature, owner, kind="prototype", upload=upload("x.exe"), note="n")
with pytest.raises(ValidationError, match="empty"):
services.add_file(feature, owner, kind="prototype", upload=upload(data=b""), note="n")
def test_size_cap(feature, owner, monkeypatch):
monkeypatch.setattr(services, "MAX_BYTES", 10)
with pytest.raises(ValidationError, match="up to"):
services.add_file(feature, owner, kind="prototype", upload=upload(), note="n")
def test_note_required_and_editors_only(feature, owner, collaborator):
with pytest.raises(ValidationError):
services.add_file(feature, owner, kind="prototype", upload=upload(), note=" ")
with pytest.raises(PermissionDenied):
services.add_file(feature, collaborator, kind="prototype", upload=upload(), note="n")
def test_replace_keeps_old_versions_and_their_threads(image, owner, outsider):
old = current(image)
pin = comment_services.start_file_thread(old, outsider, body="Here", x=0.25, y=0.5)
services.replace_file(image, owner, upload=upload("mockup-2.png"), note="Tweak")
image.refresh_from_db()
assert (image.current_version, image.name) == (2, "mockup-2.png")
assert image.feature.revisions.last().files_change == "Replaced “mockup.png” with version 2"
assert CommentThread.objects.get(pk=pin.pk).file_version == old
with pytest.raises(ValidationError, match="latest"):
comment_services.start_file_thread(old, outsider, body="Late")
def test_remove_and_files_at_revision(feature, image, owner):
services.replace_file(image, owner, upload=upload("v2.png"), note="v2") # revision 3
services.remove_file(image, owner, note="Not needed") # revision 4
image.refresh_from_db()
assert image.removed_in_revision == 4
assert [v.version for v in services.files_at(feature, 1)] == []
assert [v.version for v in services.files_at(feature, 2)] == [1]
assert [v.version for v in services.files_at(feature, 3)] == [2]
assert services.files_at(feature, 4) == []
with pytest.raises(ValidationError):
services.remove_file(image, owner, note="Again")
def test_image_pins_need_an_image_and_a_point(feature, image, owner, outsider):
doc = services.add_file(
feature, owner, kind="documentation", upload=upload("notes.md", b"# Notes"), note="n"
)
with pytest.raises(ValidationError, match="Only images"):
comment_services.start_file_thread(current(doc), outsider, body="x", x=0.1, y=0.1)
with pytest.raises(ValidationError, match="point"):
comment_services.start_file_thread(current(image), outsider, body="x", x=0.1)
with pytest.raises(ValidationError, match="point"):
comment_services.start_file_thread(current(image), outsider, body="x", x=1.5, y=0.1)
file_thread = comment_services.start_file_thread(current(doc), outsider, body="Whole")
assert file_thread.kind == ThreadKind.FILE
def test_promotion_text_mentions_the_pin(image, outsider):
thread = comment_services.start_file_thread(
current(image), outsider, body="Button too small", x=0.25, y=0.5
)
_, body = promotion_text(thread)
assert "file “mockup.png” version 1, at 25% across and 50% down" in body
# Views
def test_upload_via_feature_page(client, feature, editor):
client.force_login(editor)
response = client.post(
furl("add", feature=feature),
{"kind": "prototype", "upload": upload(), "note": "Mockup"},
)
file = feature.files.get()
assert response["Location"] == furl("detail", file)
page = client.get(
reverse(
"features:detail",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": "INT-1"},
)
).content.decode()
assert furl("detail", file) in page
def test_bad_upload_via_page_shows_error(client, feature, editor):
client.force_login(editor)
response = client.post(
furl("add", feature=feature),
{"kind": "prototype", "upload": upload("evil.exe"), "note": "x"},
follow=True,
)
assert b"isn&#x27;t allowed" in response.content
assert not feature.files.exists()
def test_pin_via_form_and_page_shows_it(client, image, outsider):
client.force_login(outsider)
response = client.post(
furl("thread_create", image), {"body": "Too small", "across": "25", "down": "50"}
)
thread = CommentThread.objects.get()
assert (thread.kind, thread.x, thread.y) == (ThreadKind.IMAGE, 0.25, 0.5)
assert response["Location"] == furl("detail", image) + f"#thread-{thread.pk}"
page = client.get(furl("detail", image)).content.decode()
assert '<circle cx="25.0%" cy="50.0%"' in page
assert "Pin 1: Thread by uma" in page
def test_htmx_reply_on_a_pin_keeps_its_number(client, image, outsider):
thread = comment_services.start_file_thread(current(image), outsider, body="Here", x=0.1, y=0.2)
client.force_login(outsider)
response = client.post(
reverse(
"comments:reply",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "thread_id": thread.pk},
),
{"body": "More"},
headers={"HX-Request": "true"},
)
assert b"Pin 1: Thread by uma" in response.content
def test_raw_file_permission_and_headers(client, project, owner, make_user):
feature = create_feature(project, owner, title="Private")
html = services.add_file(
feature, owner, kind="prototype", upload=upload("proto.html", b"<p>hi</p>"), note="n"
)
url = raw_url(current(html))
assert client.get(url).status_code == 302 # Signed out: sign in first.
client.force_login(make_user())
assert client.get(url).status_code == 404 # Outsider: private project is invisible.
client.force_login(owner)
response = client.get(url)
assert response["Content-Security-Policy"].startswith("sandbox allow-scripts")
assert "allow-same-origin" not in response["Content-Security-Policy"]
assert response["X-Content-Type-Options"] == "nosniff"
assert response["X-Frame-Options"] == "SAMEORIGIN"
assert response["Content-Type"] == "text/html"
assert b"".join(response.streaming_content) == b"<p>hi</p>"
def test_svg_and_zip_cannot_run_or_render(client, feature, owner):
svg = services.add_file(
feature, owner, kind="prototype", upload=upload("a.svg", b"<svg/>"), note="n"
)
zipped = services.add_file(
feature, owner, kind="documentation", upload=upload("a.zip", b"PK"), note="n"
)
client.force_login(owner)
svg_response = client.get(raw_url(current(svg)))
assert svg_response["Content-Security-Policy"].startswith("sandbox;")
zip_response = client.get(raw_url(current(zipped)))
assert zip_response["Content-Disposition"].startswith("attachment")
def test_bucket_storage_hands_off_to_a_signed_url(client, image, owner, monkeypatch):
version = current(image)
storage = version.blob.storage
calls = {}
def fake_url(name, parameters=None, **kwargs):
calls.update(parameters)
return f"https://bucket.example/{name}?signature=x"
monkeypatch.setattr(storage, "querystring_auth", True, raising=False)
monkeypatch.setattr(storage, "url", fake_url)
client.force_login(owner)
response = client.get(raw_url(version))
assert response.status_code == 302
assert response["Location"].startswith("https://bucket.example/files/")
assert calls["ResponseContentType"] == "image/png"
assert calls["ResponseContentDisposition"].startswith("inline")
def test_markdown_file_renders_on_its_page(client, feature, owner):
doc = services.add_file(
feature, owner, kind="documentation", upload=upload("notes.md", b"# Notes\n\nHi"),
note="n",
) # fmt: skip
client.force_login(owner)
page = client.get(furl("detail", doc)).content.decode()
assert "<h2" in page and "Notes" in page
def test_old_version_page_and_unknown_version(client, image, owner):
services.replace_file(image, owner, upload=upload("v2.png"), note="v2")
client.force_login(owner)
page = client.get(furl("detail", image) + "?version=1").content.decode()
assert "Older version" in page and 'id="file-comment"' not in page
assert client.get(furl("detail", image) + "?version=9").status_code == 404
def test_file_from_another_feature_is_404(client, image, open_project, owner):
other = create_feature(open_project, owner, title="Other")
client.force_login(owner)
assert client.get(furl("detail", image, feature=other)).status_code == 404
def test_versions_are_stored_under_opaque_paths(image):
name = FeatureFileVersion.objects.get().blob.name
assert name.startswith("files/") and name.endswith("/mockup.png")
assert image.feature.key not in name

View file

@ -0,0 +1,128 @@
import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.urls import reverse
from intent.features.services import create_feature, update_feature
from intent.implementations import services
from intent.implementations.models import FeatureImplStatus, FeatureStatus
P = {"org_slug": "catbriar", "project_slug": "intent"}
def iurl(name, impl=None):
kwargs = dict(P)
if impl is not None:
kwargs["impl_id"] = impl.pk
return reverse(f"implementations:{name}", kwargs=kwargs)
@pytest.fixture
def impl(open_project, owner):
return services.create_implementation(
open_project, owner, name="Web app", url="https://forge.example/acme/web"
)
@pytest.fixture
def features(open_project, owner):
return [create_feature(open_project, owner, title=t) for t in ("Sign in", "Sign out")]
def test_only_editors_create_and_urls_must_be_http(open_project, collaborator, owner, impl):
with pytest.raises(PermissionDenied):
services.create_implementation(
open_project, collaborator, name="X", url="https://x.example"
)
with pytest.raises(ValidationError):
services.create_implementation(open_project, owner, name="X", url="javascript:alert(1)")
with pytest.raises(ValidationError, match="already"):
services.create_implementation(open_project, owner, name="web APP", url="https://x.example")
def test_status_is_stamped_and_goes_behind(impl, features, owner, editor):
sign_in, sign_out = features
changed = services.set_statuses(
impl,
editor,
{sign_in: (FeatureStatus.SHIPPED, False), sign_out: (FeatureStatus.NOT_STARTED, False)},
)
assert changed == 1 # "Not started" is the default, so no row is written for it.
row = FeatureImplStatus.objects.get()
assert (row.status, row.feature_revision, row.is_behind) == ("shipped", 1, False)
update_feature(sign_in, owner, note="Add 2FA", description="Now with 2FA")
row.refresh_from_db()
assert row.is_behind
# Unchanged and unconfirmed: nothing happens. Confirmed: re-stamped at the current revision.
sign_in.refresh_from_db()
assert services.set_statuses(impl, editor, {sign_in: ("shipped", False)}) == 0
assert services.set_statuses(impl, editor, {sign_in: ("shipped", True)}) == 1
row.refresh_from_db()
assert (row.feature_revision, row.is_behind) == (2, False)
def test_outsiders_cannot_set_statuses(impl, features, outsider):
with pytest.raises(PermissionDenied):
services.set_statuses(impl, outsider, {features[0]: ("shipped", False)})
# Views
def test_status_grid_via_form(client, impl, features, editor):
sign_in, sign_out = features
client.force_login(editor)
client.post(
iurl("statuses", impl),
{f"status-{sign_in.pk}": "shipped", f"status-{sign_out.pk}": "in_progress"},
)
statuses = dict(impl.feature_statuses.values_list("feature__number", "status"))
assert statuses == {1: "shipped", 2: "in_progress"}
def test_bad_status_value_is_refused(client, impl, features, editor):
client.force_login(editor)
client.post(iurl("statuses", impl), {f"status-{features[0].pk}": "done"})
assert not impl.feature_statuses.exists()
def test_outsider_sees_grid_read_only(client, impl, features, outsider):
client.force_login(outsider)
page = client.get(iurl("detail", impl)).content.decode()
assert "Sign in" in page and 'id="status-' not in page
assert client.post(iurl("statuses", impl), {}).status_code == 403
assert client.get(iurl("create")).status_code == 403
def test_create_edit_archive_delete(client, open_project, owner, editor):
client.force_login(editor)
client.post(iurl("create"), {"name": "CLI", "url": "https://forge.example/acme/cli"})
impl = open_project.implementations.get()
client.post(
iurl("edit", impl),
{"name": "CLI", "url": "https://forge.example/acme/cli", "status": "archived"},
)
impl.refresh_from_db()
assert impl.status == "archived"
assert "Archived" in client.get(iurl("list")).content.decode()
assert client.post(iurl("delete", impl), {"confirm": "CLI"}).status_code == 403
client.force_login(owner)
client.post(iurl("delete", impl), {"confirm": "cli"})
assert open_project.implementations.exists()
client.post(iurl("delete", impl), {"confirm": "CLI"})
assert not open_project.implementations.exists()
def test_feature_page_shows_each_implementation(client, impl, features, owner, editor):
services.set_statuses(impl, editor, {features[0]: ("shipped", False)})
update_feature(features[0], owner, note="Change", title="Sign in v2")
client.force_login(editor)
page = client.get(
reverse("features:detail", kwargs={**P, "feature_key": "INT-1"})
).content.decode()
assert "Web app" in page and "Shipped" in page and "<strong>behind</strong>" in page
other = client.get(
reverse("features:detail", kwargs={**P, "feature_key": "INT-2"})
).content.decode()
assert "Not started" in other

72
tests/test_phase4_exit.py Normal file
View file

@ -0,0 +1,72 @@
"""Phase 4 exit: a feature with a prototype image has a pinned thread; an implementation shows
one feature shipped and one behind. Driven through the views."""
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from intent.comments.models import CommentThread, ThreadKind
from intent.features.models import Feature
P = {"org_slug": "catbriar", "project_slug": "intent"}
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
def test_prototype_pin_and_implementation_status(client, open_project, editor, outsider):
client.force_login(editor)
for title in ("Checkout", "Receipts"):
client.post(reverse("features:create", kwargs=P), {"title": title, "note": ""})
checkout, receipts = Feature.objects.order_by("number")
# A prototype image on the feature.
client.post(
reverse("files:add", kwargs={**P, "feature_key": checkout.key}),
{
"kind": "prototype",
"upload": SimpleUploadedFile("checkout.png", PNG),
"note": "First mockup",
},
)
file = checkout.files.get()
# Someone pins a comment to a point on it.
client.force_login(outsider)
client.post(
reverse(
"files:thread_create", kwargs={**P, "feature_key": checkout.key, "file_id": file.pk}
),
{"body": "Pay button is hard to find", "across": "80", "down": "90"},
)
pin = CommentThread.objects.get(kind=ThreadKind.IMAGE)
file_page = client.get(
reverse("files:detail", kwargs={**P, "feature_key": checkout.key, "file_id": file.pk})
).content.decode()
assert '<circle cx="80.0%" cy="90.0%"' in file_page
assert f'href="#thread-{pin.pk}"' in file_page
# An implementation ships both features, then one feature changes.
client.force_login(editor)
client.post(
reverse("implementations:create", kwargs=P),
{"name": "Storefront", "url": "https://forge.example/shop/storefront"},
)
impl = open_project.implementations.get()
statuses = reverse("implementations:statuses", kwargs={**P, "impl_id": impl.pk})
client.post(statuses, {f"status-{checkout.pk}": "shipped", f"status-{receipts.pk}": "shipped"})
client.post(
reverse("features:edit", kwargs={**P, "feature_key": receipts.key}),
{
"title": "Receipts",
"description": "Email a receipt.",
"status": "active",
"note": "Email",
},
)
page = client.get(
reverse("implementations:detail", kwargs={**P, "impl_id": impl.pk})
).content.decode()
checkout_row = page[page.index(f'id="{checkout.key}"') : page.index(f'id="{receipts.key}"')]
receipts_row = page[page.index(f'id="{receipts.key}"') :]
assert "behind" not in checkout_row
assert "<strong>behind</strong> (now 2)" in receipts_row
assert "Shipped: 2" in page and "Behind the current revision: 1" in page