diff --git a/config/settings/base.py b/config/settings/base.py index 5fb9172..8a681f1 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -29,6 +29,8 @@ INSTALLED_APPS = [ "intent.orgs", "intent.projects", "intent.features", + "intent.files", + "intent.implementations", "intent.issues", "intent.comments", ] @@ -78,6 +80,9 @@ DATABASES = { } } +# Uploads up to 25 MB (see intent.files). Larger bodies spill to a temp file, not memory. +FILE_UPLOAD_MAX_MEMORY_SIZE = 2_621_440 + # Rate limits (allauth's and ours) live here, so the cache must be shared by every worker # and survive restarts. The database is the only shared store on a one-machine deploy. CACHES = { diff --git a/config/urls.py b/config/urls.py index 7d748d0..c50df45 100644 --- a/config/urls.py +++ b/config/urls.py @@ -31,7 +31,15 @@ urlpatterns = [ path("/new/", project_create, name="project_create"), path("/", include("intent.orgs.urls")), path("//", include("intent.projects.urls")), + path( + "//features//files/", + include("intent.files.urls"), + ), path("//features/", include("intent.features.urls")), path("//issues/", include("intent.issues.urls")), + path( + "//implementations/", + include("intent.implementations.urls"), + ), path("//", include("intent.comments.urls")), ] diff --git a/docs/plan.md b/docs/plan.md index 63d25b6..f662b42 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -26,11 +26,12 @@ Decisions made in the concept are not repeated. These fill the gaps. | Issue → feature | From an issue, a collaborator follows "Create a feature from #N" or "Update INT-3 for #N". The feature form opens prefilled (title and description from the issue, or a change note naming it), ties the new revision to the issue, links the feature, and offers a checked "Mark #N accepted" box. Accept and Reject buttons also work on their own. | | Feature statuses | `active`, `withdrawn`, `superseded` (requires `superseded_by` feature). | | Implementation status | `active`, `archived`. | -| Feature × implementation status | `n/a`, `not_started`, `in_progress`, `shipped`, `retired`; stamped with the feature revision it applies to. Missing row displays as `not_started @ —`. If stamped revision < current revision, display "behind". | +| Feature × implementation status | `n/a`, `not_started`, `in_progress`, `shipped`, `retired`; stamped with the feature revision it applies to. Missing row displays as `not_started @ —`. If stamped revision < current revision, display "behind". Each implementation page is one form: a status select per feature, plus a "Still … at revision N" checkbox on rows that are behind, which re-stamps without changing the status. Only changed or confirmed rows are written. Implementation links must be http(s). Collaborators create, edit and archive implementations; project owners delete them (typed name to confirm). | | Hierarchy | Single optional parent within the same project. Cycles rejected. Moving a feature records a revision. Statuses independent in V1. | -| Files | Belong to a feature, typed `prototype` or `documentation`. Replacing a file creates a new file version and a feature revision; old versions stay (so old image-pin threads still resolve). Size cap 25 MB; allowed types: images, PDF, markdown/text, HTML, zip. | +| Files | Belong to a feature, typed `prototype` or `documentation`. Adding, replacing or removing a file is a feature revision with a required change note; the revision records what happened ("Added prototype “a.png”") and shows it in history. Replacing creates a new file version; old versions stay (so old image-pin threads still resolve). Removed files stay too, so old revisions list the files they had. Size cap 25 MB; allowed types: images (png, jpg, gif, webp, avif, svg), PDF, Markdown/text, HTML, zip. The content type comes from the extension, never the browser. Stored under opaque paths (`files//`). Markdown files render on their page; text shows as text; PDFs and zips open or download. | | Line-anchored threads | Anchor = feature revision + start/end source line of one top-level Markdown block (paragraph, list, heading, code block). Each block has its own `
` comment form, so it works without JS. A thread goes "outdated" only when the description text has changed since its revision (a status-only revision doesn't outdate it); outdated threads move to "Comments on earlier revisions", quote their original lines, and link to the revision. No attempt to re-map lines. A line comment posted against a stale revision is refused and the draft is shown back. | -| Image threads | Anchor = file version + x/y as fractions (0–1). | +| Image threads | Anchor = file version + x/y as fractions (0–1). Pins are drawn as an SVG overlay using percentage coordinates (no inline styles), numbered per version, and each is a link to its thread. Placing a pin: two number fields (across %, down %) work without JS; clicking the image fills them in. New comments go on the latest version only; threads on other versions are listed separately with a link to that version. | +| File threads | Anchor = file version, like image threads without a point. | | Comments | Edits keep history (visible "edited" link to a history page). Author or project owner may delete; a deleted comment shows as a tombstone and its text and history are removed. Delete asks for confirmation in a disclosure, so it works without JS. Resolving a feature thread is allowed for the thread author or any collaborator; resolved threads start collapsed. An issue has exactly one thread, its discussion, which isn't resolvable (the issue status says that). | | Thread → issue | Original commenter (while they can contribute) or any project collaborator. Thread gets `promoted_to` issue and is resolved; the issue is linked to the thread's feature, its body is prefilled with the quoted lines and comments, and its page links back to the thread. | | Deleted users | Content remains, attributed to a "ghost" user. | @@ -51,9 +52,9 @@ Decisions made in the concept are not repeated. These fill the gaps. | Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). Only superusers can grant staff or superuser status or permissions in the admin. | | Cache | Django's database cache (`createcachetable` runs at start). It holds allauth's rate limits and the invite throttle, so it must be shared by all gunicorn workers and survive restarts; the database is the only shared store on a one-machine deploy. Dev and tests use the in-memory cache. | | Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. | -| Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed ` + {% elif version.is_text %} + {% if version.content_type == "text/markdown" %} +
{{ preview }}
+ {% else %} +
{{ preview }}
+ {% endif %} + {% if truncated %}

