Add issues and comment threads (Phase 3)
Issues: per-project numbers, statuses (open, accepted, rejected, withdrawn, duplicate), feature links, edits, and a timeline of comments and events. From an issue, collaborators create or update a feature with the revision tied to the issue and an option to accept it. Comments: general and line-anchored threads on features, one discussion thread per issue, edit history, tombstone deletes, resolve, and promoting a feature thread to an issue. Line threads attach to top-level Markdown blocks and go outdated only when the text changes. HTMX swaps threads in place with focus management; every action also works as a plain form post. Markdown headings are re-levelled so user text never skips heading levels on the page. The example flow from concept.md (steps 1-7) is covered end to end in tests/test_example_flow.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
99f810d043
commit
5811e72a0c
57 changed files with 2924 additions and 37 deletions
|
|
@ -29,6 +29,8 @@ INSTALLED_APPS = [
|
||||||
"intent.orgs",
|
"intent.orgs",
|
||||||
"intent.projects",
|
"intent.projects",
|
||||||
"intent.features",
|
"intent.features",
|
||||||
|
"intent.issues",
|
||||||
|
"intent.comments",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,6 @@ urlpatterns = [
|
||||||
path("<slug:org_slug>/", include("intent.orgs.urls")),
|
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>/", include("intent.projects.urls")),
|
||||||
path("<slug:org_slug>/<slug:project_slug>/features/", include("intent.features.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>/", include("intent.comments.urls")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
37
docs/plan.md
37
docs/plan.md
|
|
@ -22,16 +22,17 @@ Decisions made in the concept are not repeated. These fill the gaps.
|
||||||
| Feature revision | Integer starting at 1, incremented by every commit to the feature: description edit, file add/replace/remove, parent change, status change. Each revision stores the full description text (not a diff); diffs are computed on display. |
|
| Feature revision | Integer starting at 1, incremented by every commit to the feature: description edit, file add/replace/remove, parent change, status change. Each revision stores the full description text (not a diff); diffs are computed on display. |
|
||||||
| Commit note | Required, short, free text. Optionally linked to one or more issues. |
|
| Commit note | Required, short, free text. Optionally linked to one or more issues. |
|
||||||
| Issue ↔ feature | Many-to-many. An issue promoted from a comment thread is linked to that thread's feature. Accepting an issue that creates a feature links both. |
|
| Issue ↔ feature | Many-to-many. An issue promoted from a comment thread is linked to that thread's feature. Accepting an issue that creates a feature links both. |
|
||||||
| Issue statuses | `open`, `accepted`, `rejected`, `withdrawn` (author closes), `duplicate` (links to another issue). Collaborators can reopen. |
|
| Issue statuses | `open`, `accepted`, `rejected`, `withdrawn` (author closes), `duplicate` (links to another issue). Collaborators can reopen any closed issue; authors can withdraw their own open issue and reopen it only if they withdrew it. Duplicate chains can't loop. Every status change, feature link and edit is an `IssueEvent` shown in the issue's timeline between comments; edits keep the previous title and text. Closed issues still take comments. |
|
||||||
|
| 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). |
|
| Feature statuses | `active`, `withdrawn`, `superseded` (requires `superseded_by` feature). |
|
||||||
| Implementation status | `active`, `archived`. |
|
| 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". |
|
||||||
| Hierarchy | Single optional parent within the same project. Cycles rejected. Moving a feature records a revision. Statuses independent in V1. |
|
| 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`. 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. |
|
||||||
| Line-anchored threads | Anchor = feature revision + start/end source line. On later revisions the thread shows as "outdated" with a link to the revision it was made on; no attempt to re-map lines. |
|
| 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). |
|
||||||
| Comments | Edits keep history (visible "edited" link). Author or project owner may delete; a deleted comment shows as a tombstone. Resolving a thread is allowed for the thread author or any collaborator. |
|
| 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 or any team member. Thread gets `promoted_to` issue; issue body is prefilled with the thread text and links back. |
|
| 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. |
|
| Deleted users | Content remains, attributed to a "ghost" user. |
|
||||||
| Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. |
|
| Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. |
|
||||||
| Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and the first signed-in visitor claims the invite with whatever account they used. Claiming rotates the token, so the link (which appears in request logs) is dead after one use; the claimant then accepts or declines from their Invitations page. Unclaimed email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Sending is throttled (30 per sender per hour, 3 per recipient per org per hour). Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
|
| Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and the first signed-in visitor claims the invite with whatever account they used. Claiming rotates the token, so the link (which appears in request logs) is dead after one use; the claimant then accepts or declines from their Invitations page. Unclaimed email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Sending is throttled (30 per sender per hour, 3 per recipient per org per hour). Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
|
||||||
|
|
@ -51,7 +52,7 @@ Decisions made in the concept are not repeated. These fill the gaps.
|
||||||
| 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. |
|
| 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. |
|
| 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. 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. |
|
||||||
| Markdown | `markdown-it-py` with source line mapping (`data-line` attributes on blocks) for line threads; output sanitized with `nh3`. Diffs via `difflib` rendered server-side. |
|
| 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, 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. |
|
||||||
| Email | `django-anymail` with the provider pluggable; console backend in development. **Provider TBD; launch blocker for magic links and email invites.** |
|
| 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. |
|
| Background work | None. Email is sent synchronously. |
|
||||||
|
|
@ -71,7 +72,8 @@ The app should look close to unstyled HTML. Guiding rule: if the stylesheet fail
|
||||||
- Keyboard: all actions reachable by tab; skip link to `<main>`; visible focus. Image pins get a list alternative (threads listed below the image with coordinates). Line threads have a per-line "comment" button, not hover-only.
|
- Keyboard: all actions reachable by tab; skip link to `<main>`; visible focus. Image pins get a list alternative (threads listed below the image with coordinates). Line threads have a per-line "comment" button, not hover-only.
|
||||||
- Contrast and semantics checked with axe in the Playwright smoke tests.
|
- Contrast and semantics checked with axe in the Playwright smoke tests.
|
||||||
- Until then, unit tests run a structural check (`tests/a11y.py`) on every page: `lang`, one `<h1>`, no skipped heading levels, unique ids, labelled form controls, `alt` on images. New pages get added to `tests/test_a11y.py`.
|
- Until then, unit tests run a structural check (`tests/a11y.py`) on every page: `lang`, one `<h1>`, no skipped heading levels, unique ids, labelled form controls, `alt` on images. New pages get added to `tests/test_a11y.py`.
|
||||||
- Markdown headings render one level down (`#` becomes `<h2>`) so user text never adds a second `<h1>`.
|
- Markdown headings are re-levelled so the shallowest heading in the text lands one below the surrounding page heading (`<h2>` in descriptions and issue bodies, `<h3>` in comments), keeping relative structure. User text never adds a second `<h1>`, and a comment starting at `##` doesn't skip a level.
|
||||||
|
- After an HTMX swap, focus moves to the new or edited comment, or to the thread (`data-autofocus`, `static/js/intent.js`).
|
||||||
|
|
||||||
## 3. Data model
|
## 3. Data model
|
||||||
|
|
||||||
|
|
@ -88,18 +90,20 @@ ProjectMembership project, user, role ∈ {owner, collaborator}
|
||||||
Feature project, number, parent (nullable, same project), title, status, superseded_by,
|
Feature project, number, parent (nullable, same project), title, status, superseded_by,
|
||||||
current_revision (int), created_by
|
current_revision (int), created_by
|
||||||
FeatureRevision feature, revision, description_md, note, author, parent_at_revision,
|
FeatureRevision feature, revision, description_md, note, author, parent_at_revision,
|
||||||
status_at_revision, issues (M2M)
|
status_at_revision (issues via Issue.revisions)
|
||||||
FeatureFile feature, kind ∈ {prototype, documentation}, name, current_version
|
FeatureFile feature, kind ∈ {prototype, documentation}, name, current_version
|
||||||
FeatureFileVersion file, version, storage_key, content_type, size, created_in_revision
|
FeatureFileVersion file, version, storage_key, content_type, size, created_in_revision
|
||||||
Implementation project, name, url, status ∈ {active, archived}
|
Implementation project, name, url, status ∈ {active, archived}
|
||||||
FeatureImplStatus feature, implementation, status, feature_revision, updated_by (unique feature+impl)
|
FeatureImplStatus feature, implementation, status, feature_revision, updated_by (unique feature+impl)
|
||||||
Issue project, number, title, body_md, author, status, duplicate_of,
|
Issue project, number, title, body_md, author, status, duplicate_of, closed_at,
|
||||||
features (M2M), promoted_from_thread (nullable)
|
features (M2M), revisions (M2M FeatureRevision)
|
||||||
CommentThread project, target: one of {feature, feature_line, feature_file, file_image_point, issue},
|
IssueEvent issue, actor, kind ∈ {status, linked, unlinked, revision, edited}, status,
|
||||||
feature, issue, revision, line_start, line_end, file_version, x, y,
|
previous_status, duplicate_of, feature, revision, old_title, old_body
|
||||||
resolved_by, resolved_at, promoted_to (issue)
|
CommentThread project, kind ∈ {feature, line, issue} (Phase 4 adds file, image point),
|
||||||
Comment thread, author, body_md, deleted_at
|
feature, issue, revision, line_start, line_end, author,
|
||||||
CommentEdit comment, body_md, edited_at
|
resolved_by, resolved_at, promoted_to (OneToOne issue)
|
||||||
|
Comment thread, author, body_md, edited_at, deleted_at, deleted_by
|
||||||
|
CommentEdit comment, body_md (text before the edit), created_at
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
@ -123,7 +127,9 @@ Resolved per request into a small `Access` object (`can_view`, `can_comment`, `c
|
||||||
| Accept/reject/reopen issue | | | | ✓ | ✓ |
|
| Accept/reject/reopen issue | | | | ✓ | ✓ |
|
||||||
| Create/edit feature, files, status | | | | ✓ | ✓ |
|
| Create/edit feature, files, status | | | | ✓ | ✓ |
|
||||||
| Link implementations, set statuses | | | | ✓ | ✓ |
|
| Link implementations, set statuses | | | | ✓ | ✓ |
|
||||||
| Resolve threads, delete comments | own | own | own | ✓ | ✓ |
|
| Resolve feature threads | | own | own | ✓ | ✓ |
|
||||||
|
| Edit comments, withdraw issues | | own | own | own | own |
|
||||||
|
| Delete comments | | own | own | own | ✓ |
|
||||||
| Manage project members, delete project | | | | | ✓ |
|
| Manage project members, delete project | | | | | ✓ |
|
||||||
| Create project | | | if `can_create_projects` | | org owner ✓ |
|
| Create project | | | if `can_create_projects` | | org owner ✓ |
|
||||||
| Manage org members, grants | | | | | org owner |
|
| Manage org members, grants | | | | | org owner |
|
||||||
|
|
@ -172,6 +178,7 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
|
||||||
- Promote thread to issue.
|
- Promote thread to issue.
|
||||||
- HTMX partials for posting comments and resolving threads, with form fallbacks.
|
- HTMX partials for posting comments and resolving threads, with form fallbacks.
|
||||||
- **Exit:** the example flow in concept.md (steps 1–7) works end to end.
|
- **Exit:** the example flow in concept.md (steps 1–7) works end to end.
|
||||||
|
- Done: `tests/test_example_flow.py` drives steps 1–7 (and the rejected alternative) through the views. HTMX reply, edit, resolve and the issue timeline were also checked once in headless Chromium; Playwright tests proper are Phase 5.
|
||||||
|
|
||||||
### Phase 4 — Files and implementations (medium)
|
### Phase 4 — Files and implementations (medium)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,3 +106,48 @@ def visible_projects(user, org: Organization):
|
||||||
return projects.filter(
|
return projects.filter(
|
||||||
Q(visibility__in=[Visibility.PUBLIC, Visibility.AUTHENTICATED]) | Q(memberships__user=user)
|
Q(visibility__in=[Visibility.PUBLIC, Visibility.AUTHENTICATED]) | Q(memberships__user=user)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
# Object-level rules for issues and comments. `access` is the user's ProjectAccess for the
|
||||||
|
# object's project. Authors keep rights over their own words only while they can still see
|
||||||
|
# (or, for edits, contribute to) the project.
|
||||||
|
|
||||||
|
|
||||||
|
def is_author(user, author_id) -> bool:
|
||||||
|
return _is_regular_user(user) and author_id is not None and author_id == user.pk
|
||||||
|
|
||||||
|
|
||||||
|
def can_edit_issue(user, access: ProjectAccess, issue) -> bool:
|
||||||
|
return access.can_edit or (is_author(user, issue.author_id) and access.can_contribute)
|
||||||
|
|
||||||
|
|
||||||
|
def can_withdraw_issue(user, access: ProjectAccess, issue) -> bool:
|
||||||
|
"""Authors close their own open issues, and may reopen ones they withdrew."""
|
||||||
|
return is_author(user, issue.author_id) and access.can_view
|
||||||
|
|
||||||
|
|
||||||
|
def can_resolve_thread(user, access: ProjectAccess, thread) -> bool:
|
||||||
|
from intent.comments.models import ThreadKind
|
||||||
|
|
||||||
|
if thread.kind == ThreadKind.ISSUE:
|
||||||
|
return False # An issue's status says whether its discussion is done.
|
||||||
|
return access.can_edit or (is_author(user, thread.author_id) and access.can_view)
|
||||||
|
|
||||||
|
|
||||||
|
def can_promote_thread(user, access: ProjectAccess, thread) -> bool:
|
||||||
|
from intent.comments.models import ThreadKind
|
||||||
|
|
||||||
|
if thread.kind == ThreadKind.ISSUE or thread.promoted_to_id:
|
||||||
|
return False
|
||||||
|
return access.can_edit or (is_author(user, thread.author_id) and access.can_contribute)
|
||||||
|
|
||||||
|
|
||||||
|
def can_edit_comment(user, access: ProjectAccess, comment) -> bool:
|
||||||
|
return not comment.is_deleted and is_author(user, comment.author_id) and access.can_contribute
|
||||||
|
|
||||||
|
|
||||||
|
def can_delete_comment(user, access: ProjectAccess, comment) -> bool:
|
||||||
|
"""Authors delete their own comments; project owners moderate."""
|
||||||
|
if comment.is_deleted:
|
||||||
|
return False
|
||||||
|
return access.can_admin or (is_author(user, comment.author_id) and access.can_view)
|
||||||
|
|
|
||||||
0
intent/comments/__init__.py
Normal file
0
intent/comments/__init__.py
Normal file
29
intent/comments/admin.py
Normal file
29
intent/comments/admin.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Comment, CommentThread
|
||||||
|
|
||||||
|
|
||||||
|
class CommentInline(admin.TabularInline):
|
||||||
|
model = Comment
|
||||||
|
extra = 0
|
||||||
|
can_delete = False
|
||||||
|
fields = ["created_at", "author", "body", "edited_at", "deleted_at"]
|
||||||
|
readonly_fields = fields
|
||||||
|
|
||||||
|
def has_add_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CommentThread)
|
||||||
|
class CommentThreadAdmin(admin.ModelAdmin):
|
||||||
|
"""Read-only: moderation happens in the app, where it leaves a tombstone."""
|
||||||
|
|
||||||
|
list_display = ["__str__", "project", "author", "created_at", "resolved_at"]
|
||||||
|
list_filter = ["kind"]
|
||||||
|
inlines = [CommentInline]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
6
intent/comments/apps.py
Normal file
6
intent/comments/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CommentsConfig(AppConfig):
|
||||||
|
name = "intent.comments"
|
||||||
|
label = "comments"
|
||||||
23
intent/comments/forms.py
Normal file
23
intent/comments/forms.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from .services import MAX_BODY
|
||||||
|
|
||||||
|
|
||||||
|
class CommentForm(forms.Form):
|
||||||
|
body = forms.CharField(max_length=MAX_BODY, widget=forms.Textarea)
|
||||||
|
|
||||||
|
|
||||||
|
class LineThreadForm(CommentForm):
|
||||||
|
revision = forms.IntegerField(min_value=1)
|
||||||
|
line_start = forms.IntegerField(min_value=1)
|
||||||
|
line_end = forms.IntegerField(min_value=1)
|
||||||
|
|
||||||
|
|
||||||
|
class PromoteForm(forms.Form):
|
||||||
|
title = forms.CharField(max_length=200)
|
||||||
|
body = forms.CharField(
|
||||||
|
label="Description",
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 16, "cols": 80}),
|
||||||
|
help_text="Markdown. Starts as a quote of the thread; trim it to what matters.",
|
||||||
|
)
|
||||||
66
intent/comments/migrations/0001_initial.py
Normal file
66
intent/comments/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Generated by Django 5.2.18 on 2026-10-27 12:49
|
||||||
|
|
||||||
|
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', '0002_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Comment',
|
||||||
|
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)),
|
||||||
|
('body', models.TextField(blank=True)),
|
||||||
|
('edited_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CommentEdit',
|
||||||
|
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)),
|
||||||
|
('body', models.TextField()),
|
||||||
|
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edits', to='comments.comment')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CommentThread',
|
||||||
|
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=[('feature', 'Feature'), ('line', 'Lines of a feature description'), ('issue', 'Issue')], max_length=20)),
|
||||||
|
('revision', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('line_start', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('line_end', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('resolved_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('feature', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='threads', to='features.feature')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
53
intent/comments/migrations/0002_initial.py
Normal file
53
intent/comments/migrations/0002_initial.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Generated by Django 5.2.18 on 2026-10-27 12:49
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('comments', '0001_initial'),
|
||||||
|
('issues', '0001_initial'),
|
||||||
|
('projects', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='commentthread',
|
||||||
|
name='issue',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='threads', to='issues.issue'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='commentthread',
|
||||||
|
name='project',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='threads', to='projects.project'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='commentthread',
|
||||||
|
name='promoted_to',
|
||||||
|
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='promoted_from', to='issues.issue'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='commentthread',
|
||||||
|
name='resolved_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='comment',
|
||||||
|
name='thread',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='comments.commentthread'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='commentthread',
|
||||||
|
constraint=models.CheckConstraint(condition=models.Q(models.Q(('feature__isnull', False), ('issue__isnull', True), ('kind', 'feature'), ('line_end__isnull', True), ('line_start__isnull', True), ('revision__isnull', True)), models.Q(('feature__isnull', False), ('issue__isnull', True), ('kind', 'line'), ('line_end__gte', models.F('line_start')), ('line_start__gte', 1), ('revision__isnull', False)), models.Q(('feature__isnull', True), ('issue__isnull', False), ('kind', 'issue'), ('line_end__isnull', True), ('line_start__isnull', True), ('revision__isnull', True)), _connector='OR'), name='thread_target'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='commentthread',
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(('kind', 'issue')), fields=('issue',), name='thread_one_per_issue'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
intent/comments/migrations/__init__.py
Normal file
0
intent/comments/migrations/__init__.py
Normal file
130
intent/comments/models.py
Normal file
130
intent/comments/models.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import F, Q
|
||||||
|
|
||||||
|
from intent.core.models import BaseModel
|
||||||
|
from intent.features.models import Feature
|
||||||
|
from intent.issues.models import Issue
|
||||||
|
from intent.projects.models import Project
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
issue = Q(kind=ThreadKind.ISSUE, issue__isnull=False, feature__isnull=True) & no_lines
|
||||||
|
return feature | line | issue
|
||||||
|
|
||||||
|
|
||||||
|
class CommentThread(BaseModel):
|
||||||
|
"""A conversation attached to a feature, some lines of its description, or an issue.
|
||||||
|
|
||||||
|
An issue has exactly one thread: its discussion. Features can have many.
|
||||||
|
"""
|
||||||
|
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="threads")
|
||||||
|
kind = models.CharField(max_length=20, choices=ThreadKind.choices)
|
||||||
|
feature = models.ForeignKey(
|
||||||
|
Feature, null=True, blank=True, on_delete=models.CASCADE, related_name="threads"
|
||||||
|
)
|
||||||
|
issue = models.ForeignKey(
|
||||||
|
Issue, null=True, blank=True, on_delete=models.CASCADE, related_name="threads"
|
||||||
|
)
|
||||||
|
# Line threads: the feature revision the lines refer to, and the 1-based inclusive range.
|
||||||
|
revision = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
line_start = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
line_end = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
author = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
resolved_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
|
promoted_to = models.OneToOneField(
|
||||||
|
Issue,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="promoted_from",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.CheckConstraint(condition=_target_is_consistent(), name="thread_target"),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["issue"],
|
||||||
|
condition=Q(kind=ThreadKind.ISSUE),
|
||||||
|
name="thread_one_per_issue",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
ordering = ["created_at", "id"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
target = self.issue.key if self.issue_id else self.feature.key
|
||||||
|
return f"{self.get_kind_display()} thread on {target}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_resolved(self) -> bool:
|
||||||
|
return self.resolved_at is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dom_id(self) -> str:
|
||||||
|
"""The id of the element that HTMX swaps when this thread changes."""
|
||||||
|
return "timeline" if self.kind == ThreadKind.ISSUE else f"thread-{self.pk}"
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(BaseModel):
|
||||||
|
thread = models.ForeignKey(CommentThread, on_delete=models.CASCADE, related_name="comments")
|
||||||
|
author = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
body = models.TextField(blank=True)
|
||||||
|
edited_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
# Deleted comments stay as tombstones so the thread still reads in order.
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
deleted_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["created_at", "id"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Comment by {self.author} on {self.thread}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_deleted(self) -> bool:
|
||||||
|
return self.deleted_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
class CommentEdit(BaseModel):
|
||||||
|
"""A comment's text before an edit. `created_at` is when it was replaced."""
|
||||||
|
|
||||||
|
comment = models.ForeignKey(Comment, on_delete=models.CASCADE, related_name="edits")
|
||||||
|
body = models.TextField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["created_at", "id"]
|
||||||
84
intent/comments/presenters.py
Normal file
84
intent/comments/presenters.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
"""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, 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"
|
||||||
|
).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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def thread_url(thread, anchor=None) -> str:
|
||||||
|
"""Where a thread lives: its feature's page, or its issue'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})
|
||||||
|
else:
|
||||||
|
url = reverse("features:detail", kwargs={**kwargs, "feature_key": thread.feature.key})
|
||||||
|
return f"{url}#{anchor or thread.dom_id}"
|
||||||
98
intent/comments/services.py
Normal file
98
intent/comments/services.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Comment thread rules. Same conventions as intent.orgs.services."""
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from intent.access import (
|
||||||
|
can_delete_comment,
|
||||||
|
can_edit_comment,
|
||||||
|
can_resolve_thread,
|
||||||
|
project_access,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .models import Comment, CommentEdit, CommentThread, ThreadKind
|
||||||
|
|
||||||
|
MAX_BODY = 20_000
|
||||||
|
STALE_MESSAGE = (
|
||||||
|
"The description changed while you were writing, so those lines may have moved. "
|
||||||
|
"Your comment wasn't posted; choose the lines again."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_body(body: str) -> str:
|
||||||
|
body = (body or "").strip()
|
||||||
|
if not body:
|
||||||
|
raise ValidationError("Write a comment first.")
|
||||||
|
if len(body) > MAX_BODY:
|
||||||
|
raise ValidationError(f"Keep comments under {MAX_BODY:,} characters.")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _require_contribute(by, project):
|
||||||
|
if not project_access(by, project).can_contribute:
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
def start_feature_thread(
|
||||||
|
feature, by, *, body, revision=None, line_start=None, line_end=None
|
||||||
|
) -> CommentThread:
|
||||||
|
"""Start a thread on a feature, or on lines of its current description."""
|
||||||
|
_require_contribute(by, feature.project)
|
||||||
|
body = clean_body(body)
|
||||||
|
fields = {"project": feature.project, "feature": feature, "author": by}
|
||||||
|
if line_start is None:
|
||||||
|
fields["kind"] = ThreadKind.FEATURE
|
||||||
|
else:
|
||||||
|
if revision != feature.current_revision:
|
||||||
|
raise ValidationError(STALE_MESSAGE)
|
||||||
|
line_count = len(feature.description.splitlines())
|
||||||
|
if not (1 <= line_start <= (line_end or 0) <= line_count):
|
||||||
|
raise ValidationError("Those lines aren't in the description.")
|
||||||
|
fields.update(
|
||||||
|
kind=ThreadKind.LINE, revision=revision, line_start=line_start, line_end=line_end
|
||||||
|
)
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
def edit_comment(comment, by, *, body) -> None:
|
||||||
|
if not can_edit_comment(by, project_access(by, comment.thread.project), comment):
|
||||||
|
raise PermissionDenied
|
||||||
|
body = clean_body(body)
|
||||||
|
if body == comment.body:
|
||||||
|
raise ValidationError("Nothing changed.")
|
||||||
|
with transaction.atomic():
|
||||||
|
CommentEdit.objects.create(comment=comment, body=comment.body)
|
||||||
|
comment.body = body
|
||||||
|
comment.edited_at = timezone.now()
|
||||||
|
comment.save(update_fields=["body", "edited_at", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def delete_comment(comment, by) -> None:
|
||||||
|
"""Leave a tombstone: the text and its edit history are removed."""
|
||||||
|
if not can_delete_comment(by, project_access(by, comment.thread.project), comment):
|
||||||
|
raise PermissionDenied
|
||||||
|
with transaction.atomic():
|
||||||
|
comment.edits.all().delete()
|
||||||
|
comment.body = ""
|
||||||
|
comment.deleted_at = timezone.now()
|
||||||
|
comment.deleted_by = by
|
||||||
|
comment.save(update_fields=["body", "deleted_at", "deleted_by", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def set_resolved(thread, by, resolved: bool) -> None:
|
||||||
|
if not can_resolve_thread(by, project_access(by, thread.project), thread):
|
||||||
|
raise PermissionDenied
|
||||||
|
if resolved == thread.is_resolved:
|
||||||
|
return
|
||||||
|
thread.resolved_at = timezone.now() if resolved else None
|
||||||
|
thread.resolved_by = by if resolved else None
|
||||||
|
thread.save(update_fields=["resolved_at", "resolved_by", "updated_at"])
|
||||||
14
intent/comments/urls.py
Normal file
14
intent/comments/urls.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "comments"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("threads/<str:thread_id>/reply/", views.thread_reply, name="reply"),
|
||||||
|
path("threads/<str:thread_id>/resolve/", views.thread_resolve, name="resolve"),
|
||||||
|
path("threads/<str:thread_id>/promote/", views.thread_promote, name="promote"),
|
||||||
|
path("comments/<str:comment_id>/edit/", views.comment_edit, name="edit"),
|
||||||
|
path("comments/<str:comment_id>/delete/", views.comment_delete, name="delete"),
|
||||||
|
path("comments/<str:comment_id>/history/", views.comment_history, name="history"),
|
||||||
|
]
|
||||||
197
intent/comments/views.py
Normal file
197
intent/comments/views.py
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
"""Thread and comment actions.
|
||||||
|
|
||||||
|
Every action is a plain form POST that redirects back to the thread. When HTMX sends it
|
||||||
|
(`HX-Request`), the response is instead the re-rendered thread, swapped in place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
from intent.access import can_promote_thread
|
||||||
|
from intent.features.views import get_feature
|
||||||
|
from intent.issues import services as issue_services
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _is_htmx(request) -> bool:
|
||||||
|
return request.headers.get("HX-Request") == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_thread(project, thread_id) -> CommentThread:
|
||||||
|
return get_object_or_404(
|
||||||
|
CommentThread.objects.select_related("project__org", "feature__project", "issue"),
|
||||||
|
project=project,
|
||||||
|
pk=thread_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
thread__project=project,
|
||||||
|
pk=comment_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_thread(request, project, access, thread, *, focus=None, error=None, draft=""):
|
||||||
|
"""The HTMX fragment for a thread: an issue's timeline, or a single feature thread."""
|
||||||
|
context = {"project": project, "access": access, "focus": focus, "error": error}
|
||||||
|
if thread.kind == ThreadKind.ISSUE:
|
||||||
|
from intent.issues.views import timeline_context
|
||||||
|
|
||||||
|
context.update(timeline_context(thread.issue, request.user, access))
|
||||||
|
context["draft"] = draft
|
||||||
|
return render(request, "issues/_timeline.html", context)
|
||||||
|
(prepared,) = prepare_threads(CommentThread.objects.filter(pk=thread.pk), request.user, access)
|
||||||
|
if prepared.kind == ThreadKind.LINE:
|
||||||
|
mark_line_threads(prepared.feature, [prepared])
|
||||||
|
prepared.draft = draft
|
||||||
|
context["thread"] = prepared
|
||||||
|
return render(request, "comments/_thread.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def _done(request, project, access, thread, *, focus=None):
|
||||||
|
if _is_htmx(request):
|
||||||
|
return render_thread(request, project, access, thread, focus=focus)
|
||||||
|
return redirect(thread_url(thread, focus))
|
||||||
|
|
||||||
|
|
||||||
|
def _failed(request, project, access, thread, exc, draft="", back=None):
|
||||||
|
"""Show what went wrong without losing what the person wrote."""
|
||||||
|
error = " ".join(exc.messages)
|
||||||
|
if _is_htmx(request) and thread is not None:
|
||||||
|
return render_thread(request, project, access, thread, error=error, draft=draft)
|
||||||
|
back = back or thread_url(thread)
|
||||||
|
context = {"project": project, "access": access, "error": error, "draft": draft, "back": back}
|
||||||
|
return render(request, "comments/not_posted.html", context, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
def _form_error(form) -> ValidationError:
|
||||||
|
return ValidationError([e for errors in form.errors.values() for e in errors])
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_contribute")
|
||||||
|
@require_POST
|
||||||
|
def feature_thread_create(request, project, access, feature_key):
|
||||||
|
feature = get_feature(project, feature_key)
|
||||||
|
on_lines = "line_start" in request.POST
|
||||||
|
form = (LineThreadForm if on_lines else CommentForm)(request.POST)
|
||||||
|
draft = request.POST.get("body", "")
|
||||||
|
if not form.is_valid():
|
||||||
|
exc = _form_error(form)
|
||||||
|
else:
|
||||||
|
data = form.cleaned_data
|
||||||
|
lines = {k: data[k] for k in ("revision", "line_start", "line_end")} if on_lines else {}
|
||||||
|
try:
|
||||||
|
thread = services.start_feature_thread(
|
||||||
|
feature, request.user, body=data["body"], **lines
|
||||||
|
)
|
||||||
|
except ValidationError as error:
|
||||||
|
exc = error
|
||||||
|
else:
|
||||||
|
return redirect(thread_url(thread))
|
||||||
|
back = reverse(
|
||||||
|
"features:detail",
|
||||||
|
kwargs={
|
||||||
|
"org_slug": project.org.slug,
|
||||||
|
"project_slug": project.slug,
|
||||||
|
"feature_key": feature.key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return _failed(request, project, access, None, exc, draft, back=back)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_contribute")
|
||||||
|
@require_POST
|
||||||
|
def thread_reply(request, project, access, thread_id):
|
||||||
|
thread = _get_thread(project, thread_id)
|
||||||
|
try:
|
||||||
|
comment = services.reply(thread, request.user, body=request.POST.get("body", ""))
|
||||||
|
except ValidationError as exc:
|
||||||
|
return _failed(request, project, access, thread, exc, request.POST.get("body", ""))
|
||||||
|
return _done(request, project, access, thread, focus=f"comment-{comment.pk}")
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
@require_POST
|
||||||
|
def thread_resolve(request, project, access, thread_id):
|
||||||
|
thread = _get_thread(project, thread_id)
|
||||||
|
services.set_resolved(thread, request.user, request.POST.get("resolved") == "1")
|
||||||
|
return _done(request, project, access, thread, focus=thread.dom_id)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_contribute")
|
||||||
|
def thread_promote(request, project, access, thread_id):
|
||||||
|
thread = _get_thread(project, thread_id)
|
||||||
|
if request.method == "POST":
|
||||||
|
form = PromoteForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
issue = issue_services.promote_thread(thread, request.user, **form.cleaned_data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
form.add_error(None, exc)
|
||||||
|
else:
|
||||||
|
messages.success(request, f"Opened {issue.key} from the thread.")
|
||||||
|
return redirect(
|
||||||
|
"issues:detail",
|
||||||
|
org_slug=project.org.slug,
|
||||||
|
project_slug=project.slug,
|
||||||
|
number=issue.number,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Check up front, so people without the right don't fill in the form for nothing.
|
||||||
|
if not can_promote_thread(request.user, access, thread):
|
||||||
|
raise PermissionDenied
|
||||||
|
title, body = issue_services.promotion_text(thread)
|
||||||
|
form = PromoteForm(initial={"title": title, "body": body})
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"access": access,
|
||||||
|
"thread": thread,
|
||||||
|
"form": form,
|
||||||
|
"back": thread_url(thread),
|
||||||
|
}
|
||||||
|
return render(request, "comments/promote.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
@require_POST
|
||||||
|
def comment_edit(request, project, access, comment_id):
|
||||||
|
comment = _get_comment(project, comment_id)
|
||||||
|
try:
|
||||||
|
services.edit_comment(comment, request.user, body=request.POST.get("body", ""))
|
||||||
|
except ValidationError as exc:
|
||||||
|
return _failed(request, project, access, comment.thread, exc, request.POST.get("body", ""))
|
||||||
|
return _done(request, project, access, comment.thread, focus=f"comment-{comment.pk}")
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
@require_POST
|
||||||
|
def comment_delete(request, project, access, comment_id):
|
||||||
|
comment = _get_comment(project, comment_id)
|
||||||
|
services.delete_comment(comment, request.user)
|
||||||
|
return _done(request, project, access, comment.thread, focus=f"comment-{comment.pk}")
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
def comment_history(request, project, access, comment_id):
|
||||||
|
comment = _get_comment(project, comment_id)
|
||||||
|
versions = list(comment.edits.all())
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"access": access,
|
||||||
|
"comment": comment,
|
||||||
|
"versions": versions,
|
||||||
|
"back": thread_url(comment.thread, f"comment-{comment.pk}"),
|
||||||
|
}
|
||||||
|
return render(request, "comments/history.html", context)
|
||||||
|
|
@ -31,8 +31,12 @@ class FeatureForm(forms.Form):
|
||||||
help_text="Why is this changing? Kept in the feature's history.",
|
help_text="Why is this changing? Kept in the feature's history.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, project, feature=None, exclude_ids=(), **kwargs):
|
def __init__(self, *args, project, feature=None, exclude_ids=(), issue=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
if issue is not None and issue.is_open:
|
||||||
|
self.fields["accept_issue"] = forms.BooleanField(
|
||||||
|
required=False, initial=True, label=f"Mark issue {issue.key} accepted"
|
||||||
|
)
|
||||||
others = project.features.select_related("project")
|
others = project.features.select_related("project")
|
||||||
if feature:
|
if feature:
|
||||||
others = others.exclude(pk=feature.pk)
|
others = others.exclude(pk=feature.pk)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ from intent.projects.models import Project
|
||||||
|
|
||||||
from .models import Feature, FeatureRevision, FeatureStatus
|
from .models import Feature, FeatureRevision, FeatureStatus
|
||||||
|
|
||||||
|
# Imported late in functions: intent.issues depends on this app's models.
|
||||||
|
|
||||||
TRACKED_FIELDS = ["title", "description", "parent_id", "status", "superseded_by_id"]
|
TRACKED_FIELDS = ["title", "description", "parent_id", "status", "superseded_by_id"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -76,7 +78,20 @@ def _record(feature, by, note) -> FeatureRevision:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_feature(project, by, *, title, description="", parent=None, note="") -> Feature:
|
def _tie_to_issues(revision, by, issues, accept) -> None:
|
||||||
|
"""Link a revision to the issues it was made for, accepting any that are still open."""
|
||||||
|
from intent.issues import services as issue_services
|
||||||
|
from intent.issues.models import IssueStatus
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
issue_services.record_revision(issue, by, revision)
|
||||||
|
if accept and issue.is_open:
|
||||||
|
issue_services.set_status(issue, by, IssueStatus.ACCEPTED)
|
||||||
|
|
||||||
|
|
||||||
|
def create_feature(
|
||||||
|
project, by, *, title, description="", parent=None, note="", issues=(), accept_issues=False
|
||||||
|
) -> Feature:
|
||||||
_require_edit(by, project)
|
_require_edit(by, project)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
Project.objects.filter(pk=project.pk).update(
|
Project.objects.filter(pk=project.pk).update(
|
||||||
|
|
@ -93,12 +108,18 @@ def create_feature(project, by, *, title, description="", parent=None, note="")
|
||||||
)
|
)
|
||||||
_validate(feature)
|
_validate(feature)
|
||||||
feature.save()
|
feature.save()
|
||||||
_record(feature, by, note.strip() or "Created.")
|
revision = _record(feature, by, note.strip() or "Created.")
|
||||||
|
_tie_to_issues(revision, by, issues, accept_issues)
|
||||||
return feature
|
return feature
|
||||||
|
|
||||||
|
|
||||||
def update_feature(feature, by, *, note, **changes) -> FeatureRevision:
|
def update_feature(
|
||||||
"""Apply changes to tracked fields and record a revision. `note` explains why."""
|
feature, by, *, note, issues=(), accept_issues=False, **changes
|
||||||
|
) -> FeatureRevision:
|
||||||
|
"""Apply changes to tracked fields and record a revision. `note` explains why.
|
||||||
|
|
||||||
|
`issues` are the issues this change was made for; `accept_issues` accepts the open ones.
|
||||||
|
"""
|
||||||
_require_edit(by, feature.project)
|
_require_edit(by, feature.project)
|
||||||
note = note.strip()
|
note = note.strip()
|
||||||
if not note:
|
if not note:
|
||||||
|
|
@ -113,4 +134,6 @@ def update_feature(feature, by, *, note, **changes) -> FeatureRevision:
|
||||||
raise ValidationError("Nothing changed.")
|
raise ValidationError("Nothing changed.")
|
||||||
feature.current_revision += 1
|
feature.current_revision += 1
|
||||||
feature.save()
|
feature.save()
|
||||||
return _record(feature, by, note)
|
revision = _record(feature, by, note)
|
||||||
|
_tie_to_issues(revision, by, issues, accept_issues)
|
||||||
|
return revision
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
|
from intent.comments import views as comment_views
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
app_name = "features"
|
app_name = "features"
|
||||||
|
|
@ -12,4 +14,9 @@ urlpatterns = [
|
||||||
path("<str:feature_key>/history/", views.feature_history, name="history"),
|
path("<str:feature_key>/history/", views.feature_history, name="history"),
|
||||||
path("<str:feature_key>/history/<int:revision>/", views.feature_revision, name="revision"),
|
path("<str:feature_key>/history/<int:revision>/", views.feature_revision, name="revision"),
|
||||||
path("<str:feature_key>/compare/", views.feature_compare, name="compare"),
|
path("<str:feature_key>/compare/", views.feature_compare, name="compare"),
|
||||||
|
path(
|
||||||
|
"<str:feature_key>/threads/",
|
||||||
|
comment_views.feature_thread_create,
|
||||||
|
name="thread_create",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models import Prefetch
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
|
||||||
|
from intent.comments.presenters import feature_discussion
|
||||||
from intent.markdown import render as render_markdown
|
from intent.markdown import render as render_markdown
|
||||||
|
from intent.markdown import render_blocks
|
||||||
from intent.projects.decorators import project_view
|
from intent.projects.decorators import project_view
|
||||||
|
|
||||||
from . import services
|
from . import services
|
||||||
|
|
@ -23,6 +26,16 @@ def get_feature(project, feature_key) -> Feature:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_from_query(request, project):
|
||||||
|
"""The issue a feature change is being made for, from `?issue=N`."""
|
||||||
|
from intent.issues.models import Issue
|
||||||
|
|
||||||
|
number = request.GET.get("issue", "")
|
||||||
|
if not number.isdigit():
|
||||||
|
return None
|
||||||
|
return get_object_or_404(Issue, project=project, number=int(number))
|
||||||
|
|
||||||
|
|
||||||
def _feature_redirect(feature, name="detail"):
|
def _feature_redirect(feature, name="detail"):
|
||||||
return redirect(
|
return redirect(
|
||||||
f"features:{name}",
|
f"features:{name}",
|
||||||
|
|
@ -47,7 +60,10 @@ def feature_create(request, project, access):
|
||||||
initial = {}
|
initial = {}
|
||||||
if parent_key := request.GET.get("parent"):
|
if parent_key := request.GET.get("parent"):
|
||||||
initial["parent"] = get_feature(project, parent_key)
|
initial["parent"] = get_feature(project, parent_key)
|
||||||
form = FeatureForm(request.POST or None, project=project, initial=initial)
|
issue = _issue_from_query(request, project)
|
||||||
|
if issue:
|
||||||
|
initial.update(title=issue.title, description=issue.body, note=f"Created for {issue.key}.")
|
||||||
|
form = FeatureForm(request.POST or None, project=project, initial=initial, issue=issue)
|
||||||
preview = None
|
preview = None
|
||||||
if request.method == "POST" and form.is_valid():
|
if request.method == "POST" and form.is_valid():
|
||||||
data = form.cleaned_data
|
data = form.cleaned_data
|
||||||
|
|
@ -62,13 +78,21 @@ def feature_create(request, project, access):
|
||||||
description=data["description"],
|
description=data["description"],
|
||||||
parent=data["parent"],
|
parent=data["parent"],
|
||||||
note=data["note"],
|
note=data["note"],
|
||||||
|
issues=[issue] if issue else [],
|
||||||
|
accept_issues=data.get("accept_issue", False),
|
||||||
)
|
)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
form.add_error(None, exc)
|
form.add_error(None, exc)
|
||||||
else:
|
else:
|
||||||
messages.success(request, f"Created {feature.key}.")
|
messages.success(request, f"Created {feature.key}.")
|
||||||
return _feature_redirect(feature)
|
return _feature_redirect(feature)
|
||||||
context = {"project": project, "access": access, "form": form, "preview": preview}
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"access": access,
|
||||||
|
"form": form,
|
||||||
|
"preview": preview,
|
||||||
|
"issue": issue,
|
||||||
|
}
|
||||||
return render(request, "features/form.html", context)
|
return render(request, "features/form.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,7 +108,8 @@ def feature_detail(request, project, access, feature_key):
|
||||||
"ancestors": _ancestors(feature),
|
"ancestors": _ancestors(feature),
|
||||||
"children": feature.children.select_related("project"),
|
"children": feature.children.select_related("project"),
|
||||||
"supersedes": feature.supersedes.select_related("project"),
|
"supersedes": feature.supersedes.select_related("project"),
|
||||||
"description": render_markdown(feature.description),
|
"issues": feature.issues.select_related("project"),
|
||||||
|
**feature_discussion(feature, request.user, access),
|
||||||
}
|
}
|
||||||
return render(request, "features/detail.html", context)
|
return render(request, "features/detail.html", context)
|
||||||
|
|
||||||
|
|
@ -100,6 +125,7 @@ def _ancestors(feature) -> list[Feature]:
|
||||||
@project_view(require="can_edit")
|
@project_view(require="can_edit")
|
||||||
def feature_edit(request, project, access, feature_key):
|
def feature_edit(request, project, access, feature_key):
|
||||||
feature = get_feature(project, feature_key)
|
feature = get_feature(project, feature_key)
|
||||||
|
issue = _issue_from_query(request, project)
|
||||||
initial = {
|
initial = {
|
||||||
"title": feature.title,
|
"title": feature.title,
|
||||||
"description": feature.description,
|
"description": feature.description,
|
||||||
|
|
@ -107,12 +133,15 @@ def feature_edit(request, project, access, feature_key):
|
||||||
"status": feature.status,
|
"status": feature.status,
|
||||||
"superseded_by": feature.superseded_by,
|
"superseded_by": feature.superseded_by,
|
||||||
}
|
}
|
||||||
|
if issue:
|
||||||
|
initial["note"] = f"{issue.key}: {issue.title}"[:500]
|
||||||
form = FeatureForm(
|
form = FeatureForm(
|
||||||
request.POST or None,
|
request.POST or None,
|
||||||
project=project,
|
project=project,
|
||||||
feature=feature,
|
feature=feature,
|
||||||
exclude_ids=services.descendant_ids(feature),
|
exclude_ids=services.descendant_ids(feature),
|
||||||
initial=initial,
|
initial=initial,
|
||||||
|
issue=issue,
|
||||||
)
|
)
|
||||||
preview = None
|
preview = None
|
||||||
if request.method == "POST" and form.is_valid():
|
if request.method == "POST" and form.is_valid():
|
||||||
|
|
@ -120,8 +149,15 @@ def feature_edit(request, project, access, feature_key):
|
||||||
if "preview" in request.POST:
|
if "preview" in request.POST:
|
||||||
preview = render_markdown(data["description"])
|
preview = render_markdown(data["description"])
|
||||||
else:
|
else:
|
||||||
|
accept = data.pop("accept_issue", False)
|
||||||
try:
|
try:
|
||||||
services.update_feature(feature, request.user, **data)
|
services.update_feature(
|
||||||
|
feature,
|
||||||
|
request.user,
|
||||||
|
issues=[issue] if issue else [],
|
||||||
|
accept_issues=accept,
|
||||||
|
**data,
|
||||||
|
)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
form.add_error(None, exc)
|
form.add_error(None, exc)
|
||||||
else:
|
else:
|
||||||
|
|
@ -133,6 +169,7 @@ def feature_edit(request, project, access, feature_key):
|
||||||
"feature": feature,
|
"feature": feature,
|
||||||
"form": form,
|
"form": form,
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
|
"issue": issue,
|
||||||
}
|
}
|
||||||
return render(request, "features/form.html", context)
|
return render(request, "features/form.html", context)
|
||||||
|
|
||||||
|
|
@ -156,7 +193,13 @@ def _changes(revision, previous) -> list[str]:
|
||||||
@project_view()
|
@project_view()
|
||||||
def feature_history(request, project, access, feature_key):
|
def feature_history(request, project, access, feature_key):
|
||||||
feature = get_feature(project, feature_key)
|
feature = get_feature(project, feature_key)
|
||||||
revisions = list(feature.revisions.select_related("author"))
|
from intent.issues.models import Issue
|
||||||
|
|
||||||
|
revisions = list(
|
||||||
|
feature.revisions.select_related("author").prefetch_related(
|
||||||
|
Prefetch("issues", queryset=Issue.objects.order_by("number"))
|
||||||
|
)
|
||||||
|
)
|
||||||
rows = [
|
rows = [
|
||||||
{"revision": rev, "changes": _changes(rev, revisions[i - 1] if i else None)}
|
{"revision": rev, "changes": _changes(rev, revisions[i - 1] if i else None)}
|
||||||
for i, rev in enumerate(revisions)
|
for i, rev in enumerate(revisions)
|
||||||
|
|
@ -180,7 +223,7 @@ def feature_revision(request, project, access, feature_key, revision):
|
||||||
"access": access,
|
"access": access,
|
||||||
"feature": feature,
|
"feature": feature,
|
||||||
"snapshot": snapshot,
|
"snapshot": snapshot,
|
||||||
"description": render_markdown(snapshot.description),
|
"blocks": render_blocks(snapshot.description),
|
||||||
}
|
}
|
||||||
return render(request, "features/revision.html", context)
|
return render(request, "features/revision.html", context)
|
||||||
|
|
||||||
|
|
|
||||||
0
intent/issues/__init__.py
Normal file
0
intent/issues/__init__.py
Normal file
31
intent/issues/admin.py
Normal file
31
intent/issues/admin.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Issue, IssueEvent
|
||||||
|
|
||||||
|
|
||||||
|
class IssueEventInline(admin.TabularInline):
|
||||||
|
model = IssueEvent
|
||||||
|
fk_name = "issue"
|
||||||
|
extra = 0
|
||||||
|
can_delete = False
|
||||||
|
fields = ["created_at", "actor", "kind", "status", "feature", "revision"]
|
||||||
|
readonly_fields = fields
|
||||||
|
|
||||||
|
def has_add_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Issue)
|
||||||
|
class IssueAdmin(admin.ModelAdmin):
|
||||||
|
"""Read-only: changes go through the app so they're recorded in the timeline."""
|
||||||
|
|
||||||
|
list_display = ["__str__", "project", "status", "author", "created_at"]
|
||||||
|
list_filter = ["status"]
|
||||||
|
search_fields = ["title"]
|
||||||
|
inlines = [IssueEventInline]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
6
intent/issues/apps.py
Normal file
6
intent/issues/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class IssuesConfig(AppConfig):
|
||||||
|
name = "intent.issues"
|
||||||
|
label = "issues"
|
||||||
58
intent/issues/forms.py
Normal file
58
intent/issues/forms.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from intent.features.forms import FeatureChoiceField
|
||||||
|
|
||||||
|
from .models import Issue
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||||
|
def label_from_instance(self, obj):
|
||||||
|
return f"{obj.key} {obj.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class IssueForm(forms.Form):
|
||||||
|
title = forms.CharField(max_length=200)
|
||||||
|
body = forms.CharField(
|
||||||
|
label="Description",
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 12, "cols": 80}),
|
||||||
|
help_text="Markdown. What's the problem, or what would you like to change?",
|
||||||
|
)
|
||||||
|
features = FeatureMultipleChoiceField(
|
||||||
|
queryset=None,
|
||||||
|
required=False,
|
||||||
|
label="Related features",
|
||||||
|
help_text="Optional. Hold Ctrl or ⌘ to choose more than one.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, project, include_features=True, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if include_features:
|
||||||
|
self.fields["features"].queryset = project.features.select_related("project")
|
||||||
|
else:
|
||||||
|
del self.fields["features"]
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateForm(forms.Form):
|
||||||
|
number = forms.IntegerField(min_value=1, label="Duplicate of issue number")
|
||||||
|
|
||||||
|
def __init__(self, *args, project, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.project = project
|
||||||
|
|
||||||
|
def clean_number(self):
|
||||||
|
number = self.cleaned_data["number"]
|
||||||
|
original = Issue.objects.filter(project=self.project, number=number).first()
|
||||||
|
if original is None:
|
||||||
|
raise forms.ValidationError(f"There's no issue #{number} in this project.")
|
||||||
|
return original
|
||||||
|
|
||||||
|
|
||||||
|
class LinkFeatureForm(forms.Form):
|
||||||
|
feature = FeatureChoiceField(queryset=None, label="Feature")
|
||||||
|
|
||||||
|
def __init__(self, *args, issue, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["feature"].queryset = issue.project.features.select_related("project").exclude(
|
||||||
|
issues=issue
|
||||||
|
)
|
||||||
70
intent/issues/migrations/0001_initial.py
Normal file
70
intent/issues/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Generated by Django 5.2.18 on 2026-10-27 12:49
|
||||||
|
|
||||||
|
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', '0002_initial'),
|
||||||
|
('projects', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Issue',
|
||||||
|
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)),
|
||||||
|
('number', models.PositiveIntegerField()),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('body', models.TextField(blank=True)),
|
||||||
|
('status', models.CharField(choices=[('open', 'Open'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('withdrawn', 'Withdrawn'), ('duplicate', 'Duplicate')], default='open', max_length=20)),
|
||||||
|
('closed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('duplicate_of', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='duplicates', to='issues.issue')),
|
||||||
|
('features', models.ManyToManyField(blank=True, related_name='issues', to='features.feature')),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issues', to='projects.project')),
|
||||||
|
('revisions', models.ManyToManyField(blank=True, related_name='issues', to='features.featurerevision')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-number'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='IssueEvent',
|
||||||
|
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=[('status', 'Status changed'), ('linked', 'Feature linked'), ('unlinked', 'Feature unlinked'), ('revision', 'Feature revised'), ('edited', 'Edited')], max_length=20)),
|
||||||
|
('status', models.CharField(blank=True, choices=[('open', 'Open'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('withdrawn', 'Withdrawn'), ('duplicate', 'Duplicate')], max_length=20)),
|
||||||
|
('previous_status', models.CharField(blank=True, choices=[('open', 'Open'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('withdrawn', 'Withdrawn'), ('duplicate', 'Duplicate')], max_length=20)),
|
||||||
|
('old_title', models.CharField(blank=True, max_length=200)),
|
||||||
|
('old_body', models.TextField(blank=True)),
|
||||||
|
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('duplicate_of', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='issues.issue')),
|
||||||
|
('feature', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='features.feature')),
|
||||||
|
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='issues.issue')),
|
||||||
|
('revision', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='features.featurerevision')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='issue',
|
||||||
|
constraint=models.UniqueConstraint(fields=('project', 'number'), name='issue_unique_number'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='issue',
|
||||||
|
constraint=models.CheckConstraint(condition=models.Q(models.Q(('status', 'duplicate'), ('duplicate_of__isnull', False)), models.Q(models.Q(('status', 'duplicate'), _negated=True), models.Q(('duplicate_of__isnull', False), _negated=True)), _connector='OR'), name='issue_duplicate_of'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
intent/issues/migrations/__init__.py
Normal file
0
intent/issues/migrations/__init__.py
Normal file
100
intent/issues/models.py
Normal file
100
intent/issues/models.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from intent.core.models import BaseModel
|
||||||
|
from intent.features.models import Feature, FeatureRevision
|
||||||
|
from intent.projects.models import Project
|
||||||
|
|
||||||
|
|
||||||
|
class IssueStatus(models.TextChoices):
|
||||||
|
OPEN = "open", "Open"
|
||||||
|
ACCEPTED = "accepted", "Accepted"
|
||||||
|
REJECTED = "rejected", "Rejected"
|
||||||
|
WITHDRAWN = "withdrawn", "Withdrawn"
|
||||||
|
DUPLICATE = "duplicate", "Duplicate"
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_needs_original():
|
||||||
|
is_duplicate = Q(status=IssueStatus.DUPLICATE)
|
||||||
|
has_original = Q(duplicate_of__isnull=False)
|
||||||
|
return (is_duplicate & has_original) | (~is_duplicate & ~has_original)
|
||||||
|
|
||||||
|
|
||||||
|
class Issue(BaseModel):
|
||||||
|
"""A problem or proposal. Discussed in its own comment thread, then decided."""
|
||||||
|
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="issues")
|
||||||
|
number = models.PositiveIntegerField()
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
body = models.TextField(blank=True)
|
||||||
|
author = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
status = models.CharField(max_length=20, choices=IssueStatus.choices, default=IssueStatus.OPEN)
|
||||||
|
# RESTRICT (not PROTECT) so deleting a whole project can still cascade.
|
||||||
|
duplicate_of = models.ForeignKey(
|
||||||
|
"self", null=True, blank=True, on_delete=models.RESTRICT, related_name="duplicates"
|
||||||
|
)
|
||||||
|
closed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
features = models.ManyToManyField(Feature, blank=True, related_name="issues")
|
||||||
|
# Feature revisions made because of this issue ("edits can be tied to an issue").
|
||||||
|
revisions = models.ManyToManyField(FeatureRevision, blank=True, related_name="issues")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["project", "number"], name="issue_unique_number"),
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=_duplicate_needs_original(), name="issue_duplicate_of"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
ordering = ["-number"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.key} {self.title}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return f"#{self.number}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
return self.status == IssueStatus.OPEN
|
||||||
|
|
||||||
|
|
||||||
|
class EventKind(models.TextChoices):
|
||||||
|
STATUS = "status", "Status changed"
|
||||||
|
LINKED = "linked", "Feature linked"
|
||||||
|
UNLINKED = "unlinked", "Feature unlinked"
|
||||||
|
REVISION = "revision", "Feature revised"
|
||||||
|
EDITED = "edited", "Edited"
|
||||||
|
|
||||||
|
|
||||||
|
class IssueEvent(BaseModel):
|
||||||
|
"""Something that happened to an issue, shown in its timeline between comments."""
|
||||||
|
|
||||||
|
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="events")
|
||||||
|
actor = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
kind = models.CharField(max_length=20, choices=EventKind.choices)
|
||||||
|
status = models.CharField(max_length=20, choices=IssueStatus.choices, blank=True)
|
||||||
|
previous_status = models.CharField(max_length=20, choices=IssueStatus.choices, blank=True)
|
||||||
|
duplicate_of = models.ForeignKey(
|
||||||
|
Issue, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
feature = models.ForeignKey(
|
||||||
|
Feature, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
revision = models.ForeignKey(
|
||||||
|
FeatureRevision, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
# For edits: the text before the change.
|
||||||
|
old_title = models.CharField(max_length=200, blank=True)
|
||||||
|
old_body = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["created_at", "id"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.issue.key} {self.get_kind_display()}"
|
||||||
193
intent/issues/services.py
Normal file
193
intent/issues/services.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""Issue rules. Same conventions as intent.orgs.services."""
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import F
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from intent.access import (
|
||||||
|
can_edit_issue,
|
||||||
|
can_promote_thread,
|
||||||
|
can_withdraw_issue,
|
||||||
|
project_access,
|
||||||
|
)
|
||||||
|
from intent.comments.models import CommentThread, ThreadKind
|
||||||
|
from intent.markdown import source_lines
|
||||||
|
from intent.projects.models import Project
|
||||||
|
|
||||||
|
from .models import EventKind, Issue, IssueEvent, IssueStatus
|
||||||
|
|
||||||
|
MAX_BODY = 50_000
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(title, body):
|
||||||
|
title, body = title.strip(), body.strip()
|
||||||
|
if not title:
|
||||||
|
raise ValidationError("Give the issue a title.")
|
||||||
|
if len(body) > MAX_BODY:
|
||||||
|
raise ValidationError(f"Keep the description under {MAX_BODY:,} characters.")
|
||||||
|
return title, body
|
||||||
|
|
||||||
|
|
||||||
|
def _check_features(project, features):
|
||||||
|
for feature in features:
|
||||||
|
if feature.project_id != project.pk:
|
||||||
|
raise ValidationError("Linked features must be in the same project.")
|
||||||
|
|
||||||
|
|
||||||
|
def create_issue(project, by, *, title, body="", features=()) -> Issue:
|
||||||
|
if not project_access(by, project).can_contribute:
|
||||||
|
raise PermissionDenied
|
||||||
|
title, body = _clean_text(title, body)
|
||||||
|
_check_features(project, features)
|
||||||
|
with transaction.atomic():
|
||||||
|
Project.objects.filter(pk=project.pk).update(next_issue_number=F("next_issue_number") + 1)
|
||||||
|
project.refresh_from_db(fields=["next_issue_number"])
|
||||||
|
issue = Issue.objects.create(
|
||||||
|
project=project,
|
||||||
|
number=project.next_issue_number - 1,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
author=by,
|
||||||
|
)
|
||||||
|
issue.features.set(features)
|
||||||
|
CommentThread.objects.create(project=project, kind=ThreadKind.ISSUE, issue=issue, author=by)
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def update_issue(issue, by, *, title, body) -> None:
|
||||||
|
if not can_edit_issue(by, project_access(by, issue.project), issue):
|
||||||
|
raise PermissionDenied
|
||||||
|
title, body = _clean_text(title, body)
|
||||||
|
if (title, body) == (issue.title, issue.body):
|
||||||
|
raise ValidationError("Nothing changed.")
|
||||||
|
with transaction.atomic():
|
||||||
|
IssueEvent.objects.create(
|
||||||
|
issue=issue,
|
||||||
|
actor=by,
|
||||||
|
kind=EventKind.EDITED,
|
||||||
|
old_title=issue.title,
|
||||||
|
old_body=issue.body,
|
||||||
|
)
|
||||||
|
issue.title, issue.body = title, body
|
||||||
|
issue.save(update_fields=["title", "body", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def _check_status_permission(issue, by, status) -> None:
|
||||||
|
access = project_access(by, issue.project)
|
||||||
|
if access.can_edit:
|
||||||
|
return
|
||||||
|
author = can_withdraw_issue(by, access, issue)
|
||||||
|
if status == IssueStatus.WITHDRAWN and author and issue.status == IssueStatus.OPEN:
|
||||||
|
return
|
||||||
|
if status == IssueStatus.OPEN and author and issue.status == IssueStatus.WITHDRAWN:
|
||||||
|
return
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
def _check_duplicate_of(issue, original) -> None:
|
||||||
|
if original is None:
|
||||||
|
raise ValidationError("Say which issue this duplicates.")
|
||||||
|
if original.project_id != issue.project_id:
|
||||||
|
raise ValidationError("The original must be in the same project.")
|
||||||
|
node = original
|
||||||
|
while node is not None:
|
||||||
|
if node.pk == issue.pk:
|
||||||
|
raise ValidationError("An issue can't be a duplicate of itself or of its duplicates.")
|
||||||
|
node = node.duplicate_of
|
||||||
|
|
||||||
|
|
||||||
|
def set_status(issue, by, status, *, duplicate_of=None) -> IssueEvent:
|
||||||
|
"""Accept, reject, withdraw, mark duplicate, or reopen."""
|
||||||
|
_check_status_permission(issue, by, status)
|
||||||
|
with transaction.atomic():
|
||||||
|
issue.refresh_from_db()
|
||||||
|
if status == IssueStatus.DUPLICATE:
|
||||||
|
_check_duplicate_of(issue, duplicate_of)
|
||||||
|
else:
|
||||||
|
duplicate_of = None
|
||||||
|
if status == issue.status and duplicate_of == issue.duplicate_of:
|
||||||
|
raise ValidationError(f"{issue.key} is already {issue.get_status_display().lower()}.")
|
||||||
|
previous = issue.status
|
||||||
|
issue.status = status
|
||||||
|
issue.duplicate_of = duplicate_of
|
||||||
|
issue.closed_at = None if status == IssueStatus.OPEN else timezone.now()
|
||||||
|
issue.save(update_fields=["status", "duplicate_of", "closed_at", "updated_at"])
|
||||||
|
return IssueEvent.objects.create(
|
||||||
|
issue=issue,
|
||||||
|
actor=by,
|
||||||
|
kind=EventKind.STATUS,
|
||||||
|
status=status,
|
||||||
|
previous_status=previous,
|
||||||
|
duplicate_of=duplicate_of,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def link_feature(issue, by, feature) -> None:
|
||||||
|
if not project_access(by, issue.project).can_edit:
|
||||||
|
raise PermissionDenied
|
||||||
|
_check_features(issue.project, [feature])
|
||||||
|
if issue.features.filter(pk=feature.pk).exists():
|
||||||
|
raise ValidationError(f"{feature.key} is already linked.")
|
||||||
|
with transaction.atomic():
|
||||||
|
issue.features.add(feature)
|
||||||
|
IssueEvent.objects.create(issue=issue, actor=by, kind=EventKind.LINKED, feature=feature)
|
||||||
|
|
||||||
|
|
||||||
|
def unlink_feature(issue, by, feature) -> None:
|
||||||
|
if not project_access(by, issue.project).can_edit:
|
||||||
|
raise PermissionDenied
|
||||||
|
if not issue.features.filter(pk=feature.pk).exists():
|
||||||
|
raise ValidationError(f"{feature.key} isn't linked.")
|
||||||
|
with transaction.atomic():
|
||||||
|
issue.features.remove(feature)
|
||||||
|
IssueEvent.objects.create(issue=issue, actor=by, kind=EventKind.UNLINKED, feature=feature)
|
||||||
|
|
||||||
|
|
||||||
|
def record_revision(issue, by, revision) -> None:
|
||||||
|
"""Tie a feature revision to the issue it was made for. Called by the feature services."""
|
||||||
|
feature = revision.feature
|
||||||
|
if issue.project_id != feature.project_id:
|
||||||
|
raise ValidationError("The issue must be in the same project as the feature.")
|
||||||
|
issue.revisions.add(revision)
|
||||||
|
issue.features.add(feature)
|
||||||
|
IssueEvent.objects.create(
|
||||||
|
issue=issue, actor=by, kind=EventKind.REVISION, feature=feature, revision=revision
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def promotion_text(thread) -> tuple[str, str]:
|
||||||
|
"""A suggested title and body for an issue made from a thread: the thread, quoted."""
|
||||||
|
comments = [c for c in thread.comments.select_related("author") if not c.is_deleted]
|
||||||
|
first = comments[0].body if comments else ""
|
||||||
|
title = (first.strip().splitlines() or [""])[0][:120]
|
||||||
|
where = thread.feature.key
|
||||||
|
if thread.kind == ThreadKind.LINE:
|
||||||
|
where += f", lines {thread.line_start}–{thread.line_end} of revision {thread.revision}"
|
||||||
|
parts = [f"From a comment thread on {where}."]
|
||||||
|
if thread.kind == ThreadKind.LINE:
|
||||||
|
snapshot = thread.feature.revisions.filter(revision=thread.revision).first()
|
||||||
|
if snapshot:
|
||||||
|
quoted = source_lines(snapshot.description, thread.line_start, thread.line_end)
|
||||||
|
parts.append("\n".join(f"> {line}".rstrip() for line in quoted.splitlines()))
|
||||||
|
for c in comments:
|
||||||
|
name = c.author.username if c.author else "deleted user"
|
||||||
|
quoted = "\n".join(f"> {line}".rstrip() for line in c.body.splitlines())
|
||||||
|
parts.append(f"**{name}** wrote:\n\n{quoted}")
|
||||||
|
return title, "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def promote_thread(thread, by, *, title, body) -> Issue:
|
||||||
|
"""Turn a feature thread into an issue linked to the feature, and resolve the thread."""
|
||||||
|
if not can_promote_thread(by, project_access(by, thread.project), thread):
|
||||||
|
raise PermissionDenied
|
||||||
|
with transaction.atomic():
|
||||||
|
thread = CommentThread.objects.select_for_update().get(pk=thread.pk)
|
||||||
|
if thread.promoted_to_id:
|
||||||
|
raise ValidationError("This thread is already an issue.")
|
||||||
|
issue = create_issue(thread.project, by, title=title, body=body, features=[thread.feature])
|
||||||
|
thread.promoted_to = issue
|
||||||
|
if not thread.resolved_at:
|
||||||
|
thread.resolved_at, thread.resolved_by = timezone.now(), by
|
||||||
|
thread.save(update_fields=["promoted_to", "resolved_at", "resolved_by", "updated_at"])
|
||||||
|
return issue
|
||||||
14
intent/issues/urls.py
Normal file
14
intent/issues/urls.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "issues"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.issue_list, name="list"),
|
||||||
|
path("new/", views.issue_create, name="create"),
|
||||||
|
path("<int:number>/", views.issue_detail, name="detail"),
|
||||||
|
path("<int:number>/edit/", views.issue_edit, name="edit"),
|
||||||
|
path("<int:number>/status/", views.issue_status, name="status"),
|
||||||
|
path("<int:number>/features/", views.issue_features, name="features"),
|
||||||
|
]
|
||||||
210
intent/issues/views.py
Normal file
210
intent/issues/views.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
from intent.access import can_edit_issue, can_withdraw_issue
|
||||||
|
from intent.comments.models import CommentThread, ThreadKind
|
||||||
|
from intent.comments.presenters import prepare_threads, 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 DuplicateForm, IssueForm, LinkFeatureForm
|
||||||
|
from .models import Issue, IssueStatus
|
||||||
|
|
||||||
|
FILTERS = {
|
||||||
|
"open": ("Open", {"status": IssueStatus.OPEN}),
|
||||||
|
"closed": ("Closed", {"status__in": [s for s in IssueStatus.values if s != "open"]}),
|
||||||
|
"all": ("All", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_issue(project, number) -> Issue:
|
||||||
|
return get_object_or_404(
|
||||||
|
Issue.objects.select_related("project__org", "author", "duplicate_of"),
|
||||||
|
project=project,
|
||||||
|
number=number,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_redirect(issue, anchor=""):
|
||||||
|
response = redirect(
|
||||||
|
"issues:detail",
|
||||||
|
org_slug=issue.project.org.slug,
|
||||||
|
project_slug=issue.project.slug,
|
||||||
|
number=issue.number,
|
||||||
|
)
|
||||||
|
if anchor:
|
||||||
|
response["Location"] += f"#{anchor}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _error(request, exc: ValidationError):
|
||||||
|
for message in exc.messages:
|
||||||
|
messages.error(request, message)
|
||||||
|
|
||||||
|
|
||||||
|
def timeline_context(issue, user, access) -> dict:
|
||||||
|
"""Comments and events in time order, for the issue page and its HTMX fragment."""
|
||||||
|
(thread,) = prepare_threads(
|
||||||
|
CommentThread.objects.filter(issue=issue, kind=ThreadKind.ISSUE), user, access
|
||||||
|
)
|
||||||
|
events = issue.events.select_related("actor", "duplicate_of", "feature__project", "revision")
|
||||||
|
for event in events:
|
||||||
|
if event.old_body:
|
||||||
|
event.old_html = render_markdown(event.old_body, heading_base=3)
|
||||||
|
items = [("comment", c) for c in thread.comment_list] + [("event", e) for e in events]
|
||||||
|
items.sort(key=lambda item: (item[1].created_at, item[1].pk))
|
||||||
|
return {"issue": issue, "thread": thread, "timeline": items}
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
def issue_list(request, project, access):
|
||||||
|
current = request.GET.get("status", "open")
|
||||||
|
if current not in FILTERS:
|
||||||
|
current = "open"
|
||||||
|
issues = (
|
||||||
|
project.issues.filter(**FILTERS[current][1])
|
||||||
|
.select_related("author", "project")
|
||||||
|
.prefetch_related("features__project")
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"access": access,
|
||||||
|
"issues": issues,
|
||||||
|
"filters": [(key, label) for key, (label, _) in FILTERS.items()],
|
||||||
|
"current": current,
|
||||||
|
}
|
||||||
|
return render(request, "issues/list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_contribute")
|
||||||
|
def issue_create(request, project, access):
|
||||||
|
initial = {}
|
||||||
|
if feature_key := request.GET.get("feature"):
|
||||||
|
initial["features"] = [get_feature(project, feature_key)]
|
||||||
|
form = IssueForm(request.POST or None, project=project, initial=initial)
|
||||||
|
preview = None
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
data = form.cleaned_data
|
||||||
|
if "preview" in request.POST:
|
||||||
|
preview = render_markdown(data["body"], heading_base=3)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
issue = services.create_issue(project, request.user, **data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
form.add_error(None, exc)
|
||||||
|
else:
|
||||||
|
messages.success(request, f"Opened {issue.key}.")
|
||||||
|
return _issue_redirect(issue)
|
||||||
|
context = {"project": project, "access": access, "form": form, "preview": preview}
|
||||||
|
return render(request, "issues/form.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
def issue_detail(request, project, access, number):
|
||||||
|
issue = get_issue(project, number)
|
||||||
|
user = request.user
|
||||||
|
promoted_from = (
|
||||||
|
CommentThread.objects.filter(promoted_to=issue)
|
||||||
|
.select_related("project__org", "feature__project")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"access": access,
|
||||||
|
"body": render_markdown(issue.body),
|
||||||
|
"features": issue.features.select_related("project"),
|
||||||
|
"promoted_from": promoted_from,
|
||||||
|
"promoted_url": thread_url(promoted_from) if promoted_from else None,
|
||||||
|
"duplicates": issue.duplicates.all(),
|
||||||
|
"can_edit_issue": can_edit_issue(user, access, issue),
|
||||||
|
"can_withdraw": can_withdraw_issue(user, access, issue),
|
||||||
|
"duplicate_form": DuplicateForm(project=project),
|
||||||
|
"link_form": LinkFeatureForm(issue=issue) if access.can_edit else None,
|
||||||
|
**timeline_context(issue, user, access),
|
||||||
|
}
|
||||||
|
return render(request, "issues/detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
def issue_edit(request, project, access, number):
|
||||||
|
issue = get_issue(project, number)
|
||||||
|
if not can_edit_issue(request.user, access, issue):
|
||||||
|
raise PermissionDenied
|
||||||
|
form = IssueForm(
|
||||||
|
request.POST or None,
|
||||||
|
project=project,
|
||||||
|
include_features=False,
|
||||||
|
initial={"title": issue.title, "body": issue.body},
|
||||||
|
)
|
||||||
|
preview = None
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
data = form.cleaned_data
|
||||||
|
if "preview" in request.POST:
|
||||||
|
preview = render_markdown(data["body"], heading_base=3)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
services.update_issue(issue, request.user, **data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
form.add_error(None, exc)
|
||||||
|
else:
|
||||||
|
messages.success(request, f"Saved {issue.key}.")
|
||||||
|
return _issue_redirect(issue)
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"access": access,
|
||||||
|
"issue": issue,
|
||||||
|
"form": form,
|
||||||
|
"preview": preview,
|
||||||
|
}
|
||||||
|
return render(request, "issues/form.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
@require_POST
|
||||||
|
def issue_status(request, project, access, number):
|
||||||
|
issue = get_issue(project, number)
|
||||||
|
status = request.POST.get("status")
|
||||||
|
if status not in IssueStatus.values:
|
||||||
|
messages.error(request, "Choose a status.")
|
||||||
|
return _issue_redirect(issue)
|
||||||
|
duplicate_of = None
|
||||||
|
if status == IssueStatus.DUPLICATE:
|
||||||
|
form = DuplicateForm(request.POST, project=project)
|
||||||
|
if not form.is_valid():
|
||||||
|
for error in form.errors.get("number", []):
|
||||||
|
messages.error(request, error)
|
||||||
|
return _issue_redirect(issue, "decide")
|
||||||
|
duplicate_of = form.cleaned_data["number"]
|
||||||
|
try:
|
||||||
|
services.set_status(issue, request.user, status, duplicate_of=duplicate_of)
|
||||||
|
except ValidationError as exc:
|
||||||
|
_error(request, exc)
|
||||||
|
else:
|
||||||
|
messages.success(request, f"{issue.key} is now {issue.get_status_display().lower()}.")
|
||||||
|
return _issue_redirect(issue)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_edit")
|
||||||
|
@require_POST
|
||||||
|
def issue_features(request, project, access, number):
|
||||||
|
issue = get_issue(project, number)
|
||||||
|
if request.POST.get("action") == "unlink":
|
||||||
|
feature = get_object_or_404(project.features, pk=request.POST.get("feature"))
|
||||||
|
try:
|
||||||
|
services.unlink_feature(issue, request.user, feature)
|
||||||
|
except ValidationError as exc:
|
||||||
|
_error(request, exc)
|
||||||
|
return _issue_redirect(issue, "features")
|
||||||
|
form = LinkFeatureForm(request.POST, issue=issue)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
services.link_feature(issue, request.user, form.cleaned_data["feature"])
|
||||||
|
except ValidationError as exc:
|
||||||
|
_error(request, exc)
|
||||||
|
else:
|
||||||
|
messages.error(request, "Choose a feature to link.")
|
||||||
|
return _issue_redirect(issue, "features")
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
"""Markdown to safe HTML for user-written text (feature descriptions, later issues and comments).
|
"""Markdown to safe HTML for user-written text: feature descriptions, issues and comments.
|
||||||
|
|
||||||
Block elements carry `data-source-lines="start-end"` (1-based, inclusive) so comment threads can
|
Block elements carry `data-source-lines="start-end"` (1-based, inclusive) so comment threads can
|
||||||
anchor to lines of the source. Headings shift down one level so user text never adds a second
|
anchor to lines of the source.
|
||||||
<h1> to the page.
|
|
||||||
|
Headings are re-levelled so the shallowest heading in the text becomes `heading_base` (h2 by
|
||||||
|
default), keeping their relative structure. User text then never adds a second <h1>, and a
|
||||||
|
comment that starts at `##` doesn't skip a level under the page's own headings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import nh3
|
import nh3
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
from markdown_it import MarkdownIt
|
from markdown_it import MarkdownIt
|
||||||
|
|
@ -17,12 +22,14 @@ _ALLOWED_TAGS = nh3.ALLOWED_TAGS - {"img"}
|
||||||
|
|
||||||
|
|
||||||
def _annotate(state):
|
def _annotate(state):
|
||||||
|
levels = [int(t.tag[1]) for t in state.tokens if t.type == "heading_open"]
|
||||||
|
shift = state.env.get("heading_base", 2) - min(levels, default=1)
|
||||||
for token in state.tokens:
|
for token in state.tokens:
|
||||||
if token.nesting == 1 and token.map:
|
if token.nesting == 1 and token.map:
|
||||||
start, end = token.map
|
start, end = token.map
|
||||||
token.attrSet("data-source-lines", f"{start + 1}-{end}")
|
token.attrSet("data-source-lines", f"{start + 1}-{end}")
|
||||||
if token.type in ("heading_open", "heading_close"):
|
if token.type in ("heading_open", "heading_close"):
|
||||||
token.tag = f"h{min(int(token.tag[1]) + 1, 6)}"
|
token.tag = f"h{max(1, min(int(token.tag[1]) + shift, 6))}"
|
||||||
|
|
||||||
|
|
||||||
def _build() -> MarkdownIt:
|
def _build() -> MarkdownIt:
|
||||||
|
|
@ -34,6 +41,51 @@ def _build() -> MarkdownIt:
|
||||||
_md = _build()
|
_md = _build()
|
||||||
|
|
||||||
|
|
||||||
def render(text: str) -> SafeString:
|
def _clean(html: str) -> SafeString:
|
||||||
html = _md.render(text or "")
|
|
||||||
return mark_safe(nh3.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRIBUTES))
|
return mark_safe(nh3.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRIBUTES))
|
||||||
|
|
||||||
|
|
||||||
|
def render(text: str, *, heading_base: int = 2) -> SafeString:
|
||||||
|
return _clean(_md.render(text or "", {"heading_base": heading_base}))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Block:
|
||||||
|
"""One top-level block of rendered Markdown and the source lines it came from."""
|
||||||
|
|
||||||
|
start: int
|
||||||
|
end: int
|
||||||
|
html: SafeString
|
||||||
|
|
||||||
|
@property
|
||||||
|
def anchor(self) -> str:
|
||||||
|
return f"L{self.start}-{self.end}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_blocks(text: str) -> list[Block]:
|
||||||
|
"""Render each top-level block separately, so each can carry its own comment threads."""
|
||||||
|
env = {"heading_base": 2}
|
||||||
|
tokens = _md.parse(text or "", env)
|
||||||
|
lines = (text or "").splitlines()
|
||||||
|
blocks, i = [], 0
|
||||||
|
while i < len(tokens):
|
||||||
|
j, depth = i, 0
|
||||||
|
while True:
|
||||||
|
depth += tokens[j].nesting
|
||||||
|
if depth <= 0:
|
||||||
|
break
|
||||||
|
j += 1
|
||||||
|
if tokens[i].map:
|
||||||
|
start, end = tokens[i].map
|
||||||
|
# Some blocks (loose lists) claim the blank line after them; don't count it.
|
||||||
|
while end > start + 1 and not lines[end - 1].strip():
|
||||||
|
end -= 1
|
||||||
|
html = _md.renderer.render(tokens[i : j + 1], _md.options, env)
|
||||||
|
blocks.append(Block(start + 1, end, _clean(html)))
|
||||||
|
i = j + 1
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def source_lines(text: str, start: int, end: int) -> str:
|
||||||
|
"""Lines start..end (1-based, inclusive) of the source."""
|
||||||
|
return "\n".join((text or "").splitlines()[start - 1 : end])
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,59 @@ img {
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Discussion. Threads and comments are indented by a rule, nothing more. */
|
||||||
|
|
||||||
|
.thread,
|
||||||
|
.timeline > li {
|
||||||
|
margin-block: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread {
|
||||||
|
padding-inline-start: 0.75rem;
|
||||||
|
border-inline-start: 2px solid color-mix(in srgb, CanvasText 30%, Canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
margin-block: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment > header {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.inline {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-comment > summary {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block:target,
|
||||||
|
.comment:target,
|
||||||
|
.thread:target {
|
||||||
|
outline: 2px dashed currentColor;
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.quote {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
padding-inline-start: 0.75rem;
|
||||||
|
border-inline-start: 2px solid;
|
||||||
|
}
|
||||||
|
|
||||||
.skip-link {
|
.skip-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -100vw;
|
left: -100vw;
|
||||||
|
|
|
||||||
1
static/js/htmx.min.js
vendored
Normal file
1
static/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
static/js/intent.js
Normal file
9
static/js/intent.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Keyboard and screen-reader focus after HTMX swaps a thread in place.
|
||||||
|
// The server marks the element to land on (a new or edited comment, or the thread itself)
|
||||||
|
// with data-autofocus; without this, focus would fall back to the top of the page.
|
||||||
|
document.addEventListener("htmx:afterSettle", function () {
|
||||||
|
var target = document.querySelector("[data-autofocus]");
|
||||||
|
if (!target) return;
|
||||||
|
target.removeAttribute("data-autofocus");
|
||||||
|
target.focus();
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
{# Blocking on purpose: applies the saved theme before first paint. #}
|
{# Blocking on purpose: applies the saved theme before first paint. #}
|
||||||
<script src="{% static 'js/theme.js' %}"></script>
|
<script src="{% static 'js/theme.js' %}"></script>
|
||||||
<link rel="stylesheet" href="{% static 'css/intent.css' %}">
|
<link rel="stylesheet" href="{% static 'css/intent.css' %}">
|
||||||
|
{# Enhancements only: every form and link works without these. #}
|
||||||
|
<script src="{% static 'js/htmx.min.js' %}" defer></script>
|
||||||
|
<script src="{% static 'js/intent.js' %}" defer></script>
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
48
templates/comments/_comment.html
Normal file
48
templates/comments/_comment.html
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{% comment %}
|
||||||
|
One comment. Needs `c` (prepared by intent.comments.presenters), `thread`, `project`, `focus`.
|
||||||
|
Forms post normally; with HTMX they swap the whole thread (`thread.dom_id`) in place.
|
||||||
|
{% endcomment %}
|
||||||
|
{% with by=c.author.username|default:"deleted user" comment_id="comment-"|add:c.pk %}
|
||||||
|
<article id="{{ comment_id }}" class="comment" tabindex="-1"{% if focus == comment_id %} data-autofocus{% endif %}>
|
||||||
|
<header>
|
||||||
|
<strong>{{ by }}</strong>
|
||||||
|
<a href="#{{ comment_id }}"><time datetime="{{ c.created_at|date:'c' }}">{{ c.created_at|date:"j M Y, H:i" }}</time></a>
|
||||||
|
{% if c.edited_at and not c.is_deleted %}
|
||||||
|
· <a href="{% url 'comments:history' org_slug=project.org.slug project_slug=project.slug comment_id=c.pk %}">edited<span class="visually-hidden"> (see earlier versions of {{ by }}’s comment)</span></a>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
{% if c.is_deleted %}
|
||||||
|
<p class="muted"><em>Comment deleted.</em></p>
|
||||||
|
{% else %}
|
||||||
|
<div class="prose">{{ c.html }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if c.can_edit or c.can_delete %}
|
||||||
|
<div class="actions">
|
||||||
|
{% if c.can_edit %}
|
||||||
|
{% url 'comments:edit' org_slug=project.org.slug project_slug=project.slug comment_id=c.pk as edit_url %}
|
||||||
|
<details>
|
||||||
|
<summary>Edit<span class="visually-hidden"> {{ by }}’s comment</span></summary>
|
||||||
|
<form method="post" action="{{ edit_url }}" hx-post="{{ edit_url }}" hx-target="#{{ thread.dom_id }}" hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label for="edit-{{ c.pk }}" class="visually-hidden">Comment text</label>
|
||||||
|
<textarea id="edit-{{ c.pk }}" name="body" rows="4" required>{{ c.body }}</textarea>
|
||||||
|
<button type="submit">Save edit</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
{% if c.can_delete %}
|
||||||
|
{% url 'comments:delete' org_slug=project.org.slug project_slug=project.slug comment_id=c.pk as delete_url %}
|
||||||
|
{# A disclosure rather than a script confirm, so the second step works without JS. #}
|
||||||
|
<details>
|
||||||
|
<summary>Delete<span class="visually-hidden"> {{ by }}’s comment</span></summary>
|
||||||
|
<form method="post" action="{{ delete_url }}" hx-post="{{ delete_url }}" hx-target="#{{ thread.dom_id }}" hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p>The text and its edit history will be removed. This can't be undone.</p>
|
||||||
|
<button type="submit">Delete comment</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endwith %}
|
||||||
61
templates/comments/_thread.html
Normal file
61
templates/comments/_thread.html
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{% comment %}
|
||||||
|
A feature or line thread. Needs `thread` (prepared), `project`, `access`, `focus`, `error`.
|
||||||
|
Resolved threads start collapsed. This whole element is what HTMX swaps.
|
||||||
|
{% endcomment %}
|
||||||
|
<div id="{{ thread.dom_id }}" class="thread" tabindex="-1"{% if focus == thread.dom_id %} data-autofocus{% endif %}>
|
||||||
|
{% 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" }},
|
||||||
|
{{ 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>
|
||||||
|
|
||||||
|
{% if thread.kind == "line" and thread.outdated %}
|
||||||
|
<p class="muted">
|
||||||
|
On lines {{ thread.line_start }}–{{ thread.line_end }} of
|
||||||
|
<a href="{% url 'features:revision' org_slug=project.org.slug project_slug=project.slug feature_key=thread.feature.key revision=thread.revision %}#L{{ thread.line_start }}-{{ thread.line_end }}">revision {{ thread.revision }}</a>,
|
||||||
|
which have changed since:
|
||||||
|
</p>
|
||||||
|
<pre class="quote">{{ thread.quoted }}</pre>
|
||||||
|
{% 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 %}
|
||||||
|
|
||||||
|
{% for c in thread.comment_list %}
|
||||||
|
{% include "comments/_comment.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if access.can_contribute %}
|
||||||
|
{% url 'comments:reply' org_slug=project.org.slug project_slug=project.slug thread_id=thread.pk as reply_url %}
|
||||||
|
<form method="post" action="{{ reply_url }}" hx-post="{{ reply_url }}" hx-target="#{{ thread.dom_id }}" hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label for="reply-{{ thread.pk }}">Reply</label>
|
||||||
|
<textarea id="reply-{{ thread.pk }}" name="body" rows="3" required>{{ thread.draft }}</textarea>
|
||||||
|
<button type="submit">Reply</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if thread.can_resolve or thread.can_promote %}
|
||||||
|
<div class="actions">
|
||||||
|
{% if thread.can_resolve %}
|
||||||
|
{% url 'comments:resolve' org_slug=project.org.slug project_slug=project.slug thread_id=thread.pk as resolve_url %}
|
||||||
|
<form method="post" action="{{ resolve_url }}" hx-post="{{ resolve_url }}" hx-target="#{{ thread.dom_id }}" hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if thread.is_resolved %}
|
||||||
|
<input type="hidden" name="resolved" value="0">
|
||||||
|
<button type="submit">Reopen thread</button>
|
||||||
|
{% else %}
|
||||||
|
<input type="hidden" name="resolved" value="1">
|
||||||
|
<button type="submit">Resolve thread</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if thread.can_promote %}
|
||||||
|
<a href="{% url 'comments:promote' org_slug=project.org.slug project_slug=project.slug thread_id=thread.pk %}">Turn into an issue</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
26
templates/comments/history.html
Normal file
26
templates/comments/history.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}Comment history{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<p><a href="{{ back }}">Back to the comment</a></p>
|
||||||
|
<h1>Edits to {{ comment.author.username|default:"deleted user" }}’s comment</h1>
|
||||||
|
|
||||||
|
{% if comment.is_deleted %}
|
||||||
|
<p>This comment was deleted, along with its history.</p>
|
||||||
|
{% else %}
|
||||||
|
<section aria-labelledby="current-heading">
|
||||||
|
<h2 id="current-heading">Current, edited <time datetime="{{ comment.edited_at|date:'c' }}">{{ comment.edited_at|date:"j M Y, H:i" }}</time></h2>
|
||||||
|
<pre class="quote">{{ comment.body }}</pre>
|
||||||
|
</section>
|
||||||
|
{% for version in versions reversed %}
|
||||||
|
<section aria-labelledby="version-{{ version.pk }}">
|
||||||
|
<h2 id="version-{{ version.pk }}">
|
||||||
|
{% if forloop.last %}Original{% else %}Earlier version{% endif %},
|
||||||
|
replaced <time datetime="{{ version.created_at|date:'c' }}">{{ version.created_at|date:"j M Y, H:i" }}</time>
|
||||||
|
</h2>
|
||||||
|
<pre class="quote">{{ version.body }}</pre>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
14
templates/comments/not_posted.html
Normal file
14
templates/comments/not_posted.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}Comment not posted{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<h1>Your comment wasn't posted</h1>
|
||||||
|
<p role="alert"><strong>Error:</strong> {{ error }}</p>
|
||||||
|
{% if draft %}
|
||||||
|
<p>Here's what you wrote, so you can copy it:</p>
|
||||||
|
<label for="draft">Your comment</label>
|
||||||
|
<textarea id="draft" rows="8" readonly>{{ draft }}</textarea>
|
||||||
|
{% endif %}
|
||||||
|
<p><a href="{{ back }}">Go back</a></p>
|
||||||
|
{% endblock %}
|
||||||
14
templates/comments/promote.html
Normal file
14
templates/comments/promote.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}New issue from a thread{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<p><a href="{{ back }}">Back to the thread</a></p>
|
||||||
|
<h1>New issue from a thread on <span class="muted">{{ thread.feature.key }}</span></h1>
|
||||||
|
<p>The issue will be linked to {{ thread.feature.key }} {{ thread.feature.title }}, and the thread will be resolved with a link to it.</p>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
|
<p><button type="submit">Open issue</button></p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -37,9 +37,36 @@
|
||||||
|
|
||||||
<section aria-labelledby="description-heading">
|
<section aria-labelledby="description-heading">
|
||||||
<h2 id="description-heading" class="visually-hidden">Description</h2>
|
<h2 id="description-heading" class="visually-hidden">Description</h2>
|
||||||
<div class="prose">
|
{% if blocks %}
|
||||||
{% if feature.description %}{{ description }}{% else %}<p class="muted">No description yet.</p>{% endif %}
|
{% url 'features:thread_create' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key as thread_create_url %}
|
||||||
</div>
|
<div class="prose">
|
||||||
|
{% for block, threads in blocks %}
|
||||||
|
{# Each block of the description can carry its own threads, like lines in a code review. #}
|
||||||
|
<div class="block" id="{{ block.anchor }}">
|
||||||
|
{{ block.html }}
|
||||||
|
{% for thread in threads %}
|
||||||
|
{% include "comments/_thread.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if access.can_contribute %}
|
||||||
|
<details class="line-comment">
|
||||||
|
<summary>Comment<span class="visually-hidden"> on lines {{ block.start }}–{{ block.end }}</span></summary>
|
||||||
|
<form method="post" action="{{ thread_create_url }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="revision" value="{{ feature.current_revision }}">
|
||||||
|
<input type="hidden" name="line_start" value="{{ block.start }}">
|
||||||
|
<input type="hidden" name="line_end" value="{{ block.end }}">
|
||||||
|
<label for="body-{{ block.anchor }}">Comment on lines {{ block.start }}–{{ block.end }}</label>
|
||||||
|
<textarea id="body-{{ block.anchor }}" name="body" rows="3" required></textarea>
|
||||||
|
<button type="submit">Comment</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No description yet.</p>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if children %}
|
{% if children %}
|
||||||
|
|
@ -66,4 +93,52 @@
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<section id="issues" aria-labelledby="issues-heading">
|
||||||
|
<h2 id="issues-heading">Issues</h2>
|
||||||
|
{% if issues %}
|
||||||
|
<ul>
|
||||||
|
{% for issue in issues %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }} {{ issue.title }}</a>
|
||||||
|
<span class="muted">({{ issue.get_status_display|lower }})</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No issues linked.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if access.can_contribute %}
|
||||||
|
<p><a href="{% url 'issues:create' org_slug=project.org.slug project_slug=project.slug %}?feature={{ feature.key }}">Open an issue about {{ feature.key }}</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="discussion" aria-labelledby="discussion-heading">
|
||||||
|
<h2 id="discussion-heading">Discussion</h2>
|
||||||
|
{% for thread in general_threads %}
|
||||||
|
{% include "comments/_thread.html" %}
|
||||||
|
{% empty %}
|
||||||
|
<p class="muted">No comments yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% if access.can_contribute %}
|
||||||
|
<form method="post" action="{% url 'features:thread_create' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label for="new-thread">Start a thread about {{ feature.key }}</label>
|
||||||
|
<textarea id="new-thread" name="body" rows="4" required></textarea>
|
||||||
|
<p class="muted">Markdown. To comment on part of the description, use the Comment button below that part.</p>
|
||||||
|
<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 join the discussion.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if outdated_threads %}
|
||||||
|
<section aria-labelledby="outdated-heading">
|
||||||
|
<h2 id="outdated-heading">Comments on earlier revisions</h2>
|
||||||
|
{% for thread in outdated_threads %}
|
||||||
|
{% include "comments/_thread.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@
|
||||||
<h1>New feature</h1>
|
<h1>New feature</h1>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if issue %}
|
||||||
|
<p role="note">
|
||||||
|
For issue <a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }} {{ issue.title }}</a>.
|
||||||
|
The new revision will be linked to it.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if preview %}
|
{% if preview %}
|
||||||
<section aria-labelledby="preview-heading">
|
<section aria-labelledby="preview-heading">
|
||||||
<h2 id="preview-heading">Preview</h2>
|
<h2 id="preview-heading">Preview</h2>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<th scope="col">Date</th>
|
<th scope="col">Date</th>
|
||||||
<th scope="col">Author</th>
|
<th scope="col">Author</th>
|
||||||
<th scope="col">Note</th>
|
<th scope="col">Note</th>
|
||||||
|
<th scope="col">Issues</th>
|
||||||
<th scope="col">Changed</th>
|
<th scope="col">Changed</th>
|
||||||
{% if rows|length > 1 %}
|
{% if rows|length > 1 %}
|
||||||
<th scope="col">From</th>
|
<th scope="col">From</th>
|
||||||
|
|
@ -36,6 +37,7 @@
|
||||||
<td><time datetime="{{ rev.created_at|date:'c' }}">{{ rev.created_at|date:"j M Y, H:i" }}</time></td>
|
<td><time datetime="{{ rev.created_at|date:'c' }}">{{ rev.created_at|date:"j M Y, H:i" }}</time></td>
|
||||||
<td>{{ rev.author.username|default:"unknown" }}</td>
|
<td>{{ rev.author.username|default:"unknown" }}</td>
|
||||||
<td>{{ rev.note }}</td>
|
<td>{{ rev.note }}</td>
|
||||||
|
<td>{% for issue in rev.issues.all %}<a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }}</a>{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
|
||||||
<td>{{ row.changes|join:", " }}</td>
|
<td>{{ row.changes|join:", " }}</td>
|
||||||
{% if rows|length > 1 %}
|
{% if rows|length > 1 %}
|
||||||
<td>
|
<td>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,11 @@
|
||||||
<section aria-labelledby="description-heading">
|
<section aria-labelledby="description-heading">
|
||||||
<h2 id="description-heading" class="visually-hidden">Description</h2>
|
<h2 id="description-heading" class="visually-hidden">Description</h2>
|
||||||
<div class="prose">
|
<div class="prose">
|
||||||
{% if snapshot.description %}{{ description }}{% else %}<p class="muted">No description.</p>{% endif %}
|
{% for block in blocks %}
|
||||||
|
<div class="block" id="{{ block.anchor }}">{{ block.html }}</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="muted">No description.</p>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
33
templates/issues/_event.html
Normal file
33
templates/issues/_event.html
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{% comment %}One timeline event. Needs `e`, `project`.{% endcomment %}
|
||||||
|
<p class="event muted">
|
||||||
|
<strong>{{ e.actor.username|default:"deleted user" }}</strong>
|
||||||
|
{% if e.kind == "status" %}
|
||||||
|
{% if e.status == "accepted" %}accepted this issue
|
||||||
|
{% elif e.status == "rejected" %}rejected this issue
|
||||||
|
{% elif e.status == "withdrawn" %}withdrew this issue
|
||||||
|
{% elif e.status == "duplicate" %}marked this a duplicate of
|
||||||
|
{% if e.duplicate_of %}<a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=e.duplicate_of.number %}">{{ e.duplicate_of.key }} {{ e.duplicate_of.title }}</a>{% else %}a deleted issue{% endif %}
|
||||||
|
{% else %}reopened this issue
|
||||||
|
{% endif %}
|
||||||
|
{% elif e.kind == "revision" and e.feature %}
|
||||||
|
{% if e.revision.revision == 1 %}created{% else %}updated{% endif %}
|
||||||
|
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=e.feature.key %}">{{ e.feature.key }} {{ e.feature.title }}</a>
|
||||||
|
{% if e.revision %}in <a href="{% url 'features:revision' org_slug=project.org.slug project_slug=project.slug feature_key=e.feature.key revision=e.revision.revision %}">revision {{ e.revision.revision }}</a>: {{ e.revision.note }}{% endif %}
|
||||||
|
{% elif e.kind == "linked" and e.feature %}
|
||||||
|
linked <a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=e.feature.key %}">{{ e.feature.key }} {{ e.feature.title }}</a>
|
||||||
|
{% elif e.kind == "unlinked" and e.feature %}
|
||||||
|
unlinked <a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=e.feature.key %}">{{ e.feature.key }} {{ e.feature.title }}</a>
|
||||||
|
{% elif e.kind == "edited" %}
|
||||||
|
edited this issue
|
||||||
|
{% else %}
|
||||||
|
{{ e.get_kind_display|lower }}
|
||||||
|
{% endif %}
|
||||||
|
· <time datetime="{{ e.created_at|date:'c' }}">{{ e.created_at|date:"j M Y, H:i" }}</time>
|
||||||
|
</p>
|
||||||
|
{% if e.kind == "edited" %}
|
||||||
|
<details>
|
||||||
|
<summary>Before the edit</summary>
|
||||||
|
<p><strong>{{ e.old_title }}</strong></p>
|
||||||
|
{% if e.old_body %}<div class="prose">{{ e.old_html }}</div>{% else %}<p class="muted">No description.</p>{% endif %}
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
35
templates/issues/_timeline.html
Normal file
35
templates/issues/_timeline.html
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{% comment %}
|
||||||
|
An issue's comments and events, plus the comment form. Needs `issue`, `thread`, `timeline`,
|
||||||
|
`project`, `access`, `focus`, `error`, `draft`. This whole element is what HTMX swaps.
|
||||||
|
{% endcomment %}
|
||||||
|
<div id="timeline" tabindex="-1"{% if focus == "timeline" %} data-autofocus{% endif %}>
|
||||||
|
{% if timeline %}
|
||||||
|
<ol class="timeline">
|
||||||
|
{% for kind, item in timeline %}
|
||||||
|
<li>
|
||||||
|
{% if kind == "comment" %}
|
||||||
|
{% include "comments/_comment.html" with c=item %}
|
||||||
|
{% else %}
|
||||||
|
{% include "issues/_event.html" with e=item %}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No comments yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}<p role="alert"><strong>Error:</strong> {{ error }}</p>{% endif %}
|
||||||
|
{% if access.can_contribute %}
|
||||||
|
{% url 'comments:reply' org_slug=project.org.slug project_slug=project.slug thread_id=thread.pk as reply_url %}
|
||||||
|
<form method="post" action="{{ reply_url }}" hx-post="{{ reply_url }}" hx-target="#timeline" hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label for="new-comment">Add a comment</label>
|
||||||
|
<textarea id="new-comment" name="body" rows="5" required>{{ draft }}</textarea>
|
||||||
|
<p class="muted">Markdown.</p>
|
||||||
|
<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 join the discussion.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
114
templates/issues/detail.html
Normal file
114
templates/issues/detail.html
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}{{ issue.key }} {{ issue.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<h1><span class="muted">{{ issue.key }}</span> {{ issue.title }}</h1>
|
||||||
|
<p>
|
||||||
|
<strong>{{ issue.get_status_display }}</strong>{% if issue.duplicate_of %} duplicate of <a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.duplicate_of.number %}">{{ issue.duplicate_of.key }} {{ issue.duplicate_of.title }}</a>{% endif %}.
|
||||||
|
Opened by {{ issue.author.username|default:"deleted user" }}
|
||||||
|
on <time datetime="{{ issue.created_at|date:'c' }}">{{ issue.created_at|date:"j M Y" }}</time>.
|
||||||
|
{% if promoted_url %}Started as <a href="{{ promoted_url }}">a comment thread on {{ promoted_from.feature.key }}</a>.{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if can_edit_issue %}
|
||||||
|
<p><a href="{% url 'issues:edit' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">Edit issue</a></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section aria-labelledby="body-heading">
|
||||||
|
<h2 id="body-heading" class="visually-hidden">Description</h2>
|
||||||
|
<div class="prose">
|
||||||
|
{% if issue.body %}{{ body }}{% else %}<p class="muted">No description.</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="features" aria-labelledby="features-heading">
|
||||||
|
<h2 id="features-heading">Features</h2>
|
||||||
|
{% if features %}
|
||||||
|
<ul>
|
||||||
|
{% for feature in features %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">{{ feature.key }} {{ feature.title }}</a>
|
||||||
|
{% if access.can_edit %}
|
||||||
|
<form method="post" action="{% url 'issues:features' org_slug=project.org.slug project_slug=project.slug number=issue.number %}" class="inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="unlink">
|
||||||
|
<input type="hidden" name="feature" value="{{ feature.pk }}">
|
||||||
|
<button type="submit">Unlink <span class="visually-hidden">{{ feature.key }}</span></button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Not linked to any feature.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if access.can_edit %}
|
||||||
|
{% if link_form.fields.feature.queryset.exists %}
|
||||||
|
<form method="post" action="{% url 'issues:features' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="link">
|
||||||
|
{{ link_form }}
|
||||||
|
<button type="submit">Link feature</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<p>Change features for this issue. The revision is tied to {{ issue.key }}{% if issue.is_open %}, and you can accept the issue as you save{% endif %}.</p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="{% url 'features:create' org_slug=project.org.slug project_slug=project.slug %}?issue={{ issue.number }}">Create a feature from {{ issue.key }}</a></li>
|
||||||
|
{% for feature in features %}
|
||||||
|
<li><a href="{% url 'features:edit' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}?issue={{ issue.number }}">Update {{ feature.key }} for {{ issue.key }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if duplicates %}
|
||||||
|
<section aria-labelledby="duplicates-heading">
|
||||||
|
<h2 id="duplicates-heading">Duplicates</h2>
|
||||||
|
<ul>
|
||||||
|
{% for dup in duplicates %}
|
||||||
|
<li><a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=dup.number %}">{{ dup.key }} {{ dup.title }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if access.can_edit or can_withdraw %}
|
||||||
|
{% url 'issues:status' org_slug=project.org.slug project_slug=project.slug number=issue.number as status_url %}
|
||||||
|
<section id="decide" aria-labelledby="decide-heading">
|
||||||
|
<h2 id="decide-heading">Decision</h2>
|
||||||
|
{% if issue.is_open %}
|
||||||
|
<form method="post" action="{{ status_url }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if access.can_edit %}
|
||||||
|
<button type="submit" name="status" value="accepted">Accept</button>
|
||||||
|
<button type="submit" name="status" value="rejected">Reject</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_withdraw %}
|
||||||
|
<button type="submit" name="status" value="withdrawn">Withdraw</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% if access.can_edit %}
|
||||||
|
<form method="post" action="{{ status_url }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="status" value="duplicate">
|
||||||
|
{{ duplicate_form }}
|
||||||
|
<button type="submit">Mark as duplicate</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% elif access.can_edit or issue.status == "withdrawn" %}
|
||||||
|
<form method="post" action="{{ status_url }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" name="status" value="open">Reopen</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">This issue is closed.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section aria-labelledby="discussion-heading">
|
||||||
|
<h2 id="discussion-heading">Discussion</h2>
|
||||||
|
{% include "issues/_timeline.html" %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
31
templates/issues/form.html
Normal file
31
templates/issues/form.html
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}{% if issue %}Edit {{ issue.key }}{% else %}New issue{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
{% if issue %}
|
||||||
|
<h1>Edit <span class="muted">{{ issue.key }}</span> {{ issue.title }}</h1>
|
||||||
|
{% else %}
|
||||||
|
<h1>New issue</h1>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if preview %}
|
||||||
|
<section aria-labelledby="preview-heading">
|
||||||
|
<h2 id="preview-heading">Preview</h2>
|
||||||
|
<div class="prose preview">{{ preview }}</div>
|
||||||
|
<p class="muted">Not saved yet.</p>
|
||||||
|
</section>
|
||||||
|
{% elif preview is not None %}
|
||||||
|
<p class="muted">Nothing to preview: the description is empty.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
|
<p>
|
||||||
|
{# Save comes first so pressing Enter in a text field saves rather than previews. #}
|
||||||
|
<button type="submit">{% if issue %}Save issue{% else %}Open issue{% endif %}</button>
|
||||||
|
<button type="submit" name="preview" value="1">Preview description</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
46
templates/issues/list.html
Normal file
46
templates/issues/list.html
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}Issues{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<h1>Issues</h1>
|
||||||
|
{% if access.can_contribute %}
|
||||||
|
<p><a href="{% url 'issues:create' org_slug=project.org.slug project_slug=project.slug %}">New issue</a></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<nav aria-label="Filter issues">
|
||||||
|
<ul>
|
||||||
|
{% for key, label in filters %}
|
||||||
|
<li><a href="?status={{ key }}"{% if key == current %} aria-current="page"{% endif %}>{{ label }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{% if issues %}
|
||||||
|
<table>
|
||||||
|
<caption class="visually-hidden">{% for key, label in filters %}{% if key == current %}{{ label }}{% endif %}{% endfor %} issues</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Issue</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Features</th>
|
||||||
|
<th scope="col">Opened by</th>
|
||||||
|
<th scope="col">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for issue in issues %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }} {{ issue.title }}</a></th>
|
||||||
|
<td>{{ issue.get_status_display }}</td>
|
||||||
|
<td>{% for f in issue.features.all %}{{ f.key }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
|
||||||
|
<td>{{ issue.author.username|default:"deleted user" }}</td>
|
||||||
|
<td><time datetime="{{ issue.updated_at|date:'c' }}">{{ issue.updated_at|date:"j M Y" }}</time></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No {% if current != "all" %}{{ current }} {% endif %}issues.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{% url 'projects:home' org_slug=project.org.slug project_slug=project.slug %}">Feature tree</a></li>
|
<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 '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>
|
||||||
{% if access.can_edit %}
|
{% if access.can_edit %}
|
||||||
<li><a href="{% url 'projects:members' org_slug=project.org.slug project_slug=project.slug %}">Members</a></li>
|
<li><a href="{% url 'projects:members' org_slug=project.org.slug project_slug=project.slug %}">Members</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -64,3 +64,18 @@ def editor(make_user, org, project):
|
||||||
OrgMembership.objects.create(org=org, user=user)
|
OrgMembership.objects.create(org=org, user=user)
|
||||||
ProjectMembership.objects.create(project=project, user=user)
|
ProjectMembership.objects.create(project=project, user=user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def open_project(project):
|
||||||
|
"""The project, made public and open to contributions from any signed-in user."""
|
||||||
|
project.visibility = Visibility.PUBLIC
|
||||||
|
project.open_contribution = True
|
||||||
|
project.save()
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def outsider(make_user):
|
||||||
|
"""A signed-in user with no role in the org."""
|
||||||
|
return make_user("uma")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import pytest
|
import pytest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from intent.comments.services import edit_comment, reply, start_feature_thread
|
||||||
from intent.features.services import create_feature, update_feature
|
from intent.features.services import create_feature, update_feature
|
||||||
|
from intent.issues.services import create_issue, set_status, update_issue
|
||||||
from intent.orgs.services import invite
|
from intent.orgs.services import invite
|
||||||
|
|
||||||
from .a11y import assert_accessible_html
|
from .a11y import assert_accessible_html
|
||||||
|
|
@ -13,6 +15,20 @@ def populated(project, owner, make_user, org):
|
||||||
child = create_feature(project, owner, title="Sign in", parent=parent)
|
child = create_feature(project, owner, title="Sign in", parent=parent)
|
||||||
update_feature(child, owner, note="Detail", description="Steps\n\n1. One\n2. Two\n")
|
update_feature(child, owner, note="Detail", description="Steps\n\n1. One\n2. Two\n")
|
||||||
invite(org, owner, email="new@example.com")
|
invite(org, owner, email="new@example.com")
|
||||||
|
|
||||||
|
# Discussion: a line thread that goes outdated, a current one, a general one, an issue.
|
||||||
|
old = start_feature_thread(child, owner, body="# Old\n\nOn steps", revision=2,
|
||||||
|
line_start=1, line_end=1) # fmt: skip
|
||||||
|
update_feature(child, owner, note="Reword", description="Steps\n\n1. One\n2. Two\n3. Three\n")
|
||||||
|
start_feature_thread(child, owner, body="On the list", revision=3, line_start=3, line_end=5)
|
||||||
|
general = start_feature_thread(child, owner, body="General")
|
||||||
|
reply(general, owner, body="## Reply heading")
|
||||||
|
edit_comment(general.comments.first(), owner, body="General, edited")
|
||||||
|
reply(old, owner, body="Still relevant?")
|
||||||
|
issue = create_issue(project, owner, title="Bug", body="# Heading\n\nText", features=[child])
|
||||||
|
reply(issue.threads.get(), owner, body="### Deep heading")
|
||||||
|
update_issue(issue, owner, title="Bug!", body="## New")
|
||||||
|
set_status(issue, owner, "rejected")
|
||||||
return child
|
return child
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -38,9 +54,26 @@ def _pages(org, project, feature):
|
||||||
reverse("features:history", kwargs=fk),
|
reverse("features:history", kwargs=fk),
|
||||||
reverse("features:revision", kwargs={**fk, "revision": 1}),
|
reverse("features:revision", kwargs={**fk, "revision": 1}),
|
||||||
reverse("features:compare", kwargs=fk),
|
reverse("features:compare", kwargs=fk),
|
||||||
|
reverse("features:create", kwargs=pk) + "?issue=1",
|
||||||
|
reverse("features:edit", kwargs=fk) + "?issue=1",
|
||||||
|
reverse("issues:list", kwargs=pk),
|
||||||
|
reverse("issues:list", kwargs=pk) + "?status=closed",
|
||||||
|
reverse("issues:create", kwargs=pk),
|
||||||
|
reverse("issues:detail", kwargs={**pk, "number": 1}),
|
||||||
|
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)}),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_id(feature):
|
||||||
|
return feature.threads.filter(kind="feature").get().pk
|
||||||
|
|
||||||
|
|
||||||
|
def _edited_comment_id(feature):
|
||||||
|
return feature.threads.filter(kind="feature").get().comments.first().pk
|
||||||
|
|
||||||
|
|
||||||
def test_signed_in_pages(client, owner, org, project, populated):
|
def test_signed_in_pages(client, owner, org, project, populated):
|
||||||
client.force_login(owner)
|
client.force_login(owner)
|
||||||
for path in _pages(org, project, populated):
|
for path in _pages(org, project, populated):
|
||||||
|
|
@ -52,6 +85,29 @@ def test_signed_in_pages(client, owner, org, project, populated):
|
||||||
raise AssertionError(f"{path}: {exc}") from None
|
raise AssertionError(f"{path}: {exc}") from None
|
||||||
|
|
||||||
|
|
||||||
|
def test_outsider_pages_on_open_project(client, outsider, org, project, populated):
|
||||||
|
project.visibility, project.open_contribution = "public", True
|
||||||
|
project.save()
|
||||||
|
client.force_login(outsider)
|
||||||
|
pk = {"org_slug": org.slug, "project_slug": project.slug}
|
||||||
|
for path in [
|
||||||
|
reverse("features:detail", kwargs={**pk, "feature_key": populated.key}),
|
||||||
|
reverse("issues:detail", kwargs={**pk, "number": 1}),
|
||||||
|
]:
|
||||||
|
assert_accessible_html(client.get(path).content.decode())
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_not_posted_page(client, owner, project, populated):
|
||||||
|
client.force_login(owner)
|
||||||
|
url = reverse(
|
||||||
|
"features:thread_create",
|
||||||
|
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": populated.key},
|
||||||
|
)
|
||||||
|
response = client.post(url, {"body": "x", "revision": 1, "line_start": 1, "line_end": 1})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert_accessible_html(response.content.decode())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"html, problem",
|
"html, problem",
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -75,3 +75,8 @@ def test_staff_cannot_grant_staff_without_superuser(client, make_user):
|
||||||
target = make_user("tina")
|
target = make_user("tina")
|
||||||
page = client.get(reverse("admin:accounts_user_change", args=[target.pk])).content.decode()
|
page = client.get(reverse("admin:accounts_user_change", args=[target.pk])).content.decode()
|
||||||
assert 'name="is_superuser"' not in page and 'name="is_staff"' not in page
|
assert 'name="is_superuser"' not in page and 'name="is_staff"' not in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_pages_for_issues_and_threads_render(admin_client, db):
|
||||||
|
for name in ["admin:issues_issue_changelist", "admin:comments_commentthread_changelist"]:
|
||||||
|
assert admin_client.get(reverse(name)).status_code == 200
|
||||||
|
|
|
||||||
261
tests/test_comments.py
Normal file
261
tests/test_comments.py
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
import pytest
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from intent.comments import services
|
||||||
|
from intent.comments.models import Comment, CommentThread, ThreadKind
|
||||||
|
from intent.features.services import create_feature, update_feature
|
||||||
|
from intent.issues.models import IssueStatus
|
||||||
|
from intent.issues.services import create_issue, promote_thread, set_status
|
||||||
|
|
||||||
|
DESCRIPTION = "Intro line\n\nSecond para\nwraps here.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def furl(name, feature, **kwargs):
|
||||||
|
kwargs = {
|
||||||
|
"org_slug": "catbriar",
|
||||||
|
"project_slug": "intent",
|
||||||
|
"feature_key": feature.key,
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
return reverse(f"features:{name}", kwargs=kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def curl(name, **kwargs):
|
||||||
|
return reverse(
|
||||||
|
f"comments:{name}", kwargs={"org_slug": "catbriar", "project_slug": "intent", **kwargs}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def feature(open_project, owner):
|
||||||
|
return create_feature(open_project, owner, title="Sign in", description=DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def thread(feature, outsider):
|
||||||
|
return services.start_feature_thread(feature, outsider, body="Why two paragraphs?")
|
||||||
|
|
||||||
|
|
||||||
|
def test_line_thread_must_match_current_revision_and_lines(feature, outsider, owner):
|
||||||
|
t = services.start_feature_thread(
|
||||||
|
feature, outsider, body="Hm", revision=1, line_start=3, line_end=4
|
||||||
|
)
|
||||||
|
assert t.kind == ThreadKind.LINE
|
||||||
|
with pytest.raises(ValidationError, match="changed"):
|
||||||
|
services.start_feature_thread(
|
||||||
|
feature, outsider, body="x", revision=2, line_start=1, line_end=1
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.start_feature_thread(
|
||||||
|
feature, outsider, body="x", revision=1, line_start=3, line_end=9
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.start_feature_thread(feature, outsider, body=" ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_project_outsiders_cannot_comment(project, owner, make_user):
|
||||||
|
feature = create_feature(project, owner, title="Secret")
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.start_feature_thread(feature, make_user(), body="hi")
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_keeps_history_and_only_author_edits(thread, outsider, owner):
|
||||||
|
comment = thread.comments.get()
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.edit_comment(comment, owner, body="Rewritten")
|
||||||
|
services.edit_comment(comment, outsider, body="Why two?")
|
||||||
|
comment.refresh_from_db()
|
||||||
|
assert comment.edited_at and comment.edits.get().body == "Why two paragraphs?"
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.edit_comment(comment, outsider, body="Why two?")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_leaves_tombstone_and_drops_history(thread, outsider, make_user, owner, editor):
|
||||||
|
comment = thread.comments.get()
|
||||||
|
services.edit_comment(comment, outsider, body="Edited")
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.delete_comment(comment, make_user())
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.delete_comment(comment, editor) # Collaborators don't moderate; owners do.
|
||||||
|
services.delete_comment(comment, owner)
|
||||||
|
comment.refresh_from_db()
|
||||||
|
assert comment.is_deleted and comment.body == "" and not comment.edits.exists()
|
||||||
|
assert comment.deleted_by == owner
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_by_thread_author_or_team(thread, outsider, editor, make_user):
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.set_resolved(thread, make_user(), True)
|
||||||
|
services.set_resolved(thread, outsider, True)
|
||||||
|
assert thread.is_resolved
|
||||||
|
services.set_resolved(thread, editor, False)
|
||||||
|
assert not thread.is_resolved
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_threads_cannot_be_resolved(open_project, outsider, owner):
|
||||||
|
issue = create_issue(open_project, outsider, title="Bug")
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.set_resolved(issue.threads.get(), owner, True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_promote_by_author_or_team_only(thread, feature, outsider, make_user, editor):
|
||||||
|
services.reply(thread, editor, body="Good question")
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
promote_thread(thread, make_user(), title="X", body="")
|
||||||
|
issue = promote_thread(thread, outsider, title="Two paragraphs", body="Quoted")
|
||||||
|
thread.refresh_from_db()
|
||||||
|
assert thread.promoted_to == issue and thread.is_resolved
|
||||||
|
assert list(issue.features.all()) == [feature]
|
||||||
|
assert issue.author == outsider
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
promote_thread(thread, editor, title="Again", body="")
|
||||||
|
|
||||||
|
|
||||||
|
def test_promotion_text_quotes_lines_and_comments(feature, outsider, editor):
|
||||||
|
from intent.issues.services import promotion_text
|
||||||
|
|
||||||
|
t = services.start_feature_thread(
|
||||||
|
feature, outsider, body="Unclear wording", revision=1, line_start=3, line_end=4
|
||||||
|
)
|
||||||
|
services.reply(t, editor, body="Agreed")
|
||||||
|
title, body = promotion_text(t)
|
||||||
|
assert title == "Unclear wording"
|
||||||
|
assert "lines 3–4 of revision 1" in body
|
||||||
|
assert "> Second para\n> wraps here." in body
|
||||||
|
assert "**eddie** wrote:\n\n> Agreed" in body
|
||||||
|
|
||||||
|
|
||||||
|
# Views
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_page_shows_line_threads_under_their_block(client, feature, outsider):
|
||||||
|
services.start_feature_thread(
|
||||||
|
feature, outsider, body="About para two", revision=1, line_start=3, line_end=4
|
||||||
|
)
|
||||||
|
client.force_login(outsider)
|
||||||
|
page = client.get(furl("detail", feature)).content.decode()
|
||||||
|
block = page.index('id="L3-4"')
|
||||||
|
assert block < page.index("About para two") < page.index('id="discussion"')
|
||||||
|
assert 'id="body-L1-1"' in page # A comment form for each block.
|
||||||
|
|
||||||
|
|
||||||
|
def test_line_threads_go_outdated_only_when_text_changes(client, feature, outsider, owner):
|
||||||
|
services.start_feature_thread(
|
||||||
|
feature, outsider, body="About para two", revision=1, line_start=3, line_end=4
|
||||||
|
)
|
||||||
|
update_feature(feature, owner, note="Withdraw", status="withdrawn") # Text unchanged.
|
||||||
|
client.force_login(outsider)
|
||||||
|
page = client.get(furl("detail", feature)).content.decode()
|
||||||
|
assert "Comments on earlier revisions" not in page
|
||||||
|
update_feature(feature, owner, note="Reword", description="Intro\n\nNew text\n")
|
||||||
|
page = client.get(furl("detail", feature)).content.decode()
|
||||||
|
outdated = page.index("Comments on earlier revisions")
|
||||||
|
assert outdated < page.index("About para two")
|
||||||
|
assert "Second para\nwraps here." in page # The original lines are quoted.
|
||||||
|
assert furl("revision", feature, revision=1) + "#L3-4" in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_thread_via_form(client, feature, outsider):
|
||||||
|
client.force_login(outsider)
|
||||||
|
response = client.post(furl("thread_create", feature), {"body": "General point"})
|
||||||
|
thread = CommentThread.objects.get()
|
||||||
|
assert response["Location"] == furl("detail", feature) + f"#thread-{thread.pk}"
|
||||||
|
assert thread.kind == ThreadKind.FEATURE
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_line_comment_shows_the_draft(client, feature, outsider, owner):
|
||||||
|
update_feature(feature, owner, note="Edit", description="Changed\n")
|
||||||
|
client.force_login(outsider)
|
||||||
|
response = client.post(
|
||||||
|
furl("thread_create", feature),
|
||||||
|
{"body": "My long thought", "revision": 1, "line_start": 1, "line_end": 1},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert b"My long thought" in response.content and b"changed" in response.content
|
||||||
|
assert not CommentThread.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_without_js_redirects_to_the_comment(client, thread, outsider):
|
||||||
|
client.force_login(outsider)
|
||||||
|
response = client.post(curl("reply", thread_id=thread.pk), {"body": "More"})
|
||||||
|
comment = Comment.objects.get(body="More")
|
||||||
|
assert response["Location"].endswith(f"#comment-{comment.pk}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_with_htmx_returns_the_thread_fragment(client, thread, outsider):
|
||||||
|
client.force_login(outsider)
|
||||||
|
response = client.post(
|
||||||
|
curl("reply", thread_id=thread.pk), {"body": "More"}, headers={"HX-Request": "true"}
|
||||||
|
)
|
||||||
|
html = response.content.decode()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert html.lstrip().startswith(f'<div id="thread-{thread.pk}"')
|
||||||
|
assert "<html" not in html
|
||||||
|
comment = Comment.objects.get(body="More")
|
||||||
|
assert f'id="comment-{comment.pk}" class="comment" tabindex="-1" data-autofocus' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_htmx_empty_reply_keeps_thread_and_shows_error(client, thread, outsider):
|
||||||
|
client.force_login(outsider)
|
||||||
|
response = client.post(
|
||||||
|
curl("reply", thread_id=thread.pk), {"body": " "}, headers={"HX-Request": "true"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200 and b'role="alert"' in response.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_edit_delete_via_views(client, thread, outsider, owner):
|
||||||
|
comment = thread.comments.get()
|
||||||
|
client.force_login(outsider)
|
||||||
|
client.post(curl("resolve", thread_id=thread.pk), {"resolved": "1"})
|
||||||
|
thread.refresh_from_db()
|
||||||
|
assert thread.is_resolved
|
||||||
|
client.post(curl("edit", comment_id=comment.pk), {"body": "Edited"})
|
||||||
|
history = client.get(curl("history", comment_id=comment.pk)).content.decode()
|
||||||
|
assert "Why two paragraphs?" in history and "Edited" in history
|
||||||
|
client.force_login(owner)
|
||||||
|
client.post(curl("delete", comment_id=comment.pk))
|
||||||
|
comment.refresh_from_db()
|
||||||
|
assert comment.is_deleted
|
||||||
|
|
||||||
|
|
||||||
|
def test_threads_from_another_project_are_404(client, thread, org, owner):
|
||||||
|
from intent.projects.services import create_project
|
||||||
|
|
||||||
|
create_project(
|
||||||
|
org, owner, slug="other", name="Other", feature_prefix="OTH", visibility="public",
|
||||||
|
open_contribution=True,
|
||||||
|
) # fmt: skip
|
||||||
|
client.force_login(owner)
|
||||||
|
other = reverse(
|
||||||
|
"comments:reply",
|
||||||
|
kwargs={"org_slug": "catbriar", "project_slug": "other", "thread_id": thread.pk},
|
||||||
|
)
|
||||||
|
assert client.post(other, {"body": "Sneaky"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_promote_page_prefills_and_creates_issue(client, thread, outsider):
|
||||||
|
client.force_login(outsider)
|
||||||
|
page = client.get(curl("promote", thread_id=thread.pk)).content.decode()
|
||||||
|
assert "Why two paragraphs?" in page
|
||||||
|
response = client.post(
|
||||||
|
curl("promote", thread_id=thread.pk), {"title": "Two paragraphs", "body": "See thread"}
|
||||||
|
)
|
||||||
|
thread.refresh_from_db()
|
||||||
|
issue = thread.promoted_to
|
||||||
|
assert response["Location"].endswith(f"/issues/{issue.number}/")
|
||||||
|
issue_page = client.get(response["Location"]).content.decode()
|
||||||
|
assert f"#thread-{thread.pk}" in issue_page # Links back to where it started.
|
||||||
|
|
||||||
|
|
||||||
|
def test_someone_elses_thread_cannot_be_promoted_by_outsider(client, thread, make_user):
|
||||||
|
client.force_login(make_user())
|
||||||
|
assert client.get(curl("promote", thread_id=thread.pk)).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_closed_issue_still_accepts_comments(client, open_project, outsider, editor):
|
||||||
|
issue = create_issue(open_project, outsider, title="Bug")
|
||||||
|
set_status(issue, editor, IssueStatus.REJECTED)
|
||||||
|
client.force_login(outsider)
|
||||||
|
client.post(curl("reply", thread_id=issue.threads.get().pk), {"body": "Why rejected?"})
|
||||||
|
assert Comment.objects.filter(body="Why rejected?").exists()
|
||||||
136
tests/test_example_flow.py
Normal file
136
tests/test_example_flow.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""The example flow from docs/concept.md, steps 1–7, driven through the views like a browser."""
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from intent.comments.models import CommentThread
|
||||||
|
from intent.features.models import Feature
|
||||||
|
from intent.issues.models import Issue, IssueStatus
|
||||||
|
|
||||||
|
P = {"org_slug": "catbriar", "project_slug": "intent"}
|
||||||
|
|
||||||
|
|
||||||
|
def issue_url(name, number, **kwargs):
|
||||||
|
return reverse(f"issues:{name}", kwargs={**P, "number": number, **kwargs})
|
||||||
|
|
||||||
|
|
||||||
|
def feature_url(name, key, **kwargs):
|
||||||
|
return reverse(f"features:{name}", kwargs={**P, "feature_key": key, **kwargs})
|
||||||
|
|
||||||
|
|
||||||
|
def reply_url(thread):
|
||||||
|
return reverse("comments:reply", kwargs={**P, "thread_id": thread.pk})
|
||||||
|
|
||||||
|
|
||||||
|
def test_example_flow(client, open_project, outsider, editor):
|
||||||
|
# 1. A general user creates a new issue in a project.
|
||||||
|
client.force_login(outsider)
|
||||||
|
client.post(
|
||||||
|
reverse("issues:create", kwargs=P),
|
||||||
|
{"title": "Remember me on shared computers", "body": "Sessions last too long."},
|
||||||
|
)
|
||||||
|
issue = Issue.objects.get()
|
||||||
|
|
||||||
|
# 2. Users, including team members, discuss in comments on the issue.
|
||||||
|
issue_thread = issue.threads.get()
|
||||||
|
client.post(reply_url(issue_thread), {"body": "Public library computers especially."})
|
||||||
|
client.force_login(editor)
|
||||||
|
client.post(reply_url(issue_thread), {"body": "Agreed, let's define this."})
|
||||||
|
|
||||||
|
# 3. A team member accepts the issue and creates a feature from it, linked to the issue.
|
||||||
|
create = reverse("features:create", kwargs=P) + f"?issue={issue.number}"
|
||||||
|
client.post(
|
||||||
|
create,
|
||||||
|
{
|
||||||
|
"title": "Session length",
|
||||||
|
"description": "Sessions end after 12 hours.\n\nShared computers end at close.\n",
|
||||||
|
"note": "",
|
||||||
|
"accept_issue": "on",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
issue.refresh_from_db()
|
||||||
|
feature = Feature.objects.get()
|
||||||
|
assert issue.status == IssueStatus.ACCEPTED
|
||||||
|
assert list(issue.features.all()) == [feature]
|
||||||
|
assert feature.revisions.get().issues.get() == issue
|
||||||
|
|
||||||
|
# 4. The team member updates the feature's status manually.
|
||||||
|
client.post(
|
||||||
|
feature_url("edit", feature.key),
|
||||||
|
{
|
||||||
|
"title": feature.title,
|
||||||
|
"description": feature.description,
|
||||||
|
"status": "withdrawn",
|
||||||
|
"note": "Paused until the login rework.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
feature.refresh_from_db()
|
||||||
|
assert (feature.status, feature.current_revision) == ("withdrawn", 2)
|
||||||
|
client.post(
|
||||||
|
feature_url("edit", feature.key),
|
||||||
|
{
|
||||||
|
"title": feature.title,
|
||||||
|
"description": feature.description,
|
||||||
|
"status": "active",
|
||||||
|
"note": "Back on.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Later, a user starts a comment thread on lines of the feature.
|
||||||
|
client.force_login(outsider)
|
||||||
|
client.post(
|
||||||
|
feature_url("thread_create", feature.key),
|
||||||
|
{"body": "What counts as close?", "revision": 3, "line_start": 3, "line_end": 3},
|
||||||
|
)
|
||||||
|
thread = CommentThread.objects.get(feature=feature)
|
||||||
|
|
||||||
|
# 6. After some discussion, the user promotes the thread to an issue.
|
||||||
|
client.force_login(editor)
|
||||||
|
client.post(reply_url(thread), {"body": "Browser close, I think."})
|
||||||
|
client.force_login(outsider)
|
||||||
|
promote = reverse("comments:promote", kwargs={**P, "thread_id": thread.pk})
|
||||||
|
client.post(promote, {"title": "Define 'close'", "body": "From the thread."})
|
||||||
|
follow_up = Issue.objects.get(number=2)
|
||||||
|
thread.refresh_from_db()
|
||||||
|
assert thread.promoted_to == follow_up and thread.is_resolved
|
||||||
|
assert list(follow_up.features.all()) == [feature]
|
||||||
|
|
||||||
|
# 7. More discussion, then a team member accepts the issue and updates the feature.
|
||||||
|
client.post(reply_url(follow_up.threads.get()), {"body": "Tab close too?"})
|
||||||
|
client.force_login(editor)
|
||||||
|
client.post(
|
||||||
|
feature_url("edit", feature.key) + f"?issue={follow_up.number}",
|
||||||
|
{
|
||||||
|
"title": feature.title,
|
||||||
|
"description": (
|
||||||
|
"Sessions end after 12 hours.\n\nShared computers end when the browser closes.\n"
|
||||||
|
),
|
||||||
|
"status": "active",
|
||||||
|
"note": "Define close as browser close.",
|
||||||
|
"accept_issue": "on",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
follow_up.refresh_from_db()
|
||||||
|
feature.refresh_from_db()
|
||||||
|
assert follow_up.status == IssueStatus.ACCEPTED
|
||||||
|
assert feature.current_revision == 4
|
||||||
|
assert feature.revisions.get(revision=4).issues.get() == follow_up
|
||||||
|
|
||||||
|
# The line thread is now outdated, and the feature page shows both issues.
|
||||||
|
page = client.get(feature_url("detail", feature.key)).content.decode()
|
||||||
|
assert "Comments on earlier revisions" in page
|
||||||
|
assert "#1 Remember me on shared computers" in page and "#2 Define" in page
|
||||||
|
|
||||||
|
# 7, alternative: a third issue is rejected but stays linked, so the proposal is visible.
|
||||||
|
client.force_login(outsider)
|
||||||
|
client.post(
|
||||||
|
reverse("issues:create", kwargs=P),
|
||||||
|
{"title": "Never expire sessions", "features": [feature.pk]},
|
||||||
|
)
|
||||||
|
client.force_login(editor)
|
||||||
|
client.post(issue_url("status", 3), {"status": "rejected"})
|
||||||
|
rejected = Issue.objects.get(number=3)
|
||||||
|
assert rejected.status == IssueStatus.REJECTED
|
||||||
|
assert (
|
||||||
|
"#3 Never expire sessions"
|
||||||
|
in client.get(feature_url("detail", feature.key)).content.decode()
|
||||||
|
)
|
||||||
252
tests/test_issues.py
Normal file
252
tests/test_issues.py
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import pytest
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from django.db import IntegrityError
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from intent.comments.models import CommentThread, ThreadKind
|
||||||
|
from intent.features.services import create_feature, update_feature
|
||||||
|
from intent.issues import services
|
||||||
|
from intent.issues.models import EventKind, Issue, IssueStatus
|
||||||
|
|
||||||
|
|
||||||
|
def iurl(name, project, **kwargs):
|
||||||
|
kwargs = {"org_slug": project.org.slug, "project_slug": project.slug, **kwargs}
|
||||||
|
return reverse(f"issues:{name}", kwargs=kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def issue(open_project, outsider):
|
||||||
|
return services.create_issue(open_project, outsider, title="Sign in is slow", body="Why?")
|
||||||
|
|
||||||
|
|
||||||
|
def test_numbers_are_per_project_and_issue_gets_a_thread(open_project, outsider, issue):
|
||||||
|
second = services.create_issue(open_project, outsider, title="Another")
|
||||||
|
assert (issue.key, second.key) == ("#1", "#2")
|
||||||
|
assert CommentThread.objects.get(issue=issue).kind == ThreadKind.ISSUE
|
||||||
|
|
||||||
|
|
||||||
|
def test_outsiders_need_open_contribution(project, outsider):
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.create_issue(project, outsider, title="Nope")
|
||||||
|
project.visibility = "public"
|
||||||
|
project.save()
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.create_issue(project, outsider, title="Still no")
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_required(open_project, outsider):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.create_issue(open_project, outsider, title=" ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_team_decides(issue, outsider, editor):
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.set_status(issue, outsider, IssueStatus.ACCEPTED)
|
||||||
|
services.set_status(issue, editor, IssueStatus.REJECTED)
|
||||||
|
issue.refresh_from_db()
|
||||||
|
assert issue.status == IssueStatus.REJECTED and issue.closed_at
|
||||||
|
services.set_status(issue, editor, IssueStatus.OPEN)
|
||||||
|
issue.refresh_from_db()
|
||||||
|
assert issue.is_open and issue.closed_at is None
|
||||||
|
assert [e.status for e in issue.events.all()] == ["rejected", "open"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_withdraws_and_reopens_own_issue(issue, outsider, make_user):
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.set_status(issue, make_user(), IssueStatus.WITHDRAWN)
|
||||||
|
services.set_status(issue, outsider, IssueStatus.WITHDRAWN)
|
||||||
|
services.set_status(issue, outsider, IssueStatus.OPEN)
|
||||||
|
services.set_status(issue, outsider, IssueStatus.WITHDRAWN)
|
||||||
|
issue.refresh_from_db()
|
||||||
|
assert issue.status == IssueStatus.WITHDRAWN
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_cannot_reopen_a_rejected_issue(issue, outsider, editor):
|
||||||
|
services.set_status(issue, editor, IssueStatus.REJECTED)
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.set_status(issue, outsider, IssueStatus.OPEN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_status_is_refused(issue, editor):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.set_status(issue, editor, IssueStatus.OPEN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicates_need_an_original_and_no_cycles(open_project, outsider, editor, issue):
|
||||||
|
other = services.create_issue(open_project, outsider, title="Slow sign in")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.set_status(other, editor, IssueStatus.DUPLICATE)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.set_status(other, editor, IssueStatus.DUPLICATE, duplicate_of=other)
|
||||||
|
services.set_status(other, editor, IssueStatus.DUPLICATE, duplicate_of=issue)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.set_status(issue, editor, IssueStatus.DUPLICATE, duplicate_of=other)
|
||||||
|
# Reopening clears the link.
|
||||||
|
services.set_status(other, editor, IssueStatus.OPEN)
|
||||||
|
other.refresh_from_db()
|
||||||
|
assert other.duplicate_of is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_enforces_duplicate_of(issue):
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
Issue.objects.filter(pk=issue.pk).update(status=IssueStatus.DUPLICATE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_keeps_old_text_in_timeline(issue, outsider, make_user):
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.update_issue(issue, make_user(), title="Hijack", body="")
|
||||||
|
services.update_issue(issue, outsider, title="Sign in is very slow", body="Why?")
|
||||||
|
event = issue.events.get(kind=EventKind.EDITED)
|
||||||
|
assert event.old_title == "Sign in is slow"
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.update_issue(issue, outsider, title="Sign in is very slow", body="Why?")
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_and_unlink_features(issue, editor, outsider, open_project, owner):
|
||||||
|
feature = create_feature(open_project, owner, title="Sign in")
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.link_feature(issue, outsider, feature)
|
||||||
|
services.link_feature(issue, editor, feature)
|
||||||
|
assert list(feature.issues.all()) == [issue]
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.link_feature(issue, editor, feature)
|
||||||
|
services.unlink_feature(issue, editor, feature)
|
||||||
|
assert not feature.issues.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_revision_can_accept_an_issue(issue, editor, open_project, owner):
|
||||||
|
feature = create_feature(open_project, owner, title="Sign in")
|
||||||
|
revision = update_feature(
|
||||||
|
feature, editor, note="Cache sessions", title="Fast sign in", issues=[issue],
|
||||||
|
accept_issues=True,
|
||||||
|
) # fmt: skip
|
||||||
|
issue.refresh_from_db()
|
||||||
|
assert issue.status == IssueStatus.ACCEPTED
|
||||||
|
assert list(revision.issues.all()) == [issue]
|
||||||
|
assert list(issue.features.all()) == [feature]
|
||||||
|
assert issue.events.filter(kind=EventKind.REVISION, revision=revision).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_issue_cannot_be_tied_to_a_feature(issue, org, owner):
|
||||||
|
from intent.projects.services import create_project
|
||||||
|
|
||||||
|
other = create_project(
|
||||||
|
org, owner, slug="other", name="Other", feature_prefix="OTH", visibility="private",
|
||||||
|
open_contribution=False,
|
||||||
|
) # fmt: skip
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
create_feature(other, owner, title="X", issues=[issue])
|
||||||
|
|
||||||
|
|
||||||
|
# Views
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_pages_for_outsider_on_open_project(client, open_project, outsider, issue):
|
||||||
|
client.force_login(outsider)
|
||||||
|
assert client.get(iurl("list", open_project)).status_code == 200
|
||||||
|
assert client.get(iurl("create", open_project)).status_code == 200
|
||||||
|
page = client.get(iurl("detail", open_project, number=1)).content.decode()
|
||||||
|
assert "Sign in is slow" in page
|
||||||
|
assert 'value="accepted"' not in page # No decision buttons for outsiders.
|
||||||
|
assert 'value="withdrawn"' in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_issues_are_hidden(client, project, owner, outsider):
|
||||||
|
issue = services.create_issue(project, owner, title="Secret")
|
||||||
|
client.force_login(outsider)
|
||||||
|
assert client.get(iurl("detail", project, number=issue.number)).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_via_form_with_feature_prefill(client, open_project, outsider, owner):
|
||||||
|
feature = create_feature(open_project, owner, title="Sign in")
|
||||||
|
client.force_login(outsider)
|
||||||
|
page = client.get(iurl("create", open_project) + "?feature=INT-1").content.decode()
|
||||||
|
assert f'value="{feature.pk}" selected' in page
|
||||||
|
response = client.post(
|
||||||
|
iurl("create", open_project),
|
||||||
|
{"title": "Faster", "body": "Please", "features": [feature.pk]},
|
||||||
|
)
|
||||||
|
issue = Issue.objects.get(title="Faster")
|
||||||
|
assert response["Location"] == iurl("detail", open_project, number=issue.number)
|
||||||
|
assert list(issue.features.all()) == [feature]
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_buttons(client, open_project, editor, issue):
|
||||||
|
client.force_login(editor)
|
||||||
|
client.post(iurl("status", open_project, number=1), {"status": "accepted"})
|
||||||
|
issue.refresh_from_db()
|
||||||
|
assert issue.status == IssueStatus.ACCEPTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_by_number(client, open_project, editor, outsider, issue):
|
||||||
|
other = services.create_issue(open_project, outsider, title="Again")
|
||||||
|
client.force_login(editor)
|
||||||
|
client.post(iurl("status", open_project, number=2), {"status": "duplicate", "number": 99})
|
||||||
|
other.refresh_from_db()
|
||||||
|
assert other.is_open
|
||||||
|
client.post(iurl("status", open_project, number=2), {"status": "duplicate", "number": 1})
|
||||||
|
other.refresh_from_db()
|
||||||
|
assert other.duplicate_of == issue
|
||||||
|
|
||||||
|
|
||||||
|
def test_outsider_cannot_post_status_or_links(client, open_project, outsider, issue, owner):
|
||||||
|
feature = create_feature(open_project, owner, title="Sign in")
|
||||||
|
client.force_login(outsider)
|
||||||
|
assert (
|
||||||
|
client.post(iurl("status", open_project, number=1), {"status": "accepted"}).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
iurl("features", open_project, number=1), {"action": "link", "feature": feature.pk}
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_and_create_feature_from_issue(client, open_project, editor, issue):
|
||||||
|
client.force_login(editor)
|
||||||
|
create = reverse("features:create", kwargs={"org_slug": "catbriar", "project_slug": "intent"})
|
||||||
|
page = client.get(f"{create}?issue=1").content.decode()
|
||||||
|
assert 'value="Sign in is slow"' in page and "Mark issue #1 accepted" in page
|
||||||
|
client.post(
|
||||||
|
f"{create}?issue=1",
|
||||||
|
{"title": "Fast sign in", "description": "Why?", "note": "", "accept_issue": "on"},
|
||||||
|
)
|
||||||
|
issue.refresh_from_db()
|
||||||
|
feature = issue.features.get()
|
||||||
|
assert feature.title == "Fast sign in"
|
||||||
|
assert issue.status == IssueStatus.ACCEPTED
|
||||||
|
assert feature.revisions.get().issues.get() == issue
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_feature_for_issue_without_accepting(client, open_project, editor, issue, owner):
|
||||||
|
feature = create_feature(open_project, owner, title="Sign in")
|
||||||
|
client.force_login(editor)
|
||||||
|
edit = reverse(
|
||||||
|
"features:edit",
|
||||||
|
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": "INT-1"},
|
||||||
|
)
|
||||||
|
page = client.get(f"{edit}?issue=1").content.decode()
|
||||||
|
assert "#1: Sign in is slow" in page
|
||||||
|
client.post(
|
||||||
|
f"{edit}?issue=1",
|
||||||
|
{"title": "Sign in", "description": "Faster.", "status": "active", "note": "Speed"},
|
||||||
|
)
|
||||||
|
issue.refresh_from_db()
|
||||||
|
assert issue.is_open and list(issue.features.all()) == [feature]
|
||||||
|
history = reverse(
|
||||||
|
"features:history",
|
||||||
|
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": "INT-1"},
|
||||||
|
)
|
||||||
|
assert iurl("detail", open_project, number=1) in client.get(history).content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_filters(client, open_project, editor, outsider, issue):
|
||||||
|
services.create_issue(open_project, outsider, title="Closed one")
|
||||||
|
services.set_status(Issue.objects.get(number=2), editor, IssueStatus.REJECTED)
|
||||||
|
client.force_login(outsider)
|
||||||
|
open_page = client.get(iurl("list", open_project)).content.decode()
|
||||||
|
closed_page = client.get(iurl("list", open_project) + "?status=closed").content.decode()
|
||||||
|
assert "Sign in is slow" in open_page and "Closed one" not in open_page
|
||||||
|
assert "Closed one" in closed_page and "Sign in is slow" not in closed_page
|
||||||
|
|
@ -39,3 +39,21 @@ def test_line_diff_hunks():
|
||||||
assert ("added", None, 10) in kinds
|
assert ("added", None, 10) in kinds
|
||||||
assert kinds[0] == ("same", 7, 7) # three lines of context
|
assert kinds[0] == ("same", 7, 7) # three lines of context
|
||||||
assert line_diff(old, old) == []
|
assert line_diff(old, old) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocks_split_top_level_markdown_with_line_ranges():
|
||||||
|
from intent.markdown import render_blocks, source_lines
|
||||||
|
|
||||||
|
text = "# Title\n\nPara one\ncontinues.\n\n- a\n- b\n\n```\ncode\n```\n"
|
||||||
|
blocks = render_blocks(text)
|
||||||
|
assert [(b.start, b.end) for b in blocks] == [(1, 1), (3, 4), (6, 7), (9, 11)]
|
||||||
|
assert blocks[0].anchor == "L1-1" and "<h2" in blocks[0].html
|
||||||
|
assert "<li" in blocks[2].html and "<li" not in blocks[1].html
|
||||||
|
assert source_lines(text, 3, 4) == "Para one\ncontinues."
|
||||||
|
|
||||||
|
|
||||||
|
def test_heading_base_is_adjustable():
|
||||||
|
assert "<h3" in render("# Deep", heading_base=3)
|
||||||
|
# The shallowest heading lands on the base level, keeping relative structure.
|
||||||
|
html = render("## Two\n\n### Three\n")
|
||||||
|
assert "<h2" in html and "<h3" in html and "<h4" not in html
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue