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>
137 lines
5.4 KiB
Python
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}"
|