Add projects and features with revision history (Phase 2)

Projects under organizations with fixed URL name and feature ID
prefix, three visibility levels, open contribution, per-project
roles, settings and typed-confirmation delete. Hidden projects 404.
Leaving an org drops its project roles. Project access rules live in
intent/access.py alongside org access.

Features numbered per project (INT-12) in a tree. Every change is a
revision with a required note; history, old revisions, and
side-by-side field and line diffs between any two. Cycles and
superseded-without-replacement are rejected. Markdown renders safely
with source-line attributes for Phase 3 line comments and headings
shifted below the page h1. Server-side preview works without JS.

Structural accessibility check runs on every page in tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alec K2XAP 2026-10-27 07:58:16 -04:00
parent 1af217f6fb
commit b9378b8397
55 changed files with 2446 additions and 20 deletions

View file

@ -27,6 +27,8 @@ INSTALLED_APPS = [
"intent.core", "intent.core",
"intent.accounts", "intent.accounts",
"intent.orgs", "intent.orgs",
"intent.projects",
"intent.features",
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View file

@ -3,12 +3,16 @@ from django.urls import include, path
from intent.core import views from intent.core import views
from intent.orgs.urls import invite_urlpatterns from intent.orgs.urls import invite_urlpatterns
from intent.projects.views import project_create
urlpatterns = [ urlpatterns = [
path("", views.home, name="home"), path("", views.home, name="home"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
path("invites/", include(invite_urlpatterns)), path("invites/", include(invite_urlpatterns)),
# Last: organizations own the rest of the URL space (see RESERVED_SLUGS). # Organizations own the rest of the URL space (see RESERVED_SLUGS in orgs and projects).
path("<slug:org_slug>/new/", project_create, name="project_create"),
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>/features/", include("intent.features.urls")),
] ]

View file

@ -35,7 +35,11 @@ Decisions made in the concept are not repeated. These fill the gaps.
| 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 whoever holds it may accept with any account. Email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. 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 whoever holds it may accept with any account. Email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
| URLs | Organizations live at the root (`/<org>/`, later `/<org>/<project>/`); app routes are reserved slugs. | | URLs | Organizations live at the root: `/<org>/`, `/<org>/<project>/`, `/<org>/<project>/features/INT-12/`. App routes are reserved slugs at both levels. |
| Project slug and prefix | Fixed after creation, since they appear in URLs and feature IDs. Name, visibility and open contribution stay editable. |
| Hidden projects | A project the user can't see returns 404 (not 403), so private projects don't reveal they exist. Signed-out visitors are sent to sign in instead. |
| Project owners | No last-owner guard: org owners can always administer every project. Leaving the org removes a user's project roles in it. |
| Feature editing | One form edits title, description, parent, status and replacement, with a required change note. Saving with no changes is refused. A "Preview description" button renders the Markdown server-side, so preview works without JS. Django admin shows features read-only so every change goes through revisions. |
### Stack ### Stack
@ -65,6 +69,8 @@ The app should look close to unstyled HTML. Guiding rule: if the stylesheet fail
- Every HTMX interaction has a non-JS fallback: forms post normally, links navigate. HTMX enhances, never gates. - Every HTMX interaction has a non-JS fallback: forms post normally, links navigate. HTMX enhances, never gates.
- 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`.
- Markdown headings render one level down (`#` becomes `<h2>`) so user text never adds a second `<h1>`.
## 3. Data model ## 3. Data model

View file

@ -1,9 +1,13 @@
"""What a user may do. Every permission check in views and templates goes through here.""" """What a user may do. Every permission check in views and templates goes through here."""
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING
from intent.orgs.models import Organization, OrgMembership, Role from intent.orgs.models import Organization, OrgMembership, Role
if TYPE_CHECKING:
from intent.projects.models import Project, ProjectMembership
@dataclass(frozen=True) @dataclass(frozen=True)
class OrgAccess: class OrgAccess:
@ -30,8 +34,75 @@ class OrgAccess:
return self.is_owner or (self.is_member and self.membership.can_create_projects) return self.is_owner or (self.is_member and self.membership.can_create_projects)
def _is_regular_user(user) -> bool:
# Sysadmins (staff) cannot act within orgs or projects; they get anonymous access.
return user.is_authenticated and not user.is_staff
def org_access(user, org: Organization) -> OrgAccess: def org_access(user, org: Organization) -> OrgAccess:
if not user.is_authenticated or user.is_staff: if not _is_regular_user(user):
return OrgAccess(membership=None) return OrgAccess(membership=None)
membership = OrgMembership.objects.filter(org=org, user=user).first() membership = OrgMembership.objects.filter(org=org, user=user).first()
return OrgAccess(membership=membership) return OrgAccess(membership=membership)
@dataclass(frozen=True)
class ProjectAccess:
membership: "ProjectMembership | None"
is_org_owner: bool
can_view: bool
# Comment and open issues.
can_contribute: bool
# Create and edit features, decide issues, manage implementations.
can_edit: bool
# Settings, project members, deletion.
can_admin: bool
def project_access(user, project: "Project") -> ProjectAccess:
from intent.projects.models import ProjectMembership, Visibility
signed_in = _is_regular_user(user)
membership = None
is_org_owner = False
if signed_in:
membership = ProjectMembership.objects.filter(project=project, user=user).first()
is_org_owner = OrgMembership.objects.filter(
org_id=project.org_id, user=user, role=Role.OWNER
).exists()
can_admin = is_org_owner or (membership is not None and membership.role == Role.OWNER)
can_edit = can_admin or membership is not None
can_view = (
can_edit
or {
Visibility.PUBLIC: True,
Visibility.AUTHENTICATED: signed_in,
Visibility.PRIVATE: False,
}[project.visibility]
)
can_contribute = can_edit or (signed_in and can_view and project.open_contribution)
return ProjectAccess(
membership=membership,
is_org_owner=is_org_owner,
can_view=can_view,
can_contribute=can_contribute,
can_edit=can_edit,
can_admin=can_admin,
)
def visible_projects(user, org: Organization):
"""Projects in an org the user may see, for listings."""
from django.db.models import Q
from intent.projects.models import Project, Visibility
projects = Project.objects.filter(org=org)
if not _is_regular_user(user):
return projects.filter(visibility=Visibility.PUBLIC)
if OrgMembership.objects.filter(org=org, user=user, role=Role.OWNER).exists():
return projects
return projects.filter(
Q(visibility__in=[Visibility.PUBLIC, Visibility.AUTHENTICATED]) | Q(memberships__user=user)
).distinct()

View file

29
intent/features/admin.py Normal file
View file

@ -0,0 +1,29 @@
from django.contrib import admin
from .models import Feature, FeatureRevision
@admin.register(Feature)
class FeatureAdmin(admin.ModelAdmin):
"""Read-only: edits must go through the app so they're recorded as revisions."""
list_display = ["__str__", "project", "status", "current_revision"]
list_filter = ["status"]
search_fields = ["title"]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@admin.register(FeatureRevision)
class FeatureRevisionAdmin(admin.ModelAdmin):
list_display = ["__str__", "author", "created_at", "note"]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False

6
intent/features/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class FeaturesConfig(AppConfig):
name = "intent.features"
label = "features"

29
intent/features/diff.py Normal file
View file

@ -0,0 +1,29 @@
from dataclasses import dataclass
from difflib import SequenceMatcher
@dataclass(frozen=True)
class DiffLine:
kind: str # "same", "added" or "removed"
old_number: int | None
new_number: int | None
text: str
def line_diff(old: str, new: str, context: int = 3) -> list[list[DiffLine]]:
"""Line-by-line diff as hunks, each with up to `context` unchanged lines around changes."""
a, b = old.splitlines(), new.splitlines()
hunks = []
for group in SequenceMatcher(None, a, b, autojunk=False).get_grouped_opcodes(context):
hunk = []
for tag, i1, i2, j1, j2 in group:
if tag == "equal":
hunk += [
DiffLine("same", i + 1, j + 1, a[i])
for i, j in zip(range(i1, i2), range(j1, j2), strict=True)
]
continue
hunk += [DiffLine("removed", i + 1, None, a[i]) for i in range(i1, i2)]
hunk += [DiffLine("added", None, j + 1, b[j]) for j in range(j1, j2)]
hunks.append(hunk)
return hunks

57
intent/features/forms.py Normal file
View file

@ -0,0 +1,57 @@
from django import forms
from .models import Feature, FeatureStatus
class FeatureChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
return f"{obj.key} {obj.title}"
class FeatureForm(forms.Form):
title = forms.CharField(max_length=200)
description = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 20, "cols": 80}),
help_text="Markdown.",
)
parent = FeatureChoiceField(
queryset=Feature.objects.none(), required=False, empty_label="None (top level)"
)
status = forms.ChoiceField(choices=FeatureStatus.choices)
superseded_by = FeatureChoiceField(
queryset=Feature.objects.none(),
required=False,
empty_label="—",
help_text="Required when the status is Superseded.",
)
note = forms.CharField(
label="Change note",
max_length=500,
help_text="Why is this changing? Kept in the feature's history.",
)
def __init__(self, *args, project, feature=None, exclude_ids=(), **kwargs):
super().__init__(*args, **kwargs)
others = project.features.select_related("project")
if feature:
others = others.exclude(pk=feature.pk)
self.fields["parent"].queryset = others.exclude(pk__in=exclude_ids)
self.fields["superseded_by"].queryset = others
if feature is None:
# New features start active; the note defaults to "Created."
del self.fields["status"]
del self.fields["superseded_by"]
self.fields["note"].required = False
self.fields["note"].help_text = "Optional. Kept in the feature's history."
def clean(self):
cleaned = super().clean()
if cleaned.get("status") == FeatureStatus.SUPERSEDED and not cleaned.get("superseded_by"):
self.add_error("superseded_by", "Choose the feature that supersedes this one.")
return cleaned
class CompareForm(forms.Form):
old = forms.IntegerField(min_value=1, label="From revision")
new = forms.IntegerField(min_value=1, label="To revision")

View file

@ -0,0 +1,52 @@
# Generated by Django 5.2.18 on 2026-10-27 11:33
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 = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='FeatureRevision',
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)),
('revision', models.PositiveIntegerField()),
('title', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('active', 'Active'), ('withdrawn', 'Withdrawn'), ('superseded', 'Superseded')], max_length=20)),
('note', models.CharField(max_length=500)),
],
options={
'ordering': ['feature', 'revision'],
},
),
migrations.CreateModel(
name='Feature',
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)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('active', 'Active'), ('withdrawn', 'Withdrawn'), ('superseded', 'Superseded')], default='active', max_length=20)),
('current_revision', models.PositiveIntegerField(default=1)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='children', to='features.feature')),
],
options={
'ordering': ['number'],
},
),
]

View file

@ -0,0 +1,61 @@
# Generated by Django 5.2.18 on 2026-10-27 11:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('features', '0001_initial'),
('projects', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='feature',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='projects.project'),
),
migrations.AddField(
model_name='feature',
name='superseded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='supersedes', to='features.feature'),
),
migrations.AddField(
model_name='featurerevision',
name='author',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='featurerevision',
name='feature',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='features.feature'),
),
migrations.AddField(
model_name='featurerevision',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='features.feature'),
),
migrations.AddField(
model_name='featurerevision',
name='superseded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='features.feature'),
),
migrations.AddConstraint(
model_name='feature',
constraint=models.UniqueConstraint(fields=('project', 'number'), name='feature_unique_number'),
),
migrations.AddConstraint(
model_name='feature',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('status', 'superseded'), ('superseded_by__isnull', False)), models.Q(models.Q(('status', 'superseded'), _negated=True), models.Q(('superseded_by__isnull', False), _negated=True)), _connector='OR'), name='feature_superseded_by'),
),
migrations.AddConstraint(
model_name='featurerevision',
constraint=models.UniqueConstraint(fields=('feature', 'revision'), name='revision_unique'),
),
]

View file

90
intent/features/models.py Normal file
View file

@ -0,0 +1,90 @@
import re
from django.conf import settings
from django.db import models
from django.db.models import Q
from intent.core.models import BaseModel
from intent.projects.models import Project
FEATURE_KEY_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]{1,7})-([1-9][0-9]*)$")
class FeatureStatus(models.TextChoices):
ACTIVE = "active", "Active"
WITHDRAWN = "withdrawn", "Withdrawn"
SUPERSEDED = "superseded", "Superseded"
def _superseded_needs_replacement():
is_superseded = Q(status=FeatureStatus.SUPERSEDED)
has_replacement = Q(superseded_by__isnull=False)
return (is_superseded & has_replacement) | (~is_superseded & ~has_replacement)
class Feature(BaseModel):
"""Current state of a feature. Every change is also recorded as a FeatureRevision."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="features")
number = models.PositiveIntegerField()
# RESTRICT (not PROTECT) so deleting a whole project can still cascade through the tree.
parent = models.ForeignKey(
"self", null=True, blank=True, on_delete=models.RESTRICT, related_name="children"
)
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
status = models.CharField(
max_length=20, choices=FeatureStatus.choices, default=FeatureStatus.ACTIVE
)
superseded_by = models.ForeignKey(
"self", null=True, blank=True, on_delete=models.RESTRICT, related_name="supersedes"
)
current_revision = models.PositiveIntegerField(default=1)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
)
class Meta:
constraints = [
models.UniqueConstraint(fields=["project", "number"], name="feature_unique_number"),
models.CheckConstraint(
condition=_superseded_needs_replacement(), name="feature_superseded_by"
),
]
ordering = ["number"]
def __str__(self):
return f"{self.key} {self.title}"
@property
def key(self) -> str:
return f"{self.project.feature_prefix}-{self.number}"
class FeatureRevision(BaseModel):
"""An immutable snapshot of a feature after one change, with the note explaining why."""
feature = models.ForeignKey(Feature, on_delete=models.CASCADE, related_name="revisions")
revision = models.PositiveIntegerField()
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
parent = models.ForeignKey(
Feature, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
status = models.CharField(max_length=20, choices=FeatureStatus.choices)
superseded_by = models.ForeignKey(
Feature, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
note = models.CharField(max_length=500)
author = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
)
class Meta:
constraints = [
models.UniqueConstraint(fields=["feature", "revision"], name="revision_unique"),
]
ordering = ["feature", "revision"]
def __str__(self):
return f"{self.feature.key} r{self.revision}"

116
intent/features/services.py Normal file
View file

@ -0,0 +1,116 @@
"""Feature rules. Every change goes through here so it is recorded as a revision."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import F
from intent.access import project_access
from intent.projects.models import Project
from .models import Feature, FeatureRevision, FeatureStatus
TRACKED_FIELDS = ["title", "description", "parent_id", "status", "superseded_by_id"]
def _require_edit(by, project):
if not project_access(by, project).can_edit:
raise PermissionDenied
def build_tree(features) -> list[dict]:
"""Nest features under their parents: [{"feature": f, "children": [...]}, ...]."""
nodes = {f.pk: {"feature": f, "children": []} for f in features}
roots = []
for f in features:
siblings = nodes[f.parent_id]["children"] if f.parent_id in nodes else roots
siblings.append(nodes[f.pk])
return roots
def descendant_ids(feature) -> set:
"""IDs of every feature below this one in its project's tree."""
children = {}
for pk, parent_id in Feature.objects.filter(project_id=feature.project_id).values_list(
"pk", "parent_id"
):
children.setdefault(parent_id, []).append(pk)
found, stack = set(), [feature.pk]
while stack:
for child in children.get(stack.pop(), []):
if child not in found:
found.add(child)
stack.append(child)
return found
def _validate(feature) -> None:
if feature.parent_id:
if feature.parent.project_id != feature.project_id:
raise ValidationError("The parent must be in the same project.")
if feature.pk and (
feature.parent_id == feature.pk or feature.parent_id in descendant_ids(feature)
):
raise ValidationError("A feature can't be placed under itself or its own children.")
if feature.status == FeatureStatus.SUPERSEDED:
if not feature.superseded_by_id:
raise ValidationError("Choose the feature that supersedes this one.")
if feature.superseded_by.project_id != feature.project_id:
raise ValidationError("The replacement must be in the same project.")
if feature.superseded_by_id == feature.pk:
raise ValidationError("A feature can't supersede itself.")
else:
feature.superseded_by = None
def _record(feature, by, note) -> FeatureRevision:
return FeatureRevision.objects.create(
feature=feature,
revision=feature.current_revision,
title=feature.title,
description=feature.description,
parent_id=feature.parent_id,
status=feature.status,
superseded_by_id=feature.superseded_by_id,
note=note,
author=by,
)
def create_feature(project, by, *, title, description="", parent=None, note="") -> Feature:
_require_edit(by, project)
with transaction.atomic():
Project.objects.filter(pk=project.pk).update(
next_feature_number=F("next_feature_number") + 1
)
project.refresh_from_db(fields=["next_feature_number"])
feature = Feature(
project=project,
number=project.next_feature_number - 1,
title=title,
description=description,
parent=parent,
created_by=by,
)
_validate(feature)
feature.save()
_record(feature, by, note.strip() or "Created.")
return feature
def update_feature(feature, by, *, note, **changes) -> FeatureRevision:
"""Apply changes to tracked fields and record a revision. `note` explains why."""
_require_edit(by, feature.project)
note = note.strip()
if not note:
raise ValidationError("Explain the change in the change note.")
with transaction.atomic():
feature.refresh_from_db()
before = {f: getattr(feature, f) for f in TRACKED_FIELDS}
for name, value in changes.items():
setattr(feature, name, value)
_validate(feature)
if {f: getattr(feature, f) for f in TRACKED_FIELDS} == before:
raise ValidationError("Nothing changed.")
feature.current_revision += 1
feature.save()
return _record(feature, by, note)

15
intent/features/urls.py Normal file
View file

@ -0,0 +1,15 @@
from django.urls import path
from . import views
app_name = "features"
urlpatterns = [
path("", views.feature_list, name="list"),
path("new/", views.feature_create, name="create"),
path("<str:feature_key>/", views.feature_detail, name="detail"),
path("<str:feature_key>/edit/", views.feature_edit, name="edit"),
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>/compare/", views.feature_compare, name="compare"),
]

218
intent/features/views.py Normal file
View file

@ -0,0 +1,218 @@
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from intent.markdown import render as render_markdown
from intent.projects.decorators import project_view
from . import services
from .diff import line_diff
from .forms import CompareForm, FeatureForm
from .models import FEATURE_KEY_RE, Feature
def get_feature(project, feature_key) -> Feature:
match = FEATURE_KEY_RE.match(feature_key)
if not match or match.group(1).upper() != project.feature_prefix:
raise Http404
return get_object_or_404(
Feature.objects.select_related("project__org", "parent", "superseded_by", "created_by"),
project=project,
number=int(match.group(2)),
)
def _feature_redirect(feature, name="detail"):
return redirect(
f"features:{name}",
org_slug=feature.project.org.slug,
project_slug=feature.project.slug,
feature_key=feature.key,
)
@project_view()
def feature_list(request, project, access):
features = project.features.select_related("project", "parent__project")
return render(
request,
"features/list.html",
{"project": project, "access": access, "features": features},
)
@project_view(require="can_edit")
def feature_create(request, project, access):
initial = {}
if parent_key := request.GET.get("parent"):
initial["parent"] = get_feature(project, parent_key)
form = FeatureForm(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["description"])
else:
try:
feature = services.create_feature(
project,
request.user,
title=data["title"],
description=data["description"],
parent=data["parent"],
note=data["note"],
)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, f"Created {feature.key}.")
return _feature_redirect(feature)
context = {"project": project, "access": access, "form": form, "preview": preview}
return render(request, "features/form.html", context)
@project_view()
def feature_detail(request, project, access, feature_key):
feature = get_feature(project, feature_key)
latest = feature.revisions.select_related("author").last()
context = {
"project": project,
"access": access,
"feature": feature,
"latest": latest,
"ancestors": _ancestors(feature),
"children": feature.children.select_related("project"),
"supersedes": feature.supersedes.select_related("project"),
"description": render_markdown(feature.description),
}
return render(request, "features/detail.html", context)
def _ancestors(feature) -> list[Feature]:
chain, node = [], feature.parent
while node:
chain.insert(0, node)
node = node.parent
return chain
@project_view(require="can_edit")
def feature_edit(request, project, access, feature_key):
feature = get_feature(project, feature_key)
initial = {
"title": feature.title,
"description": feature.description,
"parent": feature.parent,
"status": feature.status,
"superseded_by": feature.superseded_by,
}
form = FeatureForm(
request.POST or None,
project=project,
feature=feature,
exclude_ids=services.descendant_ids(feature),
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["description"])
else:
try:
services.update_feature(feature, request.user, **data)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, f"Saved revision {feature.current_revision}.")
return _feature_redirect(feature)
context = {
"project": project,
"access": access,
"feature": feature,
"form": form,
"preview": preview,
}
return render(request, "features/form.html", context)
def _changes(revision, previous) -> list[str]:
"""Plain-language summary of what a revision changed."""
if previous is None:
return ["Created"]
changes = []
if revision.title != previous.title:
changes.append("Title")
if revision.description != previous.description:
changes.append("Description")
if revision.parent_id != previous.parent_id:
changes.append("Parent")
if revision.status != previous.status or revision.superseded_by_id != previous.superseded_by_id:
changes.append(f"Status: {revision.get_status_display()}")
return changes
@project_view()
def feature_history(request, project, access, feature_key):
feature = get_feature(project, feature_key)
revisions = list(feature.revisions.select_related("author"))
rows = [
{"revision": rev, "changes": _changes(rev, revisions[i - 1] if i else None)}
for i, rev in enumerate(revisions)
]
rows.reverse()
context = {"project": project, "access": access, "feature": feature, "rows": rows}
return render(request, "features/history.html", context)
@project_view()
def feature_revision(request, project, access, feature_key, revision):
feature = get_feature(project, feature_key)
snapshot = get_object_or_404(
feature.revisions.select_related("author", "parent__project", "superseded_by__project"),
revision=revision,
)
if revision == feature.current_revision:
return _feature_redirect(feature)
context = {
"project": project,
"access": access,
"feature": feature,
"snapshot": snapshot,
"description": render_markdown(snapshot.description),
}
return render(request, "features/revision.html", context)
@project_view()
def feature_compare(request, project, access, feature_key):
feature = get_feature(project, feature_key)
current = feature.current_revision
form = CompareForm(request.GET or {"old": max(current - 1, 1), "new": current})
if not form.is_valid():
raise Http404
old_no, new_no = sorted([form.cleaned_data["old"], form.cleaned_data["new"]])
revisions = feature.revisions.select_related("parent__project", "superseded_by__project")
old = get_object_or_404(revisions, revision=old_no)
new = get_object_or_404(revisions, revision=new_no)
fields = [
("Title", old.title, new.title),
("Parent", _label(old.parent), _label(new.parent)),
("Status", old.get_status_display(), new.get_status_display()),
("Superseded by", _label(old.superseded_by), _label(new.superseded_by)),
]
context = {
"project": project,
"access": access,
"feature": feature,
"old": old,
"new": new,
"field_changes": [(name, a, b) for name, a, b in fields if a != b],
"hunks": line_diff(old.description, new.description),
}
return render(request, "features/compare.html", context)
def _label(feature) -> str:
return f"{feature.key} {feature.title}" if feature else "None"

36
intent/markdown.py Normal file
View file

@ -0,0 +1,36 @@
"""Markdown to safe HTML for user-written text (feature descriptions, later issues and comments).
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
<h1> to the page.
"""
import nh3
from django.utils.safestring import SafeString, mark_safe
from markdown_it import MarkdownIt
_ALLOWED_ATTRIBUTES = {tag: set(attrs) for tag, attrs in nh3.ALLOWED_ATTRIBUTES.items()}
_ALLOWED_ATTRIBUTES["*"] = {"data-source-lines"}
def _annotate(state):
for token in state.tokens:
if token.nesting == 1 and token.map:
start, end = token.map
token.attrSet("data-source-lines", f"{start + 1}-{end}")
if token.type in ("heading_open", "heading_close"):
token.tag = f"h{min(int(token.tag[1]) + 1, 6)}"
def _build() -> MarkdownIt:
md = MarkdownIt("commonmark", {"html": False}).enable(["table", "strikethrough"])
md.core.ruler.push("intent_annotate", _annotate)
return md
_md = _build()
def render(text: str) -> SafeString:
html = _md.render(text or "")
return mark_safe(nh3.clean(html, attributes=_ALLOWED_ATTRIBUTES))

View file

@ -147,4 +147,6 @@ def remove_member(membership, by) -> None:
membership.refresh_from_db() membership.refresh_from_db()
if _is_last_owner(membership): if _is_last_owner(membership):
raise ValidationError(LAST_OWNER_MESSAGE) raise ValidationError(LAST_OWNER_MESSAGE)
# Project roles only make sense for team members.
membership.user.project_memberships.filter(project__org=membership.org).delete()
membership.delete() membership.delete()

View file

@ -7,7 +7,7 @@ from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from intent.access import org_access from intent.access import org_access, visible_projects
from . import services from . import services
from .forms import InviteForm, MembershipForm from .forms import InviteForm, MembershipForm
@ -21,9 +21,12 @@ def _error(request, exc: ValidationError):
def org_detail(request, org_slug): def org_detail(request, org_slug):
org = get_object_or_404(Organization, slug=org_slug) org = get_object_or_404(Organization, slug=org_slug)
return render( context = {
request, "orgs/detail.html", {"org": org, "access": org_access(request.user, org)} "org": org,
) "access": org_access(request.user, org),
"projects": visible_projects(request.user, org),
}
return render(request, "orgs/detail.html", context)
def members(request, org_slug): def members(request, org_slug):

View file

17
intent/projects/admin.py Normal file
View file

@ -0,0 +1,17 @@
from django.contrib import admin
from .models import Project, ProjectMembership
class ProjectMembershipInline(admin.TabularInline):
model = ProjectMembership
extra = 0
autocomplete_fields = ["user"]
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ["name", "org", "slug", "feature_prefix", "visibility"]
list_filter = ["visibility"]
search_fields = ["name", "slug"]
inlines = [ProjectMembershipInline]

6
intent/projects/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ProjectsConfig(AppConfig):
name = "intent.projects"
label = "projects"

View file

@ -0,0 +1,37 @@
from functools import wraps
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404
from intent.access import project_access
from .models import Project
def project_view(require="can_view"):
"""Load the project from the URL, check access, and call view(request, project, access, ...).
Projects a user can't see are 404s, so private projects don't reveal they exist.
Signed-out visitors are sent to sign in instead.
"""
def decorator(view):
@wraps(view)
def wrapper(request, org_slug, project_slug, **kwargs):
project = get_object_or_404(
Project.objects.select_related("org"), org__slug=org_slug, slug=project_slug
)
access = project_access(request.user, project)
if not access.can_view or not getattr(access, require):
if not request.user.is_authenticated:
return redirect_to_login(request.get_full_path())
if not access.can_view:
raise Http404
raise PermissionDenied
return view(request, project, access, **kwargs)
return wrapper
return decorator

64
intent/projects/forms.py Normal file
View file

@ -0,0 +1,64 @@
from django import forms
from django.contrib.auth import get_user_model
from intent.orgs.models import Role
from .models import Project
class ProjectCreateForm(forms.ModelForm):
class Meta:
model = Project
fields = ["name", "slug", "feature_prefix", "visibility", "open_contribution"]
labels = {
"slug": "URL name",
"feature_prefix": "Feature ID prefix",
"open_contribution": "Let any signed-in user comment and open issues",
}
help_texts = {
"slug": "Lowercase letters, numbers and hyphens. Can't be changed later.",
"feature_prefix": "Features are numbered like AUTH-12. Can't be changed later.",
}
widgets = {"visibility": forms.RadioSelect}
def clean_feature_prefix(self):
return self.cleaned_data["feature_prefix"].upper()
class ProjectSettingsForm(forms.ModelForm):
class Meta:
model = Project
fields = ["name", "visibility", "open_contribution"]
labels = ProjectCreateForm.Meta.labels
widgets = ProjectCreateForm.Meta.widgets
class ProjectDeleteForm(forms.Form):
confirm = forms.CharField(label="Type the project's URL name to confirm")
def __init__(self, *args, project, **kwargs):
super().__init__(*args, **kwargs)
self.project = project
def clean_confirm(self):
if self.cleaned_data["confirm"] != self.project.slug:
raise forms.ValidationError(f"Type “{self.project.slug}” exactly.")
return self.cleaned_data["confirm"]
class AddMemberForm(forms.Form):
user = forms.ModelChoiceField(queryset=get_user_model().objects.none(), label="Team member")
role = forms.ChoiceField(choices=Role.choices, initial=Role.COLLABORATOR)
def __init__(self, *args, project, **kwargs):
super().__init__(*args, **kwargs)
self.fields["user"].queryset = (
get_user_model()
.objects.filter(org_memberships__org=project.org)
.exclude(project_memberships__project=project)
.order_by("username")
)
class MemberRoleForm(forms.Form):
role = forms.ChoiceField(choices=Role.choices)

View file

@ -0,0 +1,71 @@
# Generated by Django 5.2.18 on 2026-10-27 11:33
import django.core.validators
import django.db.models.deletion
import intent.core.models
import intent.projects.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('orgs', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Project',
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)),
('slug', models.SlugField(max_length=40, validators=[intent.projects.models.validate_project_slug])),
('feature_prefix', models.CharField(max_length=8, validators=[django.core.validators.RegexValidator('^[A-Z][A-Z0-9]{1,7}$', 'Use 2–8 uppercase letters or digits, starting with a letter.')])),
('name', models.CharField(max_length=100)),
('visibility', models.CharField(choices=[('private', 'Private: project members only'), ('authenticated', 'Signed-in users'), ('public', 'Public: anyone, including signed-out visitors')], default='private', max_length=20)),
('open_contribution', models.BooleanField(default=False)),
('next_feature_number', models.PositiveIntegerField(default=1)),
('next_issue_number', models.PositiveIntegerField(default=1)),
('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='orgs.organization')),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ProjectMembership',
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)),
('role', models.CharField(choices=[('owner', 'Owner'), ('collaborator', 'Collaborator')], default='collaborator', max_length=20)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='projects.project')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['user__username'],
},
),
migrations.AddField(
model_name='project',
name='members',
field=models.ManyToManyField(related_name='projects', through='projects.ProjectMembership', to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='projectmembership',
constraint=models.UniqueConstraint(fields=('project', 'user'), name='projectmembership_unique'),
),
migrations.AddConstraint(
model_name='project',
constraint=models.UniqueConstraint(fields=('org', 'slug'), name='project_unique_slug'),
),
migrations.AddConstraint(
model_name='project',
constraint=models.UniqueConstraint(fields=('org', 'feature_prefix'), name='project_unique_prefix'),
),
]

View file

74
intent/projects/models.py Normal file
View file

@ -0,0 +1,74 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models
from intent.core.models import BaseModel
from intent.orgs.models import Organization, Role
# Project slugs share the URL space under /<org>/ with org pages.
RESERVED_SLUGS = {"invites", "members", "new", "projects", "settings"}
def validate_project_slug(value):
if value.lower() in RESERVED_SLUGS:
raise ValidationError(f"“{value}” is reserved.")
validate_feature_prefix = RegexValidator(
r"^[A-Z][A-Z0-9]{1,7}$",
"Use 2–8 uppercase letters or digits, starting with a letter.",
)
class Visibility(models.TextChoices):
PRIVATE = "private", "Private: project members only"
AUTHENTICATED = "authenticated", "Signed-in users"
PUBLIC = "public", "Public: anyone, including signed-out visitors"
class Project(BaseModel):
org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="projects")
# Slug and prefix are fixed after creation: they appear in URLs and feature IDs.
slug = models.SlugField(max_length=40, validators=[validate_project_slug])
feature_prefix = models.CharField(max_length=8, validators=[validate_feature_prefix])
name = models.CharField(max_length=100)
visibility = models.CharField(
max_length=20, choices=Visibility.choices, default=Visibility.PRIVATE
)
# Lets signed-in non-members comment and open issues.
open_contribution = models.BooleanField(default=False)
next_feature_number = models.PositiveIntegerField(default=1)
next_issue_number = models.PositiveIntegerField(default=1)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL, through="ProjectMembership", related_name="projects"
)
class Meta:
constraints = [
models.UniqueConstraint(fields=["org", "slug"], name="project_unique_slug"),
models.UniqueConstraint(fields=["org", "feature_prefix"], name="project_unique_prefix"),
]
ordering = ["name"]
def __str__(self):
return self.name
class ProjectMembership(BaseModel):
"""Grants an org team member a role on one project. Org owners need no row."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="memberships")
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="project_memberships"
)
role = models.CharField(max_length=20, choices=Role.choices, default=Role.COLLABORATOR)
class Meta:
constraints = [
models.UniqueConstraint(fields=["project", "user"], name="projectmembership_unique"),
]
ordering = ["user__username"]
def __str__(self):
return f"{self.user} on {self.project} ({self.role})"

View file

@ -0,0 +1,71 @@
"""Project rules. Same conventions as intent.orgs.services."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from intent.access import org_access, project_access
from intent.orgs.models import Role
from .models import Project, ProjectMembership
def _require_admin(by, project):
if not project_access(by, project).can_admin:
raise PermissionDenied
def create_project(org, by, *, slug, name, feature_prefix, visibility, open_contribution):
if not org_access(by, org).can_create_projects:
raise PermissionDenied
project = Project(
org=org,
slug=slug,
name=name,
feature_prefix=feature_prefix.upper(),
visibility=visibility,
open_contribution=open_contribution,
)
project.full_clean()
with transaction.atomic():
project.save()
ProjectMembership.objects.create(project=project, user=by, role=Role.OWNER)
return project
def update_settings(project, by, *, name, visibility, open_contribution) -> None:
_require_admin(by, project)
project.name = name
project.visibility = visibility
project.open_contribution = open_contribution
project.full_clean()
project.save(update_fields=["name", "visibility", "open_contribution", "updated_at"])
def delete_project(project, by) -> None:
_require_admin(by, project)
project.delete()
def add_member(project, by, *, user, role=Role.COLLABORATOR) -> ProjectMembership:
_require_admin(by, project)
if not org_access(user, project.org).is_member:
raise ValidationError(f"{user.username} isn't on the {project.org} team.")
if project.memberships.filter(user=user).exists():
raise ValidationError(f"{user.username} is already on this project.")
return ProjectMembership.objects.create(project=project, user=user, role=role)
def update_member(membership, by, *, role) -> None:
_require_admin(by, membership.project)
membership.role = role
membership.save(update_fields=["role", "updated_at"])
def remove_member(membership, by) -> None:
"""Admins can remove anyone; members can remove themselves.
No last-owner guard: org owners can always administer the project.
"""
if membership.user_id != by.pk:
_require_admin(by, membership.project)
membership.delete()

18
intent/projects/urls.py Normal file
View file

@ -0,0 +1,18 @@
from django.urls import path
from . import views
app_name = "projects"
urlpatterns = [
path("", views.project_home, name="home"),
path("settings/", views.project_settings, name="settings"),
path("delete/", views.project_delete, name="delete"),
path("members/", views.project_members, name="members"),
path("members/<str:membership_id>/", views.project_member_update, name="member_update"),
path(
"members/<str:membership_id>/remove/",
views.project_member_remove,
name="member_remove",
),
]

118
intent/projects/views.py Normal file
View file

@ -0,0 +1,118 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied, ValidationError
from django.shortcuts import get_object_or_404, redirect, render
from intent.access import org_access
from intent.features.services import build_tree
from intent.orgs.models import Organization
from . import services
from .decorators import project_view
from .forms import (
AddMemberForm,
MemberRoleForm,
ProjectCreateForm,
ProjectDeleteForm,
ProjectSettingsForm,
)
def _project_redirect(project, name="home"):
return redirect(f"projects:{name}", org_slug=project.org.slug, project_slug=project.slug)
@login_required
def project_create(request, org_slug):
org = get_object_or_404(Organization, slug=org_slug)
if not org_access(request.user, org).can_create_projects:
raise PermissionDenied
form = ProjectCreateForm(request.POST or None)
if request.method == "POST" and form.is_valid():
try:
project = services.create_project(org, request.user, **form.cleaned_data)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, f"Created {project.name}.")
return _project_redirect(project)
return render(request, "projects/new.html", {"org": org, "form": form})
@project_view()
def project_home(request, project, access):
features = list(project.features.select_related("project"))
context = {"project": project, "access": access, "tree": build_tree(features)}
return render(request, "projects/home.html", context)
@project_view(require="can_admin")
def project_settings(request, project, access):
form = ProjectSettingsForm(request.POST or None, instance=project)
if request.method == "POST" and form.is_valid():
services.update_settings(project, request.user, **form.cleaned_data)
messages.success(request, "Settings saved.")
return _project_redirect(project, "settings")
return render(
request, "projects/settings.html", {"project": project, "access": access, "form": form}
)
@project_view(require="can_admin")
def project_delete(request, project, access):
form = ProjectDeleteForm(request.POST or None, project=project)
if request.method == "POST" and form.is_valid():
services.delete_project(project, request.user)
messages.success(request, f"Deleted {project.name}.")
return redirect("orgs:detail", org_slug=project.org.slug)
return render(
request, "projects/delete.html", {"project": project, "access": access, "form": form}
)
@project_view(require="can_edit")
def project_members(request, project, access):
form = AddMemberForm(request.POST or None, project=project)
if request.method == "POST":
if not access.can_admin:
raise PermissionDenied
if form.is_valid():
try:
membership = services.add_member(project, request.user, **form.cleaned_data)
except ValidationError as exc:
form.add_error(None, exc)
else:
messages.success(request, f"Added {membership.user.username}.")
return _project_redirect(project, "members")
context = {
"project": project,
"access": access,
"memberships": project.memberships.select_related("user"),
"form": form,
}
return render(request, "projects/members.html", context)
@project_view(require="can_edit")
def project_member_update(request, project, access, membership_id):
if request.method != "POST":
return _project_redirect(project, "members")
membership = get_object_or_404(project.memberships.select_related("user"), pk=membership_id)
form = MemberRoleForm(request.POST)
if form.is_valid():
services.update_member(membership, request.user, role=form.cleaned_data["role"])
messages.success(request, f"Updated {membership.user.username}.")
return _project_redirect(project, "members")
@project_view(require="can_edit")
def project_member_remove(request, project, access, membership_id):
if request.method != "POST":
return _project_redirect(project, "members")
membership = get_object_or_404(project.memberships.select_related("user"), pk=membership_id)
services.remove_member(membership, request.user)
if membership.user_id == request.user.pk:
messages.success(request, f"You left {project.name}.")
return redirect("orgs:detail", org_slug=project.org.slug)
messages.success(request, f"Removed {membership.user.username}.")
return _project_redirect(project, "members")

View file

@ -8,6 +8,8 @@ dependencies = [
"django-allauth[socialaccount]>=65.21", "django-allauth[socialaccount]>=65.21",
"django-storages[s3]>=1.14.6", "django-storages[s3]>=1.14.6",
"gunicorn>=26.2.0", "gunicorn>=26.2.0",
"markdown-it-py>=4.2.0",
"nh3>=0.3.7",
"python-ulid>=4.0.1", "python-ulid>=4.0.1",
"sentry-sdk[django]>=2.73.0", "sentry-sdk[django]>=2.73.0",
"whitenoise>=6.12.0", "whitenoise>=6.12.0",

View file

@ -42,10 +42,40 @@ nav form {
margin: 0; margin: 0;
} }
.breadcrumb {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 0.5rem;
padding: 0;
list-style: none;
}
.breadcrumb li + li::before {
content: "/";
content: "/" / "";
margin-inline-end: 0.5rem;
}
textarea {
box-sizing: border-box;
width: 100%;
max-width: 80ch;
}
table { table {
border-collapse: collapse; border-collapse: collapse;
} }
.diff tbody + tbody {
border-top: 1px solid;
}
.diff td:last-child {
white-space: pre-wrap;
font-family: monospace;
}
th, th,
td { td {
padding: 0.25rem 0.75rem 0.25rem 0; padding: 0.25rem 0.75rem 0.25rem 0;

View file

@ -0,0 +1,11 @@
{% comment %}Feature heading shared by the feature, edit, history and compare pages.{% endcomment %}
{% if ancestors %}
<nav aria-label="Parent features">
<ol class="breadcrumb">
{% for a in ancestors %}
<li><a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=a.key %}">{{ a.key }} {{ a.title }}</a></li>
{% endfor %}
</ol>
</nav>
{% endif %}
<h1><span class="muted">{{ feature.key }}</span> {{ heading|default:feature.title }}</h1>

View file

@ -0,0 +1,11 @@
<ul>
{% for node in nodes %}
{% with f=node.feature %}
<li>
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=f.key %}">{{ f.key }} {{ f.title }}</a>
{% if f.status != "active" %}<span class="muted">({{ f.get_status_display|lower }})</span>{% endif %}
{% if node.children %}{% include "features/_tree.html" with nodes=node.children %}{% endif %}
</li>
{% endwith %}
{% endfor %}
</ul>

View file

@ -0,0 +1,61 @@
{% extends "projects/base.html" %}
{% block page_title %}{{ feature.key }} revisions {{ old.revision }}–{{ new.revision }}{% endblock %}
{% block project_content %}
<p><a href="{% url 'features:history' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">Back to history</a></p>
<h1>Changes to <span class="muted">{{ feature.key }}</span> from revision {{ old.revision }} to {{ new.revision }}</h1>
{% if old.revision == new.revision %}
<p>Choose two different revisions to compare.</p>
{% else %}
{% if field_changes %}
<table>
<caption>Details</caption>
<thead>
<tr>
<th scope="col">Field</th>
<th scope="col">Revision {{ old.revision }}</th>
<th scope="col">Revision {{ new.revision }}</th>
</tr>
</thead>
<tbody>
{% for name, before, after in field_changes %}
<tr>
<th scope="row">{{ name }}</th>
<td><del>{{ before }}</del></td>
<td><ins>{{ after }}</ins></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2>Description</h2>
{% if hunks %}
<table class="diff">
<caption class="visually-hidden">Line-by-line changes to the description</caption>
<thead>
<tr>
<th scope="col">Old line</th>
<th scope="col">New line</th>
<th scope="col">Text</th>
</tr>
</thead>
{% for hunk in hunks %}
<tbody>
{% for line in hunk %}
<tr class="diff-{{ line.kind }}">
<td>{{ line.old_number|default_if_none:"" }}</td>
<td>{{ line.new_number|default_if_none:"" }}</td>
<td>{% if line.kind == "removed" %}<del><span class="visually-hidden">Removed: </span>- {{ line.text }}</del>{% elif line.kind == "added" %}<ins><span class="visually-hidden">Added: </span>+ {{ line.text }}</ins>{% else %} {{ line.text }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
{% else %}
<p>The description didn't change.</p>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,69 @@
{% extends "projects/base.html" %}
{% block page_title %}{{ feature.key }} {{ feature.title }}{% endblock %}
{% block project_content %}
{% include "features/_header.html" %}
{% if feature.status == "withdrawn" %}
<p role="note"><strong>Withdrawn.</strong> This feature is no longer planned.</p>
{% elif feature.status == "superseded" %}
<p role="note"><strong>Superseded</strong> by <a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.superseded_by.key %}">{{ feature.superseded_by.key }} {{ feature.superseded_by.title }}</a>.</p>
{% endif %}
<dl>
<dt>Status</dt>
<dd>{{ feature.get_status_display }}</dd>
<dt>Revision</dt>
<dd>
{{ feature.current_revision }}
(<a href="{% url 'features:history' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">history</a>)
</dd>
{% if latest %}
<dt>Last change</dt>
<dd>
<time datetime="{{ latest.created_at|date:'c' }}">{{ latest.created_at|date:"j M Y" }}</time>
by {{ latest.author.username|default:"unknown" }}: {{ latest.note }}
</dd>
{% endif %}
</dl>
{% if access.can_edit %}
<ul>
<li><a href="{% url 'features:edit' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">Edit</a></li>
<li><a href="{% url 'features:create' org_slug=project.org.slug project_slug=project.slug %}?parent={{ feature.key }}">Add a sub-feature</a></li>
</ul>
{% endif %}
<section aria-labelledby="description-heading">
<h2 id="description-heading" class="visually-hidden">Description</h2>
<div class="prose">
{% if feature.description %}{{ description }}{% else %}<p class="muted">No description yet.</p>{% endif %}
</div>
</section>
{% if children %}
<section aria-labelledby="children-heading">
<h2 id="children-heading">Sub-features</h2>
<ul>
{% for child in children %}
<li>
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=child.key %}">{{ child.key }} {{ child.title }}</a>
{% if child.status != "active" %}<span class="muted">({{ child.get_status_display|lower }})</span>{% endif %}
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if supersedes %}
<section aria-labelledby="supersedes-heading">
<h2 id="supersedes-heading">Replaces</h2>
<ul>
{% for old in supersedes %}
<li><a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=old.key %}">{{ old.key }} {{ old.title }}</a></li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "projects/base.html" %}
{% block page_title %}{% if feature %}Edit {{ feature.key }}{% else %}New feature{% endif %}{% endblock %}
{% block project_content %}
{% if feature %}
<h1>Edit <span class="muted">{{ feature.key }}</span> {{ feature.title }}</h1>
<p>Saving creates revision {{ feature.current_revision|add:1 }}.</p>
{% else %}
<h1>New feature</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 feature %}Save revision{% else %}Create feature{% endif %}</button>
<button type="submit" name="preview" value="1">Preview description</button>
</p>
</form>
{% endblock %}

View file

@ -0,0 +1,59 @@
{% extends "projects/base.html" %}
{% block page_title %}History of {{ feature.key }}{% endblock %}
{% block project_content %}
<p><a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">Back to {{ feature.key }}</a></p>
<h1>History of <span class="muted">{{ feature.key }}</span> {{ feature.title }}</h1>
<form method="get" action="{% url 'features:compare' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">
<table>
<caption>{{ rows|length }} revision{{ rows|length|pluralize }}, newest first</caption>
<thead>
<tr>
<th scope="col">Revision</th>
<th scope="col">Date</th>
<th scope="col">Author</th>
<th scope="col">Note</th>
<th scope="col">Changed</th>
{% if rows|length > 1 %}
<th scope="col">From</th>
<th scope="col">To</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for row in rows %}
{% with rev=row.revision %}
<tr>
<th scope="row">
{% if rev.revision == feature.current_revision %}
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">{{ rev.revision }}</a> (current)
{% else %}
<a href="{% url 'features:revision' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key revision=rev.revision %}">{{ rev.revision }}</a>
{% endif %}
</th>
<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.note }}</td>
<td>{{ row.changes|join:", " }}</td>
{% if rows|length > 1 %}
<td>
<input type="radio" name="old" value="{{ rev.revision }}" id="old-{{ rev.revision }}"{% if rev.revision == feature.current_revision|add:-1 %} checked{% endif %}>
<label for="old-{{ rev.revision }}" class="visually-hidden">Compare from revision {{ rev.revision }}</label>
</td>
<td>
<input type="radio" name="new" value="{{ rev.revision }}" id="new-{{ rev.revision }}"{% if rev.revision == feature.current_revision %} checked{% endif %}>
<label for="new-{{ rev.revision }}" class="visually-hidden">Compare to revision {{ rev.revision }}</label>
</td>
{% endif %}
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
{% if rows|length > 1 %}
<p><button type="submit">Compare selected revisions</button></p>
{% endif %}
</form>
{% endblock %}

View file

@ -0,0 +1,36 @@
{% extends "projects/base.html" %}
{% block page_title %}All features{% endblock %}
{% block project_content %}
<h1>All features</h1>
{% if access.can_edit %}
<p><a href="{% url 'features:create' org_slug=project.org.slug project_slug=project.slug %}">New feature</a></p>
{% endif %}
{% if features %}
<table>
<thead>
<tr>
<th scope="col">Feature</th>
<th scope="col">Status</th>
<th scope="col">Parent</th>
<th scope="col">Revision</th>
<th scope="col">Updated</th>
</tr>
</thead>
<tbody>
{% for f in features %}
<tr>
<th scope="row"><a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=f.key %}">{{ f.key }} {{ f.title }}</a></th>
<td>{{ f.get_status_display }}</td>
<td>{% if f.parent %}{{ f.parent.key }}{% else %}—{% endif %}</td>
<td>{{ f.current_revision }}</td>
<td><time datetime="{{ f.updated_at|date:'c' }}">{{ f.updated_at|date:"j M Y" }}</time></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No features yet.</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "projects/base.html" %}
{% block page_title %}{{ feature.key }} revision {{ snapshot.revision }}{% endblock %}
{% block project_content %}
<p role="note">
<strong>Old revision.</strong>
You're viewing revision {{ snapshot.revision }} of {{ feature.current_revision }}.
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">View the current version</a>
or <a href="{% url 'features:compare' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}?old={{ snapshot.revision }}&amp;new={{ feature.current_revision }}">compare with it</a>.
</p>
<h1><span class="muted">{{ feature.key }}</span> {{ snapshot.title }}</h1>
<dl>
<dt>Status</dt>
<dd>{{ snapshot.get_status_display }}{% if snapshot.superseded_by %}, by {{ snapshot.superseded_by.key }} {{ snapshot.superseded_by.title }}{% endif %}</dd>
<dt>Parent</dt>
<dd>{% if snapshot.parent %}{{ snapshot.parent.key }} {{ snapshot.parent.title }}{% else %}None (top level){% endif %}</dd>
<dt>Change</dt>
<dd>
<time datetime="{{ snapshot.created_at|date:'c' }}">{{ snapshot.created_at|date:"j M Y" }}</time>
by {{ snapshot.author.username|default:"unknown" }}: {{ snapshot.note }}
</dd>
</dl>
<section aria-labelledby="description-heading">
<h2 id="description-heading" class="visually-hidden">Description</h2>
<div class="prose">
{% if snapshot.description %}{{ description }}{% else %}<p class="muted">No description.</p>{% endif %}
</div>
</section>
{% endblock %}

View file

@ -5,19 +5,41 @@
{% block content %} {% block content %}
<h1>{{ org.name }}</h1> <h1>{{ org.name }}</h1>
{% if access.is_member %} <section aria-labelledby="projects-heading">
<p>You're {% if access.is_owner %}an owner{% else %}a collaborator{% endif %} of this organization.</p> <h2 id="projects-heading">Projects</h2>
<ul> {% if projects %}
<li><a href="{% url 'orgs:members' org_slug=org.slug %}">Members</a></li> <ul>
</ul> {% for project in projects %}
<li>
<a href="{% url 'projects:home' org_slug=org.slug project_slug=project.slug %}">{{ project.name }}</a>
{% if project.visibility != "public" %}<span class="muted">({{ project.visibility }})</span>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No projects{% if not access.is_member %} you can see{% endif %} yet.</p>
{% endif %}
{% if access.can_create_projects %}
<p><a href="{% url 'project_create' org_slug=org.slug %}">New project</a></p>
{% endif %}
</section>
<details> {% if access.is_member %}
<summary>Leave {{ org.name }}</summary> <section aria-labelledby="team-heading">
<form method="post" action="{% url 'orgs:member_remove' org_slug=org.slug membership_id=access.membership.pk %}"> <h2 id="team-heading">Team</h2>
{% csrf_token %} <p>You're {% if access.is_owner %}an owner{% else %}a collaborator{% endif %} of this organization.</p>
<p>You'll need a new invitation to rejoin.</p> <ul>
<p><button type="submit">Leave {{ org.name }}</button></p> <li><a href="{% url 'orgs:members' org_slug=org.slug %}">Members</a></li>
</form> </ul>
</details>
<details>
<summary>Leave {{ org.name }}</summary>
<form method="post" action="{% url 'orgs:member_remove' org_slug=org.slug membership_id=access.membership.pk %}">
{% csrf_token %}
<p>You'll need a new invitation to rejoin, and you'll lose your project roles.</p>
<p><button type="submit">Leave {{ org.name }}</button></p>
</form>
</details>
</section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% comment %}
Shared frame for every page inside a project.
Pages set `page_title` and fill `project_content`.
{% endcomment %}
{% block title %}{% block page_title %}{% endblock %} · {{ project.name }} · Intent{% endblock %}
{% block content %}
<nav aria-label="Breadcrumb">
<ol class="breadcrumb">
<li><a href="{% url 'orgs:detail' org_slug=project.org.slug %}">{{ project.org.name }}</a></li>
<li><a href="{% url 'projects:home' org_slug=project.org.slug project_slug=project.slug %}">{{ project.name }}</a></li>
</ol>
</nav>
<nav aria-label="Project">
<ul>
<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>
{% if access.can_edit %}
<li><a href="{% url 'projects:members' org_slug=project.org.slug project_slug=project.slug %}">Members</a></li>
{% endif %}
{% if access.can_admin %}
<li><a href="{% url 'projects:settings' org_slug=project.org.slug project_slug=project.slug %}">Settings</a></li>
{% endif %}
</ul>
</nav>
{% block project_content %}{% endblock %}
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "projects/base.html" %}
{% block page_title %}Delete{% endblock %}
{% block project_content %}
<h1>Delete {{ project.name }}</h1>
<p><strong>This permanently deletes {{ project.features.count }} feature{{ project.features.count|pluralize }} and all of their history.</strong> It can't be undone.</p>
<form method="post">
{% csrf_token %}
{{ form }}
<p><button type="submit">Delete this project</button></p>
</form>
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "projects/base.html" %}
{% block title %}{{ project.name }} · Intent{% endblock %}
{% block project_content %}
<h1>{{ project.name }}</h1>
<p class="muted">{{ project.get_visibility_display }}{% if project.open_contribution %}. Open to contributions from signed-in users.{% endif %}</p>
<section aria-labelledby="tree-heading">
<h2 id="tree-heading">Features</h2>
{% if access.can_edit %}
<p><a href="{% url 'features:create' org_slug=project.org.slug project_slug=project.slug %}">New feature</a></p>
{% endif %}
{% if tree %}
{% include "features/_tree.html" with nodes=tree %}
{% else %}
<p>No features yet.</p>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,68 @@
{% extends "projects/base.html" %}
{% block page_title %}Members{% endblock %}
{% block project_content %}
<h1>Members</h1>
<p>Owners of {{ project.org.name }} can also administer this project.</p>
{% if access.can_admin %}
{% for m in memberships %}
<form id="member-{{ m.pk }}" method="post" action="{% url 'projects:member_update' org_slug=project.org.slug project_slug=project.slug membership_id=m.pk %}">{% csrf_token %}</form>
<form id="remove-{{ m.pk }}" method="post" action="{% url 'projects:member_remove' org_slug=project.org.slug project_slug=project.slug membership_id=m.pk %}">{% csrf_token %}</form>
{% endfor %}
{% endif %}
<table>
<caption>{{ memberships|length }} member{{ memberships|length|pluralize }}</caption>
<thead>
<tr>
<th scope="col">Member</th>
<th scope="col">Role</th>
{% if access.can_admin %}<th scope="col">Actions</th>{% endif %}
</tr>
</thead>
<tbody>
{% for m in memberships %}
<tr>
<th scope="row">{{ m.user.username }}{% if m.user == user %} (you){% endif %}</th>
{% if access.can_admin %}
<td>
<label for="role-{{ m.pk }}" class="visually-hidden">Role for {{ m.user.username }}</label>
<select id="role-{{ m.pk }}" name="role" form="member-{{ m.pk }}">
<option value="owner"{% if m.role == "owner" %} selected{% endif %}>Owner</option>
<option value="collaborator"{% if m.role == "collaborator" %} selected{% endif %}>Collaborator</option>
</select>
</td>
<td>
<button type="submit" form="member-{{ m.pk }}">Save <span class="visually-hidden">{{ m.user.username }}</span></button>
<button type="submit" form="remove-{{ m.pk }}">Remove <span class="visually-hidden">{{ m.user.username }}</span></button>
</td>
{% else %}
<td>{{ m.get_role_display }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% if access.can_admin %}
<section aria-labelledby="add-heading">
<h2 id="add-heading">Add a team member</h2>
{% if form.fields.user.queryset.exists %}
<form method="post">
{% csrf_token %}
{{ form }}
<p><button type="submit">Add to project</button></p>
</form>
{% else %}
<p>Everyone on the {{ project.org.name }} team is already on this project. <a href="{% url 'orgs:members' org_slug=project.org.slug %}">Invite people to the team</a> first.</p>
{% endif %}
</section>
{% elif access.membership %}
<form method="post" action="{% url 'projects:member_remove' org_slug=project.org.slug project_slug=project.slug membership_id=access.membership.pk %}">
{% csrf_token %}
<p><button type="submit">Leave this project</button></p>
</form>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}New project · {{ org.name }} · Intent{% endblock %}
{% block content %}
<p><a href="{% url 'orgs:detail' org_slug=org.slug %}">{{ org.name }}</a></p>
<h1>New project</h1>
<form method="post">
{% csrf_token %}
{{ form }}
<p><button type="submit">Create project</button></p>
</form>
{% endblock %}

View file

@ -0,0 +1,22 @@
{% extends "projects/base.html" %}
{% block page_title %}Settings{% endblock %}
{% block project_content %}
<h1>Settings</h1>
<dl>
<dt>URL name</dt><dd><code>{{ project.slug }}</code></dd>
<dt>Feature ID prefix</dt><dd><code>{{ project.feature_prefix }}</code></dd>
</dl>
<form method="post">
{% csrf_token %}
{{ form }}
<p><button type="submit">Save settings</button></p>
</form>
<section aria-labelledby="danger-heading">
<h2 id="danger-heading">Delete project</h2>
<p>Deleting removes every feature and its history. It can't be undone.</p>
<p><a href="{% url 'projects:delete' org_slug=project.org.slug project_slug=project.slug %}">Delete {{ project.name }}…</a></p>
</section>
{% endblock %}

67
tests/a11y.py Normal file
View file

@ -0,0 +1,67 @@
"""Structural accessibility checks for rendered pages.
Not a substitute for axe or a screen reader, but cheap enough to run on every page in unit tests.
"""
from html.parser import HTMLParser
_UNLABELLED_INPUT_TYPES = {"hidden", "submit", "button", "reset", "image"}
class _Collector(HTMLParser):
def __init__(self):
super().__init__()
self.html_lang = None
self.headings = []
self.ids = []
self.label_fors = set()
self.controls = []
self.images_without_alt = 0
self._label_depth = 0
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == "html":
self.html_lang = attrs.get("lang")
if "id" in attrs:
self.ids.append(attrs["id"])
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
self.headings.append(int(tag[1]))
if tag == "label":
self._label_depth += 1
if attrs.get("for"):
self.label_fors.add(attrs["for"])
if tag == "img" and "alt" not in attrs:
self.images_without_alt += 1
if tag in {"input", "select", "textarea"}:
if tag == "input" and attrs.get("type", "text") in _UNLABELLED_INPUT_TYPES:
return
wrapped = self._label_depth > 0
named = bool(attrs.get("aria-label") or attrs.get("aria-labelledby"))
self.controls.append((attrs.get("id"), attrs.get("name"), wrapped or named))
def handle_endtag(self, tag):
if tag == "label":
self._label_depth -= 1
def assert_accessible_html(html: str) -> None:
page = _Collector()
page.feed(html)
problems = []
if not page.html_lang:
problems.append("<html> has no lang")
if page.headings.count(1) != 1:
problems.append(f"expected one <h1>, found {page.headings.count(1)}")
for before, after in zip(page.headings, page.headings[1:], strict=False):
if after > before + 1:
problems.append(f"heading level skips from h{before} to h{after}")
duplicates = {i for i in page.ids if page.ids.count(i) > 1}
if duplicates:
problems.append(f"duplicate ids: {sorted(duplicates)}")
for control_id, name, labelled in page.controls:
if not (labelled or control_id in page.label_fors):
problems.append(f"form control without a label: id={control_id} name={name}")
if page.images_without_alt:
problems.append(f"{page.images_without_alt} <img> without alt")
assert not problems, "\n".join(problems)

View file

@ -6,6 +6,8 @@ from django.contrib.auth import get_user_model
from intent.orgs.models import OrgMembership, Role from intent.orgs.models import OrgMembership, Role
from intent.orgs.services import create_org from intent.orgs.services import create_org
from intent.projects.models import ProjectMembership, Visibility
from intent.projects.services import create_project
_counter = itertools.count() _counter = itertools.count()
@ -39,3 +41,26 @@ def collaborator(make_user, org):
user = make_user("cole") user = make_user("cole")
OrgMembership.objects.create(org=org, user=user, role=Role.COLLABORATOR) OrgMembership.objects.create(org=org, user=user, role=Role.COLLABORATOR)
return user return user
@pytest.fixture
def project(org, owner):
"""A private project created by the org owner, who is therefore its project owner too."""
return create_project(
org,
owner,
slug="intent",
name="Intent",
feature_prefix="INT",
visibility=Visibility.PRIVATE,
open_contribution=False,
)
@pytest.fixture
def editor(make_user, org, project):
"""An org collaborator who is a collaborator on the project."""
user = make_user("eddie")
OrgMembership.objects.create(org=org, user=user)
ProjectMembership.objects.create(project=project, user=user)
return user

73
tests/test_a11y.py Normal file
View file

@ -0,0 +1,73 @@
import pytest
from django.urls import reverse
from intent.features.services import create_feature, update_feature
from intent.orgs.services import invite
from .a11y import assert_accessible_html
@pytest.fixture
def populated(project, owner, make_user, org):
parent = create_feature(project, owner, title="Accounts", description="# Goal\n\nText\n")
child = create_feature(project, owner, title="Sign in", parent=parent)
update_feature(child, owner, note="Detail", description="Steps\n\n1. One\n2. Two\n")
invite(org, owner, email="new@example.com")
return child
def _pages(org, project, feature):
o, p, k = org.slug, project.slug, feature.key
pk = {"org_slug": o, "project_slug": p}
fk = {**pk, "feature_key": k}
return [
reverse("home"),
reverse("invites:list"),
reverse("account_email"),
reverse("orgs:detail", kwargs={"org_slug": o}),
reverse("orgs:members", kwargs={"org_slug": o}),
reverse("project_create", kwargs={"org_slug": o}),
reverse("projects:home", kwargs=pk),
reverse("projects:settings", kwargs=pk),
reverse("projects:delete", kwargs=pk),
reverse("projects:members", kwargs=pk),
reverse("features:list", kwargs=pk),
reverse("features:create", kwargs=pk),
reverse("features:detail", kwargs=fk),
reverse("features:edit", kwargs=fk),
reverse("features:history", kwargs=fk),
reverse("features:revision", kwargs={**fk, "revision": 1}),
reverse("features:compare", kwargs=fk),
]
def test_signed_in_pages(client, owner, org, project, populated):
client.force_login(owner)
for path in _pages(org, project, populated):
response = client.get(path)
assert response.status_code == 200, path
try:
assert_accessible_html(response.content.decode())
except AssertionError as exc:
raise AssertionError(f"{path}: {exc}") from None
@pytest.mark.parametrize(
"html, problem",
[
("<html><body><h1>a</h1></body></html>", "no lang"),
('<html lang="en"><h2>a</h2></html>', "one <h1>"),
('<html lang="en"><h1>a</h1><h3>b</h3></html>', "skips"),
('<html lang="en"><h1 id="x">a</h1><p id="x"></p></html>', "duplicate ids"),
('<html lang="en"><h1>a</h1><input name="q"></html>', "without a label"),
('<html lang="en"><h1>a</h1><img src="x"></html>', "without alt"),
],
)
def test_checker_catches_problems(html, problem):
with pytest.raises(AssertionError, match=problem):
assert_accessible_html(html)
@pytest.mark.parametrize("name", ["account_login", "account_signup", "account_request_login_code"])
def test_signed_out_pages(client, db, name):
assert_accessible_html(client.get(reverse(name)).content.decode())

148
tests/test_features.py Normal file
View file

@ -0,0 +1,148 @@
import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import IntegrityError
from django.urls import reverse
from intent.features import services
from intent.features.models import Feature, FeatureStatus
def furl(name, feature=None, project=None, **kwargs):
project = project or feature.project
kwargs = {"org_slug": project.org.slug, "project_slug": project.slug, **kwargs}
if feature:
kwargs["feature_key"] = feature.key
return reverse(f"features:{name}", kwargs=kwargs)
@pytest.fixture
def feature(project, owner):
return services.create_feature(project, owner, title="Sign in", description="Line one\n")
def test_numbers_are_sequential_and_first_revision_recorded(project, owner, feature):
second = services.create_feature(project, owner, title="Second")
assert (feature.key, second.key) == ("INT-1", "INT-2")
revision = feature.revisions.get()
assert (revision.revision, revision.note) == (1, "Created.")
def test_update_records_revision_with_note(owner, feature):
revision = services.update_feature(feature, owner, note="Clarify", description="Line uno\n")
assert revision.revision == feature.current_revision == 2
assert revision.note == "Clarify"
assert feature.revisions.get(revision=1).description == "Line one\n"
def test_update_needs_a_note_and_a_change(owner, feature):
with pytest.raises(ValidationError):
services.update_feature(feature, owner, note=" ", title="New")
with pytest.raises(ValidationError):
services.update_feature(feature, owner, note="No-op", title="Sign in")
def test_cannot_create_a_cycle(project, owner, feature):
child = services.create_feature(project, owner, title="Child", parent=feature)
grandchild = services.create_feature(project, owner, title="Grandchild", parent=child)
with pytest.raises(ValidationError):
services.update_feature(feature, owner, note="Loop", parent=grandchild)
with pytest.raises(ValidationError):
services.update_feature(feature, owner, note="Self", parent=feature)
def test_superseded_needs_replacement(project, owner, feature):
with pytest.raises(ValidationError):
services.update_feature(feature, owner, note="Gone", status=FeatureStatus.SUPERSEDED)
replacement = services.create_feature(project, owner, title="Sign in v2")
services.update_feature(
feature, owner, note="Replaced", status=FeatureStatus.SUPERSEDED, superseded_by=replacement
)
services.update_feature(feature, owner, note="Back", status=FeatureStatus.ACTIVE)
feature.refresh_from_db()
assert feature.superseded_by is None
def test_database_enforces_superseded_by(feature):
with pytest.raises(IntegrityError):
Feature.objects.filter(pk=feature.pk).update(status=FeatureStatus.SUPERSEDED)
def test_collaborator_without_project_role_cannot_edit(collaborator, project, feature):
with pytest.raises(PermissionDenied):
services.create_feature(project, collaborator, title="Nope")
with pytest.raises(PermissionDenied):
services.update_feature(feature, collaborator, note="x", title="Nope")
def test_create_via_form_with_parent_prefill(client, editor, project, feature):
client.force_login(editor)
response = client.get(furl("create", project=project) + "?parent=int-1")
assert b"selected>INT-1 Sign in" in response.content
response = client.post(
furl("create", project=project),
{"title": "Child", "description": "", "parent": feature.pk, "note": ""},
)
child = Feature.objects.get(title="Child")
assert response["Location"] == furl("detail", child)
assert child.parent == feature
def test_preview_does_not_save(client, owner, feature):
client.force_login(owner)
response = client.post(
furl("edit", feature),
{
"title": "Sign in",
"description": "# Draft",
"status": "active",
"note": "x",
"preview": "1",
},
)
assert b"<h2" in response.content and b"Draft" in response.content
feature.refresh_from_db()
assert feature.current_revision == 1
def test_edit_via_form(client, owner, feature):
client.force_login(owner)
client.post(
furl("edit", feature),
{"title": "Sign in", "description": "Line uno\n", "status": "active", "note": "Spanish"},
)
feature.refresh_from_db()
assert feature.current_revision == 2
def test_feature_pages_render(client, owner, project, feature):
services.update_feature(feature, owner, note="Edit", description="Line uno\n", title="Login")
client.force_login(owner)
for path in [
furl("list", project=project),
reverse("projects:home", kwargs={"org_slug": "catbriar", "project_slug": "intent"}),
furl("detail", feature),
furl("history", feature),
furl("revision", feature, revision=1),
furl("compare", feature),
]:
assert client.get(path).status_code == 200, path
compare = client.get(furl("compare", feature) + "?old=1&new=2").content.decode()
assert "Line one" in compare and "Line uno" in compare and "Removed: " in compare
assert client.get(furl("revision", feature, revision=2)).status_code == 302
def test_feature_key_lookup(client, owner, feature):
client.force_login(owner)
base = furl("list", project=feature.project)
assert client.get(f"{base}int-1/").status_code == 200
assert client.get(f"{base}NOPE-1/").status_code == 404
assert client.get(f"{base}INT-99/").status_code == 404
def test_viewer_cannot_open_edit_page(client, make_user, project, feature):
project.visibility = "public"
project.save()
assert client.get(furl("detail", feature)).status_code == 200
client.force_login(make_user())
assert client.get(furl("edit", feature)).status_code == 403

37
tests/test_markdown.py Normal file
View file

@ -0,0 +1,37 @@
from intent.features.diff import line_diff
from intent.markdown import render
def test_blocks_carry_source_lines():
html = render("Para one\ncontinues.\n\n- a\n- b\n")
assert '<p data-source-lines="1-2">' in html
assert '<li data-source-lines="4-4">' in html
def test_headings_shift_down_one_level():
html = render("# Top\n\n###### Deepest\n")
assert "<h2" in html and "<h1" not in html
assert "<h6" in html
def test_raw_html_and_script_links_are_neutralised():
html = render("<script>alert(1)</script>\n\n[x](javascript:alert(1)) <img src=x onerror=y>")
assert "<script" not in html
assert "javascript:" not in html.replace("[x](javascript:alert(1))", "")
assert "onerror" not in html.replace("onerror=y&gt;", "")
def test_tables_and_strikethrough():
html = render("| a |\n|---|\n| 1 |\n\n~~old~~")
assert "<table" in html and "<s>old</s>" in html
def test_line_diff_hunks():
old = "\n".join(f"line {i}" for i in range(1, 21))
new = old.replace("line 10", "line ten")
[hunk] = line_diff(old, new)
kinds = [(line.kind, line.old_number, line.new_number) for line in hunk]
assert ("removed", 10, None) in kinds
assert ("added", None, 10) in kinds
assert kinds[0] == ("same", 7, 7) # three lines of context
assert line_diff(old, old) == []

View file

@ -0,0 +1,89 @@
import pytest
from django.contrib.auth.models import AnonymousUser
from intent.access import project_access, visible_projects
from intent.orgs.models import OrgMembership
from intent.projects.models import ProjectMembership, Visibility
def _flags(user, project):
a = project_access(user, project)
return (a.can_view, a.can_contribute, a.can_edit, a.can_admin)
@pytest.fixture
def people(make_user, org, project, owner, collaborator, editor):
project_owner = make_user("pam")
OrgMembership.objects.create(org=org, user=project_owner)
ProjectMembership.objects.create(project=project, user=project_owner, role="owner")
return {
"anonymous": AnonymousUser(),
"outsider": make_user(),
"staff": make_user(is_staff=True),
"org collaborator": collaborator,
"project collaborator": editor,
"project owner": project_owner,
"org owner": owner,
}
# (view, contribute, edit, admin) per visibility, without open contribution.
EXPECTED = {
Visibility.PRIVATE: {
"anonymous": (False, False, False, False),
"outsider": (False, False, False, False),
"staff": (False, False, False, False),
"org collaborator": (False, False, False, False),
"project collaborator": (True, True, True, False),
"project owner": (True, True, True, True),
"org owner": (True, True, True, True),
},
Visibility.AUTHENTICATED: {
"anonymous": (False, False, False, False),
"outsider": (True, False, False, False),
"staff": (False, False, False, False),
"org collaborator": (True, False, False, False),
"project collaborator": (True, True, True, False),
"project owner": (True, True, True, True),
"org owner": (True, True, True, True),
},
Visibility.PUBLIC: {
"anonymous": (True, False, False, False),
"outsider": (True, False, False, False),
"staff": (True, False, False, False),
"org collaborator": (True, False, False, False),
"project collaborator": (True, True, True, False),
"project owner": (True, True, True, True),
"org owner": (True, True, True, True),
},
}
@pytest.mark.parametrize("visibility", list(EXPECTED))
def test_access_matrix(people, project, visibility):
project.visibility = visibility
project.save()
for who, user in people.items():
assert _flags(user, project) == EXPECTED[visibility][who], who
def test_open_contribution_lets_signed_in_viewers_contribute(people, project):
project.visibility = Visibility.PUBLIC
project.open_contribution = True
project.save()
assert project_access(people["outsider"], project).can_contribute
assert project_access(people["org collaborator"], project).can_contribute
assert not project_access(people["anonymous"], project).can_contribute
assert not project_access(people["staff"], project).can_contribute
assert not project_access(people["outsider"], project).can_edit
def test_visible_projects(people, org, project):
assert list(visible_projects(people["org owner"], org)) == [project]
assert list(visible_projects(people["project collaborator"], org)) == [project]
assert list(visible_projects(people["org collaborator"], org)) == []
assert list(visible_projects(people["anonymous"], org)) == []
project.visibility = Visibility.AUTHENTICATED
project.save()
assert list(visible_projects(people["outsider"], org)) == [project]
assert list(visible_projects(people["anonymous"], org)) == []

127
tests/test_projects.py Normal file
View file

@ -0,0 +1,127 @@
import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.urls import reverse
from intent.features.models import Feature, FeatureStatus
from intent.features.services import create_feature, update_feature
from intent.orgs.models import OrgMembership
from intent.orgs.services import remove_member as remove_org_member
from intent.projects import services
from intent.projects.models import Project, ProjectMembership
def url(name, project, **kwargs):
return reverse(
f"projects:{name}",
kwargs={"org_slug": project.org.slug, "project_slug": project.slug, **kwargs},
)
def test_create_requires_grant(collaborator, org):
with pytest.raises(PermissionDenied):
services.create_project(
org,
collaborator,
slug="x",
name="X",
feature_prefix="XX",
visibility="private",
open_contribution=False,
)
def test_granted_collaborator_creates_and_owns_project(client, collaborator, org):
OrgMembership.objects.filter(user=collaborator).update(can_create_projects=True)
client.force_login(collaborator)
response = client.post(
reverse("project_create", kwargs={"org_slug": org.slug}),
{"name": "Docs", "slug": "docs", "feature_prefix": "doc", "visibility": "public"},
)
project = Project.objects.get(slug="docs")
assert response.status_code == 302
assert project.feature_prefix == "DOC"
assert project.memberships.get(user=collaborator).role == "owner"
def test_duplicate_prefix_is_a_form_error(client, owner, org, project):
client.force_login(owner)
response = client.post(
reverse("project_create", kwargs={"org_slug": org.slug}),
{"name": "Other", "slug": "other", "feature_prefix": "INT", "visibility": "private"},
)
assert response.status_code == 200
assert b"already exists" in response.content
def test_reserved_slug_rejected(owner, org):
with pytest.raises(ValidationError):
services.create_project(
org,
owner,
slug="members",
name="M",
feature_prefix="MM",
visibility="private",
open_contribution=False,
)
def test_private_project_hidden_from_outsiders(client, make_user, project):
response = client.get(url("home", project))
assert response.status_code == 302 and "/accounts/login/" in response["Location"]
client.force_login(make_user())
assert client.get(url("home", project)).status_code == 404
def test_public_project_visible_to_anonymous(client, project):
project.visibility = "public"
project.save()
assert client.get(url("home", project)).status_code == 200
def test_settings_require_admin(client, editor, owner, project):
client.force_login(editor)
assert client.get(url("settings", project)).status_code == 403
client.force_login(owner)
client.post(url("settings", project), {"name": "Renamed", "visibility": "public"})
project.refresh_from_db()
assert project.name == "Renamed" and project.visibility == "public"
def test_delete_needs_typed_slug_and_cascades_features(client, owner, project):
parent = create_feature(project, owner, title="Parent")
child = create_feature(project, owner, title="Child", parent=parent)
update_feature(
parent, owner, note="Replaced", status=FeatureStatus.SUPERSEDED, superseded_by=child
)
client.force_login(owner)
client.post(url("delete", project), {"confirm": "wrong"})
assert Project.objects.filter(pk=project.pk).exists()
client.post(url("delete", project), {"confirm": "intent"})
assert not Project.objects.filter(pk=project.pk).exists()
assert not Feature.objects.exists()
def test_project_members_must_be_on_the_org_team(owner, make_user, project):
with pytest.raises(ValidationError):
services.add_member(project, owner, user=make_user())
def test_leaving_org_drops_project_roles(editor, org, project):
remove_org_member(org.memberships.get(user=editor), editor)
assert not ProjectMembership.objects.filter(user=editor).exists()
def test_admin_adds_member_via_page(client, owner, collaborator, project):
client.force_login(owner)
client.post(url("members", project), {"user": collaborator.pk, "role": "collaborator"})
assert project.memberships.filter(user=collaborator).exists()
def test_org_page_lists_visible_projects(client, owner, collaborator, org, project):
org_page = reverse("orgs:detail", kwargs={"org_slug": org.slug})
link = f'href="{url("home", project)}"'
client.force_login(owner)
assert link in client.get(org_page).content.decode()
client.force_login(collaborator)
assert link not in client.get(org_page).content.decode()

59
uv.lock generated
View file

@ -394,6 +394,8 @@ dependencies = [
{ name = "django-allauth", extra = ["socialaccount"] }, { name = "django-allauth", extra = ["socialaccount"] },
{ name = "django-storages", extra = ["s3"] }, { name = "django-storages", extra = ["s3"] },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "markdown-it-py" },
{ name = "nh3" },
{ name = "python-ulid" }, { name = "python-ulid" },
{ name = "sentry-sdk", extra = ["django"] }, { name = "sentry-sdk", extra = ["django"] },
{ name = "whitenoise" }, { name = "whitenoise" },
@ -412,6 +414,8 @@ requires-dist = [
{ name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.21" }, { name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.21" },
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" }, { name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
{ name = "gunicorn", specifier = ">=26.2.0" }, { name = "gunicorn", specifier = ">=26.2.0" },
{ name = "markdown-it-py", specifier = ">=4.2.0" },
{ name = "nh3", specifier = ">=0.3.7" },
{ name = "python-ulid", specifier = ">=4.0.1" }, { name = "python-ulid", specifier = ">=4.0.1" },
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.73.0" }, { name = "sentry-sdk", extras = ["django"], specifier = ">=2.73.0" },
{ name = "whitenoise", specifier = ">=6.12.0" }, { name = "whitenoise", specifier = ">=6.12.0" },
@ -433,6 +437,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 }, { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 },
] ]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "nh3"
version = "0.3.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz", hash = "sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848", size = 25662 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/88/b594f0e86856b37e182fb663283da419eea6424972506e640e890885467f/nh3-0.3.7-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba", size = 1471147 },
{ url = "https://files.pythonhosted.org/packages/1e/60/847a21339f095c4d4c655af31fa2d18b174585bcc210709facacc7ce205c/nh3-0.3.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b", size = 820463 },
{ url = "https://files.pythonhosted.org/packages/7b/7f/1a103e00aaf5e59f2dee4c2709aac609bb2d4bb74fddaf0dcfade11ed87b/nh3-0.3.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32", size = 861456 },
{ url = "https://files.pythonhosted.org/packages/d8/4a/e9c436089a0c80b928011ead0efd156aa7639a19b6064ef58dcedcab8369/nh3-0.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa", size = 1023930 },
{ url = "https://files.pythonhosted.org/packages/04/5c/aa1468e3e281e78d2b3b7d762ccba59f681af355e971dbd255d5903f7b86/nh3-0.3.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac", size = 1102614 },
{ url = "https://files.pythonhosted.org/packages/6a/9f/57d186d9d3dd38905dc12dddb3484406cdf6aa0b1ce33639a2d277d4ee1c/nh3-0.3.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102", size = 1059915 },
{ url = "https://files.pythonhosted.org/packages/6b/53/097a5ad0b34b15d67a472ef849165a54209fa5fbd3e639801c6fe439ba28/nh3-0.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a", size = 1047402 },
{ url = "https://files.pythonhosted.org/packages/9a/a7/c57a2c70534418310889a65ccfac3525e62f0bc0a8613225903403755ce7/nh3-0.3.7-cp314-cp314t-win32.whl", hash = "sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946", size = 619895 },
{ url = "https://files.pythonhosted.org/packages/e6/b7/efda1d0a611d940bdfde6893bde1ea6b7b7d48c31273aea48e35b822fd58/nh3-0.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d", size = 633456 },
{ url = "https://files.pythonhosted.org/packages/1d/18/3ab564595cb88196f50d26e163ed0fd2acc731ab26ac615df91981885887/nh3-0.3.7-cp314-cp314t-win_arm64.whl", hash = "sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877", size = 611003 },
{ url = "https://files.pythonhosted.org/packages/94/0d/c257754bf57f829f307aa226bbe136d3a1356b5a0d08324c7b6bd2a8aacd/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5", size = 1493959 },
{ url = "https://files.pythonhosted.org/packages/07/42/a687e7091928806e514f89fa2666f25ec9bfe0a902fc4402b25e51ce408b/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479", size = 859615 },
{ url = "https://files.pythonhosted.org/packages/85/05/b0e6bef633549a23347d5462aa288fcc42381e7918482062ca3cb456242a/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506", size = 839872 },
{ url = "https://files.pythonhosted.org/packages/17/40/2a0921d45b20828708bcb56887e47dcf8cae13818de5bf9a01308d348712/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086", size = 1091325 },
{ url = "https://files.pythonhosted.org/packages/e4/d1/9d70e0e418a48280ec0ddc6c1b08b4b1136ebcc31a1625e57ff5c665fa51/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563", size = 1042482 },
{ url = "https://files.pythonhosted.org/packages/93/a7/02dd159d4e71f98607d8d4249cddb7561e77be1a8e4dec77d76e1b68fc99/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174", size = 946868 },
{ url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42", size = 832161 },
{ url = "https://files.pythonhosted.org/packages/7b/e3/3212c1a5b5745245d7f18885207bbddb34c56075f34dd682bd539aad55cc/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8", size = 849791 },
{ url = "https://files.pythonhosted.org/packages/20/64/9e36594efad6c290de4240d02cb2bd80c339a4ab1c4de66e599ffa6d9d81/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493", size = 875473 },
{ url = "https://files.pythonhosted.org/packages/00/0c/1a8985fd43fea5530c0ac890b6f0b423770ee72f111b70b7a77f2dec243a/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd", size = 1036463 },
{ url = "https://files.pythonhosted.org/packages/b2/5d/891e533b716cf00df76ad0ba6485dcfd14d59a6430a3cc99057c4c04004e/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac", size = 1116029 },
{ url = "https://files.pythonhosted.org/packages/42/e5/ae8c0782fce74fb6fcf7234bb3d4017f37ce181b4f9d29369eab21c50a04/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62", size = 1076589 },
{ url = "https://files.pythonhosted.org/packages/26/a4/c3423351e8d864ad756e85e15f0c01433361f14d34e4ed156482c0518f2a/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af", size = 1058871 },
{ url = "https://files.pythonhosted.org/packages/4b/6a/478f153f1d7c0baaa3d1e8bb5fdcee3a6235f90fe44ea969a9d4e2b8c47a/nh3-0.3.7-cp38-abi3-win32.whl", hash = "sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59", size = 630729 },
{ url = "https://files.pythonhosted.org/packages/b4/b9/34433ccb1f0fe6968dabbb7d4bf5721c6221878ef07832748c06655a6a80/nh3-0.3.7-cp38-abi3-win_amd64.whl", hash = "sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc", size = 644462 },
{ url = "https://files.pythonhosted.org/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867 },
]
[[package]] [[package]]
name = "oauthlib" name = "oauthlib"
version = "4.0.0" version = "4.0.0"