From b9378b83978801ff6f375546ba93a10a60a2e7a1 Mon Sep 17 00:00:00 2001 From: Alec Perkins Date: Tue, 27 Oct 2026 07:58:16 -0400 Subject: [PATCH] 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 --- config/settings/base.py | 2 + config/urls.py | 6 +- docs/plan.md | 8 +- intent/access.py | 73 ++++++- intent/features/__init__.py | 0 intent/features/admin.py | 29 +++ intent/features/apps.py | 6 + intent/features/diff.py | 29 +++ intent/features/forms.py | 57 ++++++ intent/features/migrations/0001_initial.py | 52 +++++ intent/features/migrations/0002_initial.py | 61 ++++++ intent/features/migrations/__init__.py | 0 intent/features/models.py | 90 +++++++++ intent/features/services.py | 116 +++++++++++ intent/features/urls.py | 15 ++ intent/features/views.py | 218 +++++++++++++++++++++ intent/markdown.py | 36 ++++ intent/orgs/services.py | 2 + intent/orgs/views.py | 11 +- intent/projects/__init__.py | 0 intent/projects/admin.py | 17 ++ intent/projects/apps.py | 6 + intent/projects/decorators.py | 37 ++++ intent/projects/forms.py | 64 ++++++ intent/projects/migrations/0001_initial.py | 71 +++++++ intent/projects/migrations/__init__.py | 0 intent/projects/models.py | 74 +++++++ intent/projects/services.py | 71 +++++++ intent/projects/urls.py | 18 ++ intent/projects/views.py | 118 +++++++++++ pyproject.toml | 2 + static/css/intent.css | 30 +++ templates/features/_header.html | 11 ++ templates/features/_tree.html | 11 ++ templates/features/compare.html | 61 ++++++ templates/features/detail.html | 69 +++++++ templates/features/form.html | 32 +++ templates/features/history.html | 59 ++++++ templates/features/list.html | 36 ++++ templates/features/revision.html | 30 +++ templates/orgs/detail.html | 48 +++-- templates/projects/base.html | 29 +++ templates/projects/delete.html | 13 ++ templates/projects/home.html | 20 ++ templates/projects/members.html | 68 +++++++ templates/projects/new.html | 13 ++ templates/projects/settings.html | 22 +++ tests/a11y.py | 67 +++++++ tests/conftest.py | 25 +++ tests/test_a11y.py | 73 +++++++ tests/test_features.py | 148 ++++++++++++++ tests/test_markdown.py | 37 ++++ tests/test_project_access.py | 89 +++++++++ tests/test_projects.py | 127 ++++++++++++ uv.lock | 59 ++++++ 55 files changed, 2446 insertions(+), 20 deletions(-) create mode 100644 intent/features/__init__.py create mode 100644 intent/features/admin.py create mode 100644 intent/features/apps.py create mode 100644 intent/features/diff.py create mode 100644 intent/features/forms.py create mode 100644 intent/features/migrations/0001_initial.py create mode 100644 intent/features/migrations/0002_initial.py create mode 100644 intent/features/migrations/__init__.py create mode 100644 intent/features/models.py create mode 100644 intent/features/services.py create mode 100644 intent/features/urls.py create mode 100644 intent/features/views.py create mode 100644 intent/markdown.py create mode 100644 intent/projects/__init__.py create mode 100644 intent/projects/admin.py create mode 100644 intent/projects/apps.py create mode 100644 intent/projects/decorators.py create mode 100644 intent/projects/forms.py create mode 100644 intent/projects/migrations/0001_initial.py create mode 100644 intent/projects/migrations/__init__.py create mode 100644 intent/projects/models.py create mode 100644 intent/projects/services.py create mode 100644 intent/projects/urls.py create mode 100644 intent/projects/views.py create mode 100644 templates/features/_header.html create mode 100644 templates/features/_tree.html create mode 100644 templates/features/compare.html create mode 100644 templates/features/detail.html create mode 100644 templates/features/form.html create mode 100644 templates/features/history.html create mode 100644 templates/features/list.html create mode 100644 templates/features/revision.html create mode 100644 templates/projects/base.html create mode 100644 templates/projects/delete.html create mode 100644 templates/projects/home.html create mode 100644 templates/projects/members.html create mode 100644 templates/projects/new.html create mode 100644 templates/projects/settings.html create mode 100644 tests/a11y.py create mode 100644 tests/test_a11y.py create mode 100644 tests/test_features.py create mode 100644 tests/test_markdown.py create mode 100644 tests/test_project_access.py create mode 100644 tests/test_projects.py diff --git a/config/settings/base.py b/config/settings/base.py index 23d604e..d12a8e0 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -27,6 +27,8 @@ INSTALLED_APPS = [ "intent.core", "intent.accounts", "intent.orgs", + "intent.projects", + "intent.features", ] MIDDLEWARE = [ diff --git a/config/urls.py b/config/urls.py index c45c799..a1093ea 100644 --- a/config/urls.py +++ b/config/urls.py @@ -3,12 +3,16 @@ from django.urls import include, path from intent.core import views from intent.orgs.urls import invite_urlpatterns +from intent.projects.views import project_create urlpatterns = [ path("", views.home, name="home"), path("admin/", admin.site.urls), path("accounts/", include("allauth.urls")), 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("/new/", project_create, name="project_create"), path("/", include("intent.orgs.urls")), + path("//", include("intent.projects.urls")), + path("//features/", include("intent.features.urls")), ] diff --git a/docs/plan.md b/docs/plan.md index 61a511c..3ec88e2 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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. | | 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. | -| URLs | Organizations live at the root (`//`, later `///`); app routes are reserved slugs. | +| URLs | Organizations live at the root: `//`, `///`, `///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 @@ -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. - Keyboard: all actions reachable by tab; skip link to `
`; 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. +- Until then, unit tests run a structural check (`tests/a11y.py`) on every page: `lang`, one `

`, 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 `

`) so user text never adds a second `

`. ## 3. Data model diff --git a/intent/access.py b/intent/access.py index 73b576c..b217b10 100644 --- a/intent/access.py +++ b/intent/access.py @@ -1,9 +1,13 @@ """What a user may do. Every permission check in views and templates goes through here.""" from dataclasses import dataclass +from typing import TYPE_CHECKING from intent.orgs.models import Organization, OrgMembership, Role +if TYPE_CHECKING: + from intent.projects.models import Project, ProjectMembership + @dataclass(frozen=True) class OrgAccess: @@ -30,8 +34,75 @@ class OrgAccess: 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: - if not user.is_authenticated or user.is_staff: + if not _is_regular_user(user): return OrgAccess(membership=None) membership = OrgMembership.objects.filter(org=org, user=user).first() 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() diff --git a/intent/features/__init__.py b/intent/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/intent/features/admin.py b/intent/features/admin.py new file mode 100644 index 0000000..99c9946 --- /dev/null +++ b/intent/features/admin.py @@ -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 diff --git a/intent/features/apps.py b/intent/features/apps.py new file mode 100644 index 0000000..71aa6a2 --- /dev/null +++ b/intent/features/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FeaturesConfig(AppConfig): + name = "intent.features" + label = "features" diff --git a/intent/features/diff.py b/intent/features/diff.py new file mode 100644 index 0000000..796560e --- /dev/null +++ b/intent/features/diff.py @@ -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 diff --git a/intent/features/forms.py b/intent/features/forms.py new file mode 100644 index 0000000..a232ce3 --- /dev/null +++ b/intent/features/forms.py @@ -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") diff --git a/intent/features/migrations/0001_initial.py b/intent/features/migrations/0001_initial.py new file mode 100644 index 0000000..41bc7c1 --- /dev/null +++ b/intent/features/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/intent/features/migrations/0002_initial.py b/intent/features/migrations/0002_initial.py new file mode 100644 index 0000000..b86338e --- /dev/null +++ b/intent/features/migrations/0002_initial.py @@ -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'), + ), + ] diff --git a/intent/features/migrations/__init__.py b/intent/features/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/intent/features/models.py b/intent/features/models.py new file mode 100644 index 0000000..7095f7a --- /dev/null +++ b/intent/features/models.py @@ -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}" diff --git a/intent/features/services.py b/intent/features/services.py new file mode 100644 index 0000000..e05bea9 --- /dev/null +++ b/intent/features/services.py @@ -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) diff --git a/intent/features/urls.py b/intent/features/urls.py new file mode 100644 index 0000000..2b2bfa8 --- /dev/null +++ b/intent/features/urls.py @@ -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("/", views.feature_detail, name="detail"), + path("/edit/", views.feature_edit, name="edit"), + path("/history/", views.feature_history, name="history"), + path("/history//", views.feature_revision, name="revision"), + path("/compare/", views.feature_compare, name="compare"), +] diff --git a/intent/features/views.py b/intent/features/views.py new file mode 100644 index 0000000..94799f7 --- /dev/null +++ b/intent/features/views.py @@ -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" diff --git a/intent/markdown.py b/intent/markdown.py new file mode 100644 index 0000000..20356f7 --- /dev/null +++ b/intent/markdown.py @@ -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 +

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)) diff --git a/intent/orgs/services.py b/intent/orgs/services.py index d67540f..b85dc5c 100644 --- a/intent/orgs/services.py +++ b/intent/orgs/services.py @@ -147,4 +147,6 @@ def remove_member(membership, by) -> None: membership.refresh_from_db() if _is_last_owner(membership): 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() diff --git a/intent/orgs/views.py b/intent/orgs/views.py index 35b189c..0007d5f 100644 --- a/intent/orgs/views.py +++ b/intent/orgs/views.py @@ -7,7 +7,7 @@ from django.template.loader import render_to_string from django.urls import reverse 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 .forms import InviteForm, MembershipForm @@ -21,9 +21,12 @@ def _error(request, exc: ValidationError): def org_detail(request, org_slug): org = get_object_or_404(Organization, slug=org_slug) - return render( - request, "orgs/detail.html", {"org": org, "access": org_access(request.user, org)} - ) + context = { + "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): diff --git a/intent/projects/__init__.py b/intent/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/intent/projects/admin.py b/intent/projects/admin.py new file mode 100644 index 0000000..f09c1d6 --- /dev/null +++ b/intent/projects/admin.py @@ -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] diff --git a/intent/projects/apps.py b/intent/projects/apps.py new file mode 100644 index 0000000..218d4f0 --- /dev/null +++ b/intent/projects/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + name = "intent.projects" + label = "projects" diff --git a/intent/projects/decorators.py b/intent/projects/decorators.py new file mode 100644 index 0000000..90bb8b3 --- /dev/null +++ b/intent/projects/decorators.py @@ -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 diff --git a/intent/projects/forms.py b/intent/projects/forms.py new file mode 100644 index 0000000..b1f545b --- /dev/null +++ b/intent/projects/forms.py @@ -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) diff --git a/intent/projects/migrations/0001_initial.py b/intent/projects/migrations/0001_initial.py new file mode 100644 index 0000000..f6860e9 --- /dev/null +++ b/intent/projects/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/intent/projects/migrations/__init__.py b/intent/projects/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/intent/projects/models.py b/intent/projects/models.py new file mode 100644 index 0000000..6276ea9 --- /dev/null +++ b/intent/projects/models.py @@ -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 // 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})" diff --git a/intent/projects/services.py b/intent/projects/services.py new file mode 100644 index 0000000..7a1c0cb --- /dev/null +++ b/intent/projects/services.py @@ -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() diff --git a/intent/projects/urls.py b/intent/projects/urls.py new file mode 100644 index 0000000..3192bd9 --- /dev/null +++ b/intent/projects/urls.py @@ -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//", views.project_member_update, name="member_update"), + path( + "members//remove/", + views.project_member_remove, + name="member_remove", + ), +] diff --git a/intent/projects/views.py b/intent/projects/views.py new file mode 100644 index 0000000..1b8c3d7 --- /dev/null +++ b/intent/projects/views.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 8ff68c7..0b77a62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ dependencies = [ "django-allauth[socialaccount]>=65.21", "django-storages[s3]>=1.14.6", "gunicorn>=26.2.0", + "markdown-it-py>=4.2.0", + "nh3>=0.3.7", "python-ulid>=4.0.1", "sentry-sdk[django]>=2.73.0", "whitenoise>=6.12.0", diff --git a/static/css/intent.css b/static/css/intent.css index 750511f..c5b4afb 100644 --- a/static/css/intent.css +++ b/static/css/intent.css @@ -42,10 +42,40 @@ nav form { 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 { border-collapse: collapse; } +.diff tbody + tbody { + border-top: 1px solid; +} + +.diff td:last-child { + white-space: pre-wrap; + font-family: monospace; +} + th, td { padding: 0.25rem 0.75rem 0.25rem 0; diff --git a/templates/features/_header.html b/templates/features/_header.html new file mode 100644 index 0000000..5002864 --- /dev/null +++ b/templates/features/_header.html @@ -0,0 +1,11 @@ +{% comment %}Feature heading shared by the feature, edit, history and compare pages.{% endcomment %} +{% if ancestors %} + +{% endif %} +

{{ feature.key }} {{ heading|default:feature.title }}

diff --git a/templates/features/_tree.html b/templates/features/_tree.html new file mode 100644 index 0000000..f7526ee --- /dev/null +++ b/templates/features/_tree.html @@ -0,0 +1,11 @@ +
    + {% for node in nodes %} + {% with f=node.feature %} +
  • + {{ f.key }} {{ f.title }} + {% if f.status != "active" %}({{ f.get_status_display|lower }}){% endif %} + {% if node.children %}{% include "features/_tree.html" with nodes=node.children %}{% endif %} +
  • + {% endwith %} + {% endfor %} +
diff --git a/templates/features/compare.html b/templates/features/compare.html new file mode 100644 index 0000000..c5ba598 --- /dev/null +++ b/templates/features/compare.html @@ -0,0 +1,61 @@ +{% extends "projects/base.html" %} + +{% block page_title %}{{ feature.key }} revisions {{ old.revision }}–{{ new.revision }}{% endblock %} + +{% block project_content %} +

Back to history

+

Changes to {{ feature.key }} from revision {{ old.revision }} to {{ new.revision }}

+ + {% if old.revision == new.revision %} +

Choose two different revisions to compare.

+ {% else %} + {% if field_changes %} + + + + + + + + + + + {% for name, before, after in field_changes %} + + + + + + {% endfor %} + +
Details
FieldRevision {{ old.revision }}Revision {{ new.revision }}
{{ name }}{{ before }}{{ after }}
+ {% endif %} + +

Description

+ {% if hunks %} + + + + + + + + + + {% for hunk in hunks %} + + {% for line in hunk %} + + + + + + {% endfor %} + + {% endfor %} +
Line-by-line changes to the description
Old lineNew lineText
{{ line.old_number|default_if_none:"" }}{{ line.new_number|default_if_none:"" }}{% if line.kind == "removed" %}Removed: - {{ line.text }}{% elif line.kind == "added" %}Added: + {{ line.text }}{% else %} {{ line.text }}{% endif %}
+ {% else %} +

The description didn't change.

+ {% endif %} + {% endif %} +{% endblock %} diff --git a/templates/features/detail.html b/templates/features/detail.html new file mode 100644 index 0000000..5aa17a4 --- /dev/null +++ b/templates/features/detail.html @@ -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" %} +

Withdrawn. This feature is no longer planned.

+ {% elif feature.status == "superseded" %} +

Superseded by {{ feature.superseded_by.key }} {{ feature.superseded_by.title }}.

+ {% endif %} + +
+
Status
+
{{ feature.get_status_display }}
+
Revision
+
+ {{ feature.current_revision }} + (history) +
+ {% if latest %} +
Last change
+
+ + by {{ latest.author.username|default:"unknown" }}: {{ latest.note }} +
+ {% endif %} +
+ + {% if access.can_edit %} + + {% endif %} + +
+

Description

+
+ {% if feature.description %}{{ description }}{% else %}

No description yet.

{% endif %} +
+
+ + {% if children %} +
+

Sub-features

+
    + {% for child in children %} +
  • + {{ child.key }} {{ child.title }} + {% if child.status != "active" %}({{ child.get_status_display|lower }}){% endif %} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if supersedes %} +
+

Replaces

+ +
+ {% endif %} +{% endblock %} diff --git a/templates/features/form.html b/templates/features/form.html new file mode 100644 index 0000000..d7fa746 --- /dev/null +++ b/templates/features/form.html @@ -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 %} +

Edit {{ feature.key }} {{ feature.title }}

+

Saving creates revision {{ feature.current_revision|add:1 }}.

+ {% else %} +

New feature

+ {% endif %} + + {% if preview %} +
+

Preview

+
{{ preview }}
+

Not saved yet.

+
+ {% elif preview is not None %} +

Nothing to preview: the description is empty.

+ {% endif %} + +
+ {% csrf_token %} + {{ form }} +

+ {# Save comes first so pressing Enter in a text field saves rather than previews. #} + + +

+
+{% endblock %} diff --git a/templates/features/history.html b/templates/features/history.html new file mode 100644 index 0000000..75fb5dd --- /dev/null +++ b/templates/features/history.html @@ -0,0 +1,59 @@ +{% extends "projects/base.html" %} + +{% block page_title %}History of {{ feature.key }}{% endblock %} + +{% block project_content %} +

Back to {{ feature.key }}

+

History of {{ feature.key }} {{ feature.title }}

+ +
+ + + + + + + + + + {% if rows|length > 1 %} + + + {% endif %} + + + + {% for row in rows %} + {% with rev=row.revision %} + + + + + + + {% if rows|length > 1 %} + + + {% endif %} + + {% endwith %} + {% endfor %} + +
{{ rows|length }} revision{{ rows|length|pluralize }}, newest first
RevisionDateAuthorNoteChangedFromTo
+ {% if rev.revision == feature.current_revision %} + {{ rev.revision }} (current) + {% else %} + {{ rev.revision }} + {% endif %} + {{ rev.author.username|default:"unknown" }}{{ rev.note }}{{ row.changes|join:", " }} + + + + + +
+ {% if rows|length > 1 %} +

+ {% endif %} +
+{% endblock %} diff --git a/templates/features/list.html b/templates/features/list.html new file mode 100644 index 0000000..846f99a --- /dev/null +++ b/templates/features/list.html @@ -0,0 +1,36 @@ +{% extends "projects/base.html" %} + +{% block page_title %}All features{% endblock %} + +{% block project_content %} +

All features

+ {% if access.can_edit %} +

New feature

+ {% endif %} + {% if features %} + + + + + + + + + + + + {% for f in features %} + + + + + + + + {% endfor %} + +
FeatureStatusParentRevisionUpdated
{{ f.key }} {{ f.title }}{{ f.get_status_display }}{% if f.parent %}{{ f.parent.key }}{% else %}—{% endif %}{{ f.current_revision }}
+ {% else %} +

No features yet.

+ {% endif %} +{% endblock %} diff --git a/templates/features/revision.html b/templates/features/revision.html new file mode 100644 index 0000000..a7658d0 --- /dev/null +++ b/templates/features/revision.html @@ -0,0 +1,30 @@ +{% extends "projects/base.html" %} + +{% block page_title %}{{ feature.key }} revision {{ snapshot.revision }}{% endblock %} + +{% block project_content %} +

+ Old revision. + You're viewing revision {{ snapshot.revision }} of {{ feature.current_revision }}. + View the current version + or compare with it. +

+

{{ feature.key }} {{ snapshot.title }}

+
+
Status
+
{{ snapshot.get_status_display }}{% if snapshot.superseded_by %}, by {{ snapshot.superseded_by.key }} {{ snapshot.superseded_by.title }}{% endif %}
+
Parent
+
{% if snapshot.parent %}{{ snapshot.parent.key }} {{ snapshot.parent.title }}{% else %}None (top level){% endif %}
+
Change
+
+ + by {{ snapshot.author.username|default:"unknown" }}: {{ snapshot.note }} +
+
+
+

Description

+
+ {% if snapshot.description %}{{ description }}{% else %}

No description.

{% endif %} +
+
+{% endblock %} diff --git a/templates/orgs/detail.html b/templates/orgs/detail.html index c2b1748..8fac986 100644 --- a/templates/orgs/detail.html +++ b/templates/orgs/detail.html @@ -5,19 +5,41 @@ {% block content %}

{{ org.name }}

- {% if access.is_member %} -

You're {% if access.is_owner %}an owner{% else %}a collaborator{% endif %} of this organization.

- +
+

Projects

+ {% if projects %} +
    + {% for project in projects %} +
  • + {{ project.name }} + {% if project.visibility != "public" %}({{ project.visibility }}){% endif %} +
  • + {% endfor %} +
+ {% else %} +

No projects{% if not access.is_member %} you can see{% endif %} yet.

+ {% endif %} + {% if access.can_create_projects %} +

New project

+ {% endif %} +
-
- Leave {{ org.name }} -
- {% csrf_token %} -

You'll need a new invitation to rejoin.

-

-
-
+ {% if access.is_member %} +
+

Team

+

You're {% if access.is_owner %}an owner{% else %}a collaborator{% endif %} of this organization.

+ + +
+ Leave {{ org.name }} +
+ {% csrf_token %} +

You'll need a new invitation to rejoin, and you'll lose your project roles.

+

+
+
+
{% endif %} {% endblock %} diff --git a/templates/projects/base.html b/templates/projects/base.html new file mode 100644 index 0000000..75cff95 --- /dev/null +++ b/templates/projects/base.html @@ -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 %} + + + {% block project_content %}{% endblock %} +{% endblock %} diff --git a/templates/projects/delete.html b/templates/projects/delete.html new file mode 100644 index 0000000..4957d5a --- /dev/null +++ b/templates/projects/delete.html @@ -0,0 +1,13 @@ +{% extends "projects/base.html" %} + +{% block page_title %}Delete{% endblock %} + +{% block project_content %} +

Delete {{ project.name }}

+

This permanently deletes {{ project.features.count }} feature{{ project.features.count|pluralize }} and all of their history. It can't be undone.

+
+ {% csrf_token %} + {{ form }} +

+
+{% endblock %} diff --git a/templates/projects/home.html b/templates/projects/home.html new file mode 100644 index 0000000..83bd9ca --- /dev/null +++ b/templates/projects/home.html @@ -0,0 +1,20 @@ +{% extends "projects/base.html" %} + +{% block title %}{{ project.name }} · Intent{% endblock %} + +{% block project_content %} +

{{ project.name }}

+

{{ project.get_visibility_display }}{% if project.open_contribution %}. Open to contributions from signed-in users.{% endif %}

+ +
+

Features

+ {% if access.can_edit %} +

New feature

+ {% endif %} + {% if tree %} + {% include "features/_tree.html" with nodes=tree %} + {% else %} +

No features yet.

+ {% endif %} +
+{% endblock %} diff --git a/templates/projects/members.html b/templates/projects/members.html new file mode 100644 index 0000000..b340204 --- /dev/null +++ b/templates/projects/members.html @@ -0,0 +1,68 @@ +{% extends "projects/base.html" %} + +{% block page_title %}Members{% endblock %} + +{% block project_content %} +

Members

+

Owners of {{ project.org.name }} can also administer this project.

+ + {% if access.can_admin %} + {% for m in memberships %} +
{% csrf_token %}
+
{% csrf_token %}
+ {% endfor %} + {% endif %} + + + + + + + + {% if access.can_admin %}{% endif %} + + + + {% for m in memberships %} + + + {% if access.can_admin %} + + + {% else %} + + {% endif %} + + {% endfor %} + +
{{ memberships|length }} member{{ memberships|length|pluralize }}
MemberRoleActions
{{ m.user.username }}{% if m.user == user %} (you){% endif %} + + + + + + {{ m.get_role_display }}
+ + {% if access.can_admin %} +
+

Add a team member

+ {% if form.fields.user.queryset.exists %} +
+ {% csrf_token %} + {{ form }} +

+
+ {% else %} +

Everyone on the {{ project.org.name }} team is already on this project. Invite people to the team first.

+ {% endif %} +
+ {% elif access.membership %} +
+ {% csrf_token %} +

+
+ {% endif %} +{% endblock %} diff --git a/templates/projects/new.html b/templates/projects/new.html new file mode 100644 index 0000000..c6dc61f --- /dev/null +++ b/templates/projects/new.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}New project · {{ org.name }} · Intent{% endblock %} + +{% block content %} +

{{ org.name }}

+

New project

+
+ {% csrf_token %} + {{ form }} +

+
+{% endblock %} diff --git a/templates/projects/settings.html b/templates/projects/settings.html new file mode 100644 index 0000000..b6eecf6 --- /dev/null +++ b/templates/projects/settings.html @@ -0,0 +1,22 @@ +{% extends "projects/base.html" %} + +{% block page_title %}Settings{% endblock %} + +{% block project_content %} +

Settings

+
+
URL name
{{ project.slug }}
+
Feature ID prefix
{{ project.feature_prefix }}
+
+
+ {% csrf_token %} + {{ form }} +

+
+ +
+

Delete project

+

Deleting removes every feature and its history. It can't be undone.

+

Delete {{ project.name }}…

+
+{% endblock %} diff --git a/tests/a11y.py b/tests/a11y.py new file mode 100644 index 0000000..c27bce9 --- /dev/null +++ b/tests/a11y.py @@ -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(" has no lang") + if page.headings.count(1) != 1: + problems.append(f"expected one

, 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} without alt") + assert not problems, "\n".join(problems) diff --git a/tests/conftest.py b/tests/conftest.py index 6ff4ada..4eacf1c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,8 @@ from django.contrib.auth import get_user_model from intent.orgs.models import OrgMembership, Role from intent.orgs.services import create_org +from intent.projects.models import ProjectMembership, Visibility +from intent.projects.services import create_project _counter = itertools.count() @@ -39,3 +41,26 @@ def collaborator(make_user, org): user = make_user("cole") OrgMembership.objects.create(org=org, user=user, role=Role.COLLABORATOR) 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 diff --git a/tests/test_a11y.py b/tests/test_a11y.py new file mode 100644 index 0000000..ff037d5 --- /dev/null +++ b/tests/test_a11y.py @@ -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", + [ + ("

a

", "no lang"), + ('

a

', "one

"), + ('

a

b

', "skips"), + ('

a

', "duplicate ids"), + ('

a

', "without a label"), + ('

a

', "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()) diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..a3968c1 --- /dev/null +++ b/tests/test_features.py @@ -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"' in html + assert '
  • ' in html + + +def test_headings_shift_down_one_level(): + html = render("# Top\n\n###### Deepest\n") + assert "alert(1)\n\n[x](javascript:alert(1)) ") + assert "old" 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) == [] diff --git a/tests/test_project_access.py b/tests/test_project_access.py new file mode 100644 index 0000000..b2905c0 --- /dev/null +++ b/tests/test_project_access.py @@ -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)) == [] diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..87dc4e5 --- /dev/null +++ b/tests/test_projects.py @@ -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() diff --git a/uv.lock b/uv.lock index b14b29c..14d353e 100644 --- a/uv.lock +++ b/uv.lock @@ -394,6 +394,8 @@ dependencies = [ { name = "django-allauth", extra = ["socialaccount"] }, { name = "django-storages", extra = ["s3"] }, { name = "gunicorn" }, + { name = "markdown-it-py" }, + { name = "nh3" }, { name = "python-ulid" }, { name = "sentry-sdk", extra = ["django"] }, { name = "whitenoise" }, @@ -412,6 +414,8 @@ requires-dist = [ { name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.21" }, { name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" }, { 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 = "sentry-sdk", extras = ["django"], specifier = ">=2.73.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 }, ] +[[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]] name = "oauthlib" version = "4.0.0"