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.projects",
"intent.features",
"intent.files",
"intent.implementations",
"intent.issues",
"intent.comments",
]
@ -78,6 +80,9 @@ DATABASES = {
}
}
# Uploads up to 25 MB (see intent.files). Larger bodies spill to a temp file, not memory.
FILE_UPLOAD_MAX_MEMORY_SIZE = 2_621_440
# Rate limits (allauth's and ours) live here, so the cache must be shared by every worker
# and survive restarts. The database is the only shared store on a one-machine deploy.
CACHES = {

View file

@ -31,7 +31,15 @@ urlpatterns = [
path("<slug:org_slug>/new/", project_create, name="project_create"),
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>/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>/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")),
]

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. |
| Feature statuses | `active`, `withdrawn`, `superseded` (requires `superseded_by` feature). |
| Implementation status | `active`, `archived`. |
| Feature × implementation status | `n/a`, `not_started`, `in_progress`, `shipped`, `retired`; stamped with the feature revision it applies to. Missing row displays as `not_started @ —`. If stamped revision < current revision, display "behind". |
| Feature × implementation status | `n/a`, `not_started`, `in_progress`, `shipped`, `retired`; stamped with the feature revision it applies to. Missing row displays as `not_started @ —`. If stamped revision < current revision, display "behind". Each implementation page is one form: a status select per feature, plus a "Still … at revision N" checkbox on rows that are behind, which re-stamps without changing the status. Only changed or confirmed rows are written. Implementation links must be http(s). Collaborators create, edit and archive implementations; project owners delete them (typed name to confirm). |
| Hierarchy | Single optional parent within the same project. Cycles rejected. Moving a feature records a revision. Statuses independent in V1. |
| Files | Belong to a feature, typed `prototype` or `documentation`. Replacing a file creates a new file version and a feature revision; old versions stay (so old image-pin threads still resolve). Size cap 25 MB; allowed types: images, PDF, markdown/text, HTML, zip. |
| Files | Belong to a feature, typed `prototype` or `documentation`. Adding, replacing or removing a file is a feature revision with a required change note; the revision records what happened ("Added prototype “a.png”") and shows it in history. Replacing creates a new file version; old versions stay (so old image-pin threads still resolve). Removed files stay too, so old revisions list the files they had. Size cap 25 MB; allowed types: images (png, jpg, gif, webp, avif, svg), PDF, Markdown/text, HTML, zip. The content type comes from the extension, never the browser. Stored under opaque paths (`files/<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. |
| Image threads | Anchor = file version + x/y as fractions (0–1). |
| Image threads | Anchor = file version + x/y as fractions (0–1). Pins are drawn as an SVG overlay using percentage coordinates (no inline styles), numbered per version, and each is a link to its thread. Placing a pin: two number fields (across %, down %) work without JS; clicking the image fills them in. New comments go on the latest version only; threads on other versions are listed separately with a link to that version. |
| File threads | Anchor = file version, like image threads without a point. |
| Comments | Edits keep history (visible "edited" link to a history page). Author or project owner may delete; a deleted comment shows as a tombstone and its text and history are removed. Delete asks for confirmation in a disclosure, so it works without JS. Resolving a feature thread is allowed for the thread author or any collaborator; resolved threads start collapsed. An issue has exactly one thread, its discussion, which isn't resolvable (the issue status says that). |
| Thread → issue | Original commenter (while they can contribute) or any project collaborator. Thread gets `promoted_to` issue and is resolved; the issue is linked to the thread's feature, its body is prefilled with the quoted lines and comments, and its page links back to the thread. |
| Deleted users | Content remains, attributed to a "ghost" user. |
@ -51,9 +52,9 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). Only superusers can grant staff or superuser status or permissions in the admin. |
| Cache | Django's database cache (`createcachetable` runs at start). It holds allauth's rate limits and the invite throttle, so it must be shared by all gunicorn workers and survive restarts; the database is the only shared store on a one-machine deploy. Dev and tests use the in-memory cache. |
| Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. |
| Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `<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. |
| 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.** |
| Background work | None. Email is sent synchronously. |
| Observability | Sentry (free tier), Fly logs. |
@ -91,15 +92,17 @@ Feature project, number, parent (nullable, same project), title, st
current_revision (int), created_by
FeatureRevision feature, revision, description_md, note, author, parent_at_revision,
status_at_revision (issues via Issue.revisions)
FeatureFile feature, kind ∈ {prototype, documentation}, name, current_version
FeatureFileVersion file, version, storage_key, content_type, size, created_in_revision
FeatureFile feature, kind ∈ {prototype, documentation}, name, current_version,
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}
FeatureImplStatus feature, implementation, status, feature_revision, updated_by (unique feature+impl)
Issue project, number, title, body_md, author, status, duplicate_of, closed_at,
features (M2M), revisions (M2M FeatureRevision)
IssueEvent issue, actor, kind ∈ {status, linked, unlinked, revision, edited}, status,
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,
resolved_by, resolved_at, promoted_to (OneToOne issue)
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)
- 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.
- 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.
- 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)

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.features.models import Feature
from intent.files.models import FeatureFileVersion
from intent.issues.models import Issue
from intent.projects.models import Project
@ -12,26 +13,42 @@ class ThreadKind(models.TextChoices):
FEATURE = "feature", "Feature"
LINE = "line", "Lines of a feature description"
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():
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
line = Q(
kind=ThreadKind.LINE,
feature__isnull=False,
issue__isnull=True,
revision__isnull=False,
line_start__gte=1,
line_end__gte=F("line_start"),
no_file = Q(file_version__isnull=True, x__isnull=True, y__isnull=True)
on_feature = Q(feature__isnull=False, issue__isnull=True)
feature = Q(kind=ThreadKind.FEATURE) & on_feature & no_lines & no_file
line = (
Q(
kind=ThreadKind.LINE,
revision__isnull=False,
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
return feature | line | issue
issue = Q(kind=ThreadKind.ISSUE, issue__isnull=False, feature__isnull=True) & no_lines & no_file
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):
"""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.
"""
@ -48,6 +65,17 @@ class CommentThread(BaseModel):
revision = models.PositiveIntegerField(null=True, blank=True)
line_start = 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(
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 .models import Comment, ThreadKind
from .models import Comment, CommentThread, ThreadKind
def prepare_threads(threads, user, access) -> list:
"""Load threads with their comments, and attach what the templates need to each."""
threads = list(
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")))
)
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:
"""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
kwargs = {"org_slug": project.org.slug, "project_slug": project.slug}
if thread.kind == ThreadKind.ISSUE:
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:
url = reverse("features:detail", kwargs={**kwargs, "feature_key": thread.feature.key})
return f"{url}#{anchor or thread.dom_id}"

View file

@ -58,6 +58,34 @@ def start_feature_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:
_require_contribute(by, thread.project)
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 .forms import CommentForm, LineThreadForm, PromoteForm
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:
@ -27,7 +27,9 @@ def _is_htmx(request) -> bool:
def _get_thread(project, thread_id) -> CommentThread:
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,
pk=thread_id,
)
@ -36,7 +38,11 @@ def _get_thread(project, thread_id) -> CommentThread:
def _get_comment(project, comment_id) -> Comment:
return get_object_or_404(
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,
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)
if prepared.kind == ThreadKind.LINE:
mark_line_threads(prepared.feature, [prepared])
mark_file_threads([prepared])
prepared.draft = draft
context["thread"] = prepared
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="+"
)
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(
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
def _record(feature, by, note) -> FeatureRevision:
def _record(feature, by, note, files_change="") -> FeatureRevision:
return FeatureRevision.objects.create(
files_change=files_change,
feature=feature,
revision=feature.current_revision,
title=feature.title,
@ -113,6 +114,23 @@ def create_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(
feature, by, *, note, issues=(), accept_issues=False, **changes
) -> FeatureRevision:

View file

@ -1,10 +1,11 @@
from django.contrib import messages
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.shortcuts import get_object_or_404, redirect, render
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_blocks
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"),
"supersedes": feature.supersedes.select_related("project"),
"issues": feature.issues.select_related("project"),
**feature_files(feature),
"implementations": implementation_rows(feature),
**feature_discussion(feature, request.user, access),
}
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]:
chain, node = [], feature.parent
while node:
@ -187,6 +229,8 @@ def _changes(revision, previous) -> list[str]:
changes.append("Parent")
if revision.status != previous.status or revision.superseded_by_id != previous.superseded_by_id:
changes.append(f"Status: {revision.get_status_display()}")
if revision.files_change:
changes.append(revision.files_change)
return changes
@ -224,6 +268,7 @@ def feature_revision(request, project, access, feature_key, revision):
"feature": feature,
"snapshot": snapshot,
"blocks": render_blocks(snapshot.description),
"files": files_at(feature, snapshot.revision),
}
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
if thread.kind == ThreadKind.LINE:
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}."]
if thread.kind == ThreadKind.LINE:
snapshot = thread.feature.revisions.filter(revision=thread.revision).first()

View file

@ -146,6 +146,63 @@ form.inline {
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 {
white-space: pre-wrap;
padding-inline-start: 0.75rem;

View file

@ -7,3 +7,54 @@ document.addEventListener("htmx:afterSettle", function () {
target.removeAttribute("data-autofocus");
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 %}
<details{% if not thread.is_resolved or error or focus %} open{% endif %}>
<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 %}
</summary>
@ -19,6 +19,13 @@
<pre class="quote">{{ thread.quoted }}</pre>
{% 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 %}
<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 %}

View file

@ -94,6 +94,63 @@
</section>
{% 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">
<h2 id="issues-heading">Issues</h2>
{% if issues %}

View file

@ -31,4 +31,15 @@
{% endfor %}
</div>
</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 %}

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 '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 'implementations:list' org_slug=project.org.slug project_slug=project.slug %}">Implementations</a></li>
{% if access.can_edit %}
<li><a href="{% url 'projects:members' org_slug=project.org.slug project_slug=project.slug %}">Members</a></li>
{% endif %}

View file

@ -1,8 +1,16 @@
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
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.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.orgs.services import invite
@ -29,6 +37,23 @@ def populated(project, owner, make_user, org):
reply(issue.threads.get(), owner, body="### Deep heading")
update_issue(issue, owner, title="Bug!", body="## New")
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
@ -53,6 +78,7 @@ def _pages(org, project, feature):
reverse("features:edit", kwargs=fk),
reverse("features:history", kwargs=fk),
reverse("features:revision", kwargs={**fk, "revision": 1}),
reverse("features:revision", kwargs={**fk, "revision": 5}),
reverse("features:compare", kwargs=fk),
reverse("features:create", kwargs=pk) + "?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("comments:promote", kwargs={**pk, "thread_id": _thread_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):
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