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:
parent
5811e72a0c
commit
f6b7374be7
49 changed files with 2272 additions and 31 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
]
|
||||
|
|
|
|||
22
docs/plan.md
22
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/<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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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="+"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
18
intent/features/migrations/0003_revision_files_change.py
Normal file
18
intent/features/migrations/0003_revision_files_change.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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="+"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
0
intent/files/__init__.py
Normal file
29
intent/files/admin.py
Normal file
29
intent/files/admin.py
Normal 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
6
intent/files/apps.py
Normal 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
30
intent/files/forms.py
Normal 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)
|
||||
57
intent/files/migrations/0001_initial.py
Normal file
57
intent/files/migrations/0001_initial.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
intent/files/migrations/__init__.py
Normal file
0
intent/files/migrations/__init__.py
Normal file
98
intent/files/models.py
Normal file
98
intent/files/models.py
Normal 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
103
intent/files/services.py
Normal 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
15
intent/files/urls.py
Normal 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
236
intent/files/views.py
Normal 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
|
||||
0
intent/implementations/__init__.py
Normal file
0
intent/implementations/__init__.py
Normal file
10
intent/implementations/admin.py
Normal file
10
intent/implementations/admin.py
Normal 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"]
|
||||
6
intent/implementations/apps.py
Normal file
6
intent/implementations/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ImplementationsConfig(AppConfig):
|
||||
name = "intent.implementations"
|
||||
label = "implementations"
|
||||
21
intent/implementations/forms.py
Normal file
21
intent/implementations/forms.py
Normal 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.",
|
||||
)
|
||||
56
intent/implementations/migrations/0001_initial.py
Normal file
56
intent/implementations/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
intent/implementations/migrations/__init__.py
Normal file
0
intent/implementations/migrations/__init__.py
Normal file
71
intent/implementations/models.py
Normal file
71
intent/implementations/models.py
Normal 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
|
||||
74
intent/implementations/services.py
Normal file
74
intent/implementations/services.py
Normal 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
|
||||
14
intent/implementations/urls.py
Normal file
14
intent/implementations/urls.py
Normal 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"),
|
||||
]
|
||||
155
intent/implementations/views.py
Normal file
155
intent/implementations/views.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
138
templates/files/detail.html
Normal 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 %}
|
||||
19
templates/implementations/_table.html
Normal file
19
templates/implementations/_table.html
Normal 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>
|
||||
97
templates/implementations/detail.html
Normal file
97
templates/implementations/detail.html
Normal 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 %}
|
||||
12
templates/implementations/form.html
Normal file
12
templates/implementations/form.html
Normal 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 %}
|
||||
23
templates/implementations/list.html
Normal file
23
templates/implementations/list.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
266
tests/test_files.py
Normal 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'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
|
||||
128
tests/test_implementations.py
Normal file
128
tests/test_implementations.py
Normal 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
72
tests/test_phase4_exit.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue