intent/intent/comments/presenters.py
Alec Perkins f6b7374be7 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>
2026-10-27 10:15:30 -04:00

137 lines
5.4 KiB
Python

"""Prepare threads for templates: rendered comment text, per-object permissions, line state."""
from django.db.models import Prefetch
from django.urls import reverse
from intent.access import (
can_delete_comment,
can_edit_comment,
can_promote_thread,
can_resolve_thread,
)
from intent.markdown import render, render_blocks, source_lines
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",
"file_version__file",
"author",
"resolved_by",
"promoted_to",
).prefetch_related(Prefetch("comments", queryset=Comment.objects.select_related("author")))
)
for thread in threads:
thread.can_resolve = can_resolve_thread(user, access, thread)
thread.can_promote = can_promote_thread(user, access, thread)
thread.comment_list = list(thread.comments.all())
thread.live_count = sum(1 for c in thread.comment_list if not c.is_deleted)
for comment in thread.comment_list:
comment.html = "" if comment.is_deleted else render(comment.body, heading_base=3)
comment.can_edit = can_edit_comment(user, access, comment)
comment.can_delete = can_delete_comment(user, access, comment)
return threads
def mark_line_threads(feature, threads) -> None:
"""Decide which line threads still point at the current text.
A thread is current if the description is unchanged since its revision (a status change
alone doesn't outdate it). Outdated threads keep a quote of the lines they were about.
"""
line_threads = [t for t in threads if t.kind == ThreadKind.LINE]
revisions = {t.revision for t in line_threads}
texts = dict(
feature.revisions.filter(revision__in=revisions).values_list("revision", "description")
)
for thread in line_threads:
old = texts.get(thread.revision, "")
thread.outdated = old != feature.description
thread.quoted = source_lines(old, thread.line_start, thread.line_end)
def feature_discussion(feature, user, access) -> dict:
"""Context for a feature page: description blocks with their line threads, and the rest."""
threads = prepare_threads(feature.threads.all(), user, access)
mark_line_threads(feature, threads)
blocks = render_blocks(feature.description)
by_range = {(b.start, b.end): [] for b in blocks}
outdated = []
for thread in threads:
if thread.kind != ThreadKind.LINE:
continue
key = (thread.line_start, thread.line_end)
if not thread.outdated and key in by_range:
by_range[key].append(thread)
else:
thread.outdated = True
outdated.append(thread)
return {
"blocks": [(block, by_range[(block.start, block.end)]) for block in blocks],
"general_threads": [t for t in threads if t.kind == ThreadKind.FEATURE],
"outdated_threads": outdated,
}
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 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}"