Only the start of the file is shown. Open it to see everything.

{% endif %} + {% endif %} + +
+

Comments on this version

+ {% for thread in file_threads %} + {% include "comments/_thread.html" %} + {% empty %} +

No comments yet.

+ {% endfor %} + {% if is_current and access.can_contribute %} +
+ {% csrf_token %} + {% if version.is_image %} +
+ Pin to a point (optional) +

Click the image to place a pin, or enter its position.

+ + + + +
+ {% endif %} + + + +
+ {% elif not user.is_authenticated and project.open_contribution %} +

Sign in to comment.

+ {% endif %} +
+ + {% if other_threads %} +
+

Comments on other versions

+ {% for thread in other_threads %} + {% include "comments/_thread.html" %} + {% endfor %} +
+ {% endif %} + +
+

Versions

+ + + + + + + + + + + + {% for v in versions %} + + + + + + + + {% endfor %} + +
VersionNameUploadedFeature revisionSize
+ {% if v.version == version.version %}{{ v.version }} (shown){% else %}{{ v.version }}{% endif %} + {{ v.original_name }} by {{ v.uploaded_by.username|default:"deleted user" }}{{ v.created_in_revision }}{{ v.size|filesizeformat }}
+
+ + {% if access.can_edit and not file.is_removed %} +
+

Change this file

+

Each change is a new revision of {{ feature.key }}. Comments on earlier versions are kept.

+
+ Upload a new version +
+ {% csrf_token %} + {{ replace_form.as_p }} + +
+
+
+ Remove from the feature +
+ {% csrf_token %} +

+ + +

+ +
+
+
+ {% endif %} +{% endblock %} diff --git a/templates/implementations/_table.html b/templates/implementations/_table.html new file mode 100644 index 0000000..bde296c --- /dev/null +++ b/templates/implementations/_table.html @@ -0,0 +1,19 @@ + + + + + + + + + + + {% for impl in impls %} + + + + + + {% endfor %} + +
{{ caption }} implementations
ImplementationLinkFeatures
{{ impl.name }}{{ impl.url }}{% for label, count in impl.summary %}{{ label }}: {{ count }}{% if not forloop.last %}; {% endif %}{% empty %}No features yet{% endfor %}
diff --git a/templates/implementations/detail.html b/templates/implementations/detail.html new file mode 100644 index 0000000..f972ff5 --- /dev/null +++ b/templates/implementations/detail.html @@ -0,0 +1,97 @@ +{% extends "projects/base.html" %} + +{% block page_title %}{{ impl.name }}{% endblock %} + +{% block project_content %} +

{{ impl.name }}{% if impl.status == "archived" %} (archived){% endif %}

+

{{ impl.url }}

+ {% if access.can_edit %} +

Edit implementation

+ {% endif %} + + {% if summary %} +
    + {% for label, count in summary %}
  • {{ label }}: {{ count }}
  • {% endfor %} +
+ {% endif %} + +
+

Features

+

+ 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. +

+ {% if rows %} +
+ {% csrf_token %} + + + + + + + {% if access.can_edit %}{% endif %} + + + + {% for row in rows %} + {% with f=row.feature %} + + + + + {% if access.can_edit %} + + {% endif %} + + {% endwith %} + {% endfor %} + +
FeatureStatusAs of revisionStill true now
+ {{ f.key }} {{ f.title }} + {% if f.status != "active" %}({{ f.get_status_display|lower }}){% endif %} + + {% if access.can_edit %} + + + {% else %} + {{ row.label }} + {% endif %} + + {% if row.revision %} + {{ row.revision }}{% if row.behind %}, behind (now {{ f.current_revision }}){% endif %} + {% else %}—{% endif %} + + {% if row.behind %} + + + {% endif %} +
+ {% if access.can_edit %}

{% endif %} +
+ {% else %} +

This project has no features yet.

+ {% endif %} +
+ + {% if access.can_admin %} +
+

Delete

+
+ Delete {{ impl.name }} +
+ {% csrf_token %} +

This removes the implementation and every status recorded for it. To keep the history, archive it instead.

+

+ + +

+ +
+
+
+ {% endif %} +{% endblock %} diff --git a/templates/implementations/form.html b/templates/implementations/form.html new file mode 100644 index 0000000..133b770 --- /dev/null +++ b/templates/implementations/form.html @@ -0,0 +1,12 @@ +{% extends "projects/base.html" %} + +{% block page_title %}{% if impl %}Edit {{ impl.name }}{% else %}New implementation{% endif %}{% endblock %} + +{% block project_content %} +

{% if impl %}Edit {{ impl.name }}{% else %}New implementation{% endif %}

+
+ {% csrf_token %} + {{ form }} +

+
+{% endblock %} diff --git a/templates/implementations/list.html b/templates/implementations/list.html new file mode 100644 index 0000000..8a1242d --- /dev/null +++ b/templates/implementations/list.html @@ -0,0 +1,23 @@ +{% extends "projects/base.html" %} + +{% block page_title %}Implementations{% endblock %} + +{% block project_content %} +

Implementations

+

Apps and packages that implement {{ project.name }}, and where each stands on every feature.

+ {% if access.can_edit %} +

Add an implementation

+ {% endif %} + + {% if active %} + {% include "implementations/_table.html" with impls=active caption="Active" %} + {% else %} +

No active implementations.

+ {% endif %} + {% if archived %} +
+

Archived

+ {% include "implementations/_table.html" with impls=archived caption="Archived" %} +
+ {% endif %} +{% endblock %} diff --git a/templates/projects/base.html b/templates/projects/base.html index a97d41a..5b225bd 100644 --- a/templates/projects/base.html +++ b/templates/projects/base.html @@ -18,6 +18,7 @@
  • Feature tree
  • All features
  • Issues
  • +
  • Implementations
  • {% if access.can_edit %}
  • Members
  • {% endif %} diff --git a/tests/test_a11y.py b/tests/test_a11y.py index 767612e..8db0bbc 100644 --- a/tests/test_a11y.py +++ b/tests/test_a11y.py @@ -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"

    x

    "), + 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 diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..ced52e5 --- /dev/null +++ b/tests/test_files.py @@ -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'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 'hi

    "), 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"

    hi

    " + + +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""), 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 "behind" in page + other = client.get( + reverse("features:detail", kwargs={**P, "feature_key": "INT-2"}) + ).content.decode() + assert "Not started" in other diff --git a/tests/test_phase4_exit.py b/tests/test_phase4_exit.py new file mode 100644 index 0000000..41d5d19 --- /dev/null +++ b/tests/test_phase4_exit.py @@ -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 'behind (now 2)" in receipts_row + assert "Shipped: 2" in page and "Behind the current revision: 1" in page