Add projects and features with revision history (Phase 2)
Projects under organizations with fixed URL name and feature ID prefix, three visibility levels, open contribution, per-project roles, settings and typed-confirmation delete. Hidden projects 404. Leaving an org drops its project roles. Project access rules live in intent/access.py alongside org access. Features numbered per project (INT-12) in a tree. Every change is a revision with a required note; history, old revisions, and side-by-side field and line diffs between any two. Cycles and superseded-without-replacement are rejected. Markdown renders safely with source-line attributes for Phase 3 line comments and headings shifted below the page h1. Server-side preview works without JS. Structural accessibility check runs on every page in tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1af217f6fb
commit
b9378b8397
55 changed files with 2446 additions and 20 deletions
|
|
@ -27,6 +27,8 @@ INSTALLED_APPS = [
|
||||||
"intent.core",
|
"intent.core",
|
||||||
"intent.accounts",
|
"intent.accounts",
|
||||||
"intent.orgs",
|
"intent.orgs",
|
||||||
|
"intent.projects",
|
||||||
|
"intent.features",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,16 @@ from django.urls import include, path
|
||||||
|
|
||||||
from intent.core import views
|
from intent.core import views
|
||||||
from intent.orgs.urls import invite_urlpatterns
|
from intent.orgs.urls import invite_urlpatterns
|
||||||
|
from intent.projects.views import project_create
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.home, name="home"),
|
path("", views.home, name="home"),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
path("invites/", include(invite_urlpatterns)),
|
path("invites/", include(invite_urlpatterns)),
|
||||||
# Last: organizations own the rest of the URL space (see RESERVED_SLUGS).
|
# Organizations own the rest of the URL space (see RESERVED_SLUGS in orgs and projects).
|
||||||
|
path("<slug:org_slug>/new/", project_create, name="project_create"),
|
||||||
path("<slug:org_slug>/", include("intent.orgs.urls")),
|
path("<slug:org_slug>/", include("intent.orgs.urls")),
|
||||||
|
path("<slug:org_slug>/<slug:project_slug>/", include("intent.projects.urls")),
|
||||||
|
path("<slug:org_slug>/<slug:project_slug>/features/", include("intent.features.urls")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,11 @@ Decisions made in the concept are not repeated. These fill the gaps.
|
||||||
| Deleted users | Content remains, attributed to a "ghost" user. |
|
| Deleted users | Content remains, attributed to a "ghost" user. |
|
||||||
| Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. |
|
| Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. |
|
||||||
| Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and whoever holds it may accept with any account. Email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
|
| Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and whoever holds it may accept with any account. Email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
|
||||||
| URLs | Organizations live at the root (`/<org>/`, later `/<org>/<project>/`); app routes are reserved slugs. |
|
| URLs | Organizations live at the root: `/<org>/`, `/<org>/<project>/`, `/<org>/<project>/features/INT-12/`. App routes are reserved slugs at both levels. |
|
||||||
|
| Project slug and prefix | Fixed after creation, since they appear in URLs and feature IDs. Name, visibility and open contribution stay editable. |
|
||||||
|
| Hidden projects | A project the user can't see returns 404 (not 403), so private projects don't reveal they exist. Signed-out visitors are sent to sign in instead. |
|
||||||
|
| Project owners | No last-owner guard: org owners can always administer every project. Leaving the org removes a user's project roles in it. |
|
||||||
|
| Feature editing | One form edits title, description, parent, status and replacement, with a required change note. Saving with no changes is refused. A "Preview description" button renders the Markdown server-side, so preview works without JS. Django admin shows features read-only so every change goes through revisions. |
|
||||||
|
|
||||||
### Stack
|
### Stack
|
||||||
|
|
||||||
|
|
@ -65,6 +69,8 @@ The app should look close to unstyled HTML. Guiding rule: if the stylesheet fail
|
||||||
- Every HTMX interaction has a non-JS fallback: forms post normally, links navigate. HTMX enhances, never gates.
|
- Every HTMX interaction has a non-JS fallback: forms post normally, links navigate. HTMX enhances, never gates.
|
||||||
- Keyboard: all actions reachable by tab; skip link to `<main>`; visible focus. Image pins get a list alternative (threads listed below the image with coordinates). Line threads have a per-line "comment" button, not hover-only.
|
- Keyboard: all actions reachable by tab; skip link to `<main>`; visible focus. Image pins get a list alternative (threads listed below the image with coordinates). Line threads have a per-line "comment" button, not hover-only.
|
||||||
- Contrast and semantics checked with axe in the Playwright smoke tests.
|
- Contrast and semantics checked with axe in the Playwright smoke tests.
|
||||||
|
- Until then, unit tests run a structural check (`tests/a11y.py`) on every page: `lang`, one `<h1>`, no skipped heading levels, unique ids, labelled form controls, `alt` on images. New pages get added to `tests/test_a11y.py`.
|
||||||
|
- Markdown headings render one level down (`#` becomes `<h2>`) so user text never adds a second `<h1>`.
|
||||||
|
|
||||||
## 3. Data model
|
## 3. Data model
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
"""What a user may do. Every permission check in views and templates goes through here."""
|
"""What a user may do. Every permission check in views and templates goes through here."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from intent.orgs.models import Organization, OrgMembership, Role
|
from intent.orgs.models import Organization, OrgMembership, Role
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from intent.projects.models import Project, ProjectMembership
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class OrgAccess:
|
class OrgAccess:
|
||||||
|
|
@ -30,8 +34,75 @@ class OrgAccess:
|
||||||
return self.is_owner or (self.is_member and self.membership.can_create_projects)
|
return self.is_owner or (self.is_member and self.membership.can_create_projects)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_regular_user(user) -> bool:
|
||||||
|
# Sysadmins (staff) cannot act within orgs or projects; they get anonymous access.
|
||||||
|
return user.is_authenticated and not user.is_staff
|
||||||
|
|
||||||
|
|
||||||
def org_access(user, org: Organization) -> OrgAccess:
|
def org_access(user, org: Organization) -> OrgAccess:
|
||||||
if not user.is_authenticated or user.is_staff:
|
if not _is_regular_user(user):
|
||||||
return OrgAccess(membership=None)
|
return OrgAccess(membership=None)
|
||||||
membership = OrgMembership.objects.filter(org=org, user=user).first()
|
membership = OrgMembership.objects.filter(org=org, user=user).first()
|
||||||
return OrgAccess(membership=membership)
|
return OrgAccess(membership=membership)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProjectAccess:
|
||||||
|
membership: "ProjectMembership | None"
|
||||||
|
is_org_owner: bool
|
||||||
|
can_view: bool
|
||||||
|
# Comment and open issues.
|
||||||
|
can_contribute: bool
|
||||||
|
# Create and edit features, decide issues, manage implementations.
|
||||||
|
can_edit: bool
|
||||||
|
# Settings, project members, deletion.
|
||||||
|
can_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
def project_access(user, project: "Project") -> ProjectAccess:
|
||||||
|
from intent.projects.models import ProjectMembership, Visibility
|
||||||
|
|
||||||
|
signed_in = _is_regular_user(user)
|
||||||
|
membership = None
|
||||||
|
is_org_owner = False
|
||||||
|
if signed_in:
|
||||||
|
membership = ProjectMembership.objects.filter(project=project, user=user).first()
|
||||||
|
is_org_owner = OrgMembership.objects.filter(
|
||||||
|
org_id=project.org_id, user=user, role=Role.OWNER
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
can_admin = is_org_owner or (membership is not None and membership.role == Role.OWNER)
|
||||||
|
can_edit = can_admin or membership is not None
|
||||||
|
can_view = (
|
||||||
|
can_edit
|
||||||
|
or {
|
||||||
|
Visibility.PUBLIC: True,
|
||||||
|
Visibility.AUTHENTICATED: signed_in,
|
||||||
|
Visibility.PRIVATE: False,
|
||||||
|
}[project.visibility]
|
||||||
|
)
|
||||||
|
can_contribute = can_edit or (signed_in and can_view and project.open_contribution)
|
||||||
|
return ProjectAccess(
|
||||||
|
membership=membership,
|
||||||
|
is_org_owner=is_org_owner,
|
||||||
|
can_view=can_view,
|
||||||
|
can_contribute=can_contribute,
|
||||||
|
can_edit=can_edit,
|
||||||
|
can_admin=can_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def visible_projects(user, org: Organization):
|
||||||
|
"""Projects in an org the user may see, for listings."""
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from intent.projects.models import Project, Visibility
|
||||||
|
|
||||||
|
projects = Project.objects.filter(org=org)
|
||||||
|
if not _is_regular_user(user):
|
||||||
|
return projects.filter(visibility=Visibility.PUBLIC)
|
||||||
|
if OrgMembership.objects.filter(org=org, user=user, role=Role.OWNER).exists():
|
||||||
|
return projects
|
||||||
|
return projects.filter(
|
||||||
|
Q(visibility__in=[Visibility.PUBLIC, Visibility.AUTHENTICATED]) | Q(memberships__user=user)
|
||||||
|
).distinct()
|
||||||
|
|
|
||||||
0
intent/features/__init__.py
Normal file
0
intent/features/__init__.py
Normal file
29
intent/features/admin.py
Normal file
29
intent/features/admin.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Feature, FeatureRevision
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Feature)
|
||||||
|
class FeatureAdmin(admin.ModelAdmin):
|
||||||
|
"""Read-only: edits must go through the app so they're recorded as revisions."""
|
||||||
|
|
||||||
|
list_display = ["__str__", "project", "status", "current_revision"]
|
||||||
|
list_filter = ["status"]
|
||||||
|
search_fields = ["title"]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FeatureRevision)
|
||||||
|
class FeatureRevisionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["__str__", "author", "created_at", "note"]
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
6
intent/features/apps.py
Normal file
6
intent/features/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FeaturesConfig(AppConfig):
|
||||||
|
name = "intent.features"
|
||||||
|
label = "features"
|
||||||
29
intent/features/diff.py
Normal file
29
intent/features/diff.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DiffLine:
|
||||||
|
kind: str # "same", "added" or "removed"
|
||||||
|
old_number: int | None
|
||||||
|
new_number: int | None
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
def line_diff(old: str, new: str, context: int = 3) -> list[list[DiffLine]]:
|
||||||
|
"""Line-by-line diff as hunks, each with up to `context` unchanged lines around changes."""
|
||||||
|
a, b = old.splitlines(), new.splitlines()
|
||||||
|
hunks = []
|
||||||
|
for group in SequenceMatcher(None, a, b, autojunk=False).get_grouped_opcodes(context):
|
||||||
|
hunk = []
|
||||||
|
for tag, i1, i2, j1, j2 in group:
|
||||||
|
if tag == "equal":
|
||||||
|
hunk += [
|
||||||
|
DiffLine("same", i + 1, j + 1, a[i])
|
||||||
|
for i, j in zip(range(i1, i2), range(j1, j2), strict=True)
|
||||||
|
]
|
||||||
|
continue
|
||||||
|
hunk += [DiffLine("removed", i + 1, None, a[i]) for i in range(i1, i2)]
|
||||||
|
hunk += [DiffLine("added", None, j + 1, b[j]) for j in range(j1, j2)]
|
||||||
|
hunks.append(hunk)
|
||||||
|
return hunks
|
||||||
57
intent/features/forms.py
Normal file
57
intent/features/forms.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from .models import Feature, FeatureStatus
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureChoiceField(forms.ModelChoiceField):
|
||||||
|
def label_from_instance(self, obj):
|
||||||
|
return f"{obj.key} {obj.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureForm(forms.Form):
|
||||||
|
title = forms.CharField(max_length=200)
|
||||||
|
description = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 20, "cols": 80}),
|
||||||
|
help_text="Markdown.",
|
||||||
|
)
|
||||||
|
parent = FeatureChoiceField(
|
||||||
|
queryset=Feature.objects.none(), required=False, empty_label="None (top level)"
|
||||||
|
)
|
||||||
|
status = forms.ChoiceField(choices=FeatureStatus.choices)
|
||||||
|
superseded_by = FeatureChoiceField(
|
||||||
|
queryset=Feature.objects.none(),
|
||||||
|
required=False,
|
||||||
|
empty_label="—",
|
||||||
|
help_text="Required when the status is Superseded.",
|
||||||
|
)
|
||||||
|
note = forms.CharField(
|
||||||
|
label="Change note",
|
||||||
|
max_length=500,
|
||||||
|
help_text="Why is this changing? Kept in the feature's history.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, project, feature=None, exclude_ids=(), **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
others = project.features.select_related("project")
|
||||||
|
if feature:
|
||||||
|
others = others.exclude(pk=feature.pk)
|
||||||
|
self.fields["parent"].queryset = others.exclude(pk__in=exclude_ids)
|
||||||
|
self.fields["superseded_by"].queryset = others
|
||||||
|
if feature is None:
|
||||||
|
# New features start active; the note defaults to "Created."
|
||||||
|
del self.fields["status"]
|
||||||
|
del self.fields["superseded_by"]
|
||||||
|
self.fields["note"].required = False
|
||||||
|
self.fields["note"].help_text = "Optional. Kept in the feature's history."
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
if cleaned.get("status") == FeatureStatus.SUPERSEDED and not cleaned.get("superseded_by"):
|
||||||
|
self.add_error("superseded_by", "Choose the feature that supersedes this one.")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class CompareForm(forms.Form):
|
||||||
|
old = forms.IntegerField(min_value=1, label="From revision")
|
||||||
|
new = forms.IntegerField(min_value=1, label="To revision")
|
||||||
52
intent/features/migrations/0001_initial.py
Normal file
52
intent/features/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Generated by Django 5.2.18 on 2026-10-27 11:33
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import intent.core.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FeatureRevision',
|
||||||
|
fields=[
|
||||||
|
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('revision', models.PositiveIntegerField()),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('status', models.CharField(choices=[('active', 'Active'), ('withdrawn', 'Withdrawn'), ('superseded', 'Superseded')], max_length=20)),
|
||||||
|
('note', models.CharField(max_length=500)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['feature', 'revision'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Feature',
|
||||||
|
fields=[
|
||||||
|
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('number', models.PositiveIntegerField()),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('status', models.CharField(choices=[('active', 'Active'), ('withdrawn', 'Withdrawn'), ('superseded', 'Superseded')], default='active', max_length=20)),
|
||||||
|
('current_revision', models.PositiveIntegerField(default=1)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='children', to='features.feature')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['number'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
61
intent/features/migrations/0002_initial.py
Normal file
61
intent/features/migrations/0002_initial.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Generated by Django 5.2.18 on 2026-10-27 11:33
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('features', '0001_initial'),
|
||||||
|
('projects', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='feature',
|
||||||
|
name='project',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='projects.project'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='feature',
|
||||||
|
name='superseded_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='supersedes', to='features.feature'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='featurerevision',
|
||||||
|
name='author',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='featurerevision',
|
||||||
|
name='feature',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='features.feature'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='featurerevision',
|
||||||
|
name='parent',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='features.feature'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='featurerevision',
|
||||||
|
name='superseded_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='features.feature'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='feature',
|
||||||
|
constraint=models.UniqueConstraint(fields=('project', 'number'), name='feature_unique_number'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='feature',
|
||||||
|
constraint=models.CheckConstraint(condition=models.Q(models.Q(('status', 'superseded'), ('superseded_by__isnull', False)), models.Q(models.Q(('status', 'superseded'), _negated=True), models.Q(('superseded_by__isnull', False), _negated=True)), _connector='OR'), name='feature_superseded_by'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='featurerevision',
|
||||||
|
constraint=models.UniqueConstraint(fields=('feature', 'revision'), name='revision_unique'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
intent/features/migrations/__init__.py
Normal file
0
intent/features/migrations/__init__.py
Normal file
90
intent/features/models.py
Normal file
90
intent/features/models.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from intent.core.models import BaseModel
|
||||||
|
from intent.projects.models import Project
|
||||||
|
|
||||||
|
FEATURE_KEY_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]{1,7})-([1-9][0-9]*)$")
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureStatus(models.TextChoices):
|
||||||
|
ACTIVE = "active", "Active"
|
||||||
|
WITHDRAWN = "withdrawn", "Withdrawn"
|
||||||
|
SUPERSEDED = "superseded", "Superseded"
|
||||||
|
|
||||||
|
|
||||||
|
def _superseded_needs_replacement():
|
||||||
|
is_superseded = Q(status=FeatureStatus.SUPERSEDED)
|
||||||
|
has_replacement = Q(superseded_by__isnull=False)
|
||||||
|
return (is_superseded & has_replacement) | (~is_superseded & ~has_replacement)
|
||||||
|
|
||||||
|
|
||||||
|
class Feature(BaseModel):
|
||||||
|
"""Current state of a feature. Every change is also recorded as a FeatureRevision."""
|
||||||
|
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="features")
|
||||||
|
number = models.PositiveIntegerField()
|
||||||
|
# RESTRICT (not PROTECT) so deleting a whole project can still cascade through the tree.
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
"self", null=True, blank=True, on_delete=models.RESTRICT, related_name="children"
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20, choices=FeatureStatus.choices, default=FeatureStatus.ACTIVE
|
||||||
|
)
|
||||||
|
superseded_by = models.ForeignKey(
|
||||||
|
"self", null=True, blank=True, on_delete=models.RESTRICT, related_name="supersedes"
|
||||||
|
)
|
||||||
|
current_revision = models.PositiveIntegerField(default=1)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["project", "number"], name="feature_unique_number"),
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=_superseded_needs_replacement(), name="feature_superseded_by"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
ordering = ["number"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.key} {self.title}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return f"{self.project.feature_prefix}-{self.number}"
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureRevision(BaseModel):
|
||||||
|
"""An immutable snapshot of a feature after one change, with the note explaining why."""
|
||||||
|
|
||||||
|
feature = models.ForeignKey(Feature, on_delete=models.CASCADE, related_name="revisions")
|
||||||
|
revision = models.PositiveIntegerField()
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
Feature, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
status = models.CharField(max_length=20, choices=FeatureStatus.choices)
|
||||||
|
superseded_by = models.ForeignKey(
|
||||||
|
Feature, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
note = models.CharField(max_length=500)
|
||||||
|
author = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["feature", "revision"], name="revision_unique"),
|
||||||
|
]
|
||||||
|
ordering = ["feature", "revision"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.feature.key} r{self.revision}"
|
||||||
116
intent/features/services.py
Normal file
116
intent/features/services.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Feature rules. Every change goes through here so it is recorded as a revision."""
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import F
|
||||||
|
|
||||||
|
from intent.access import project_access
|
||||||
|
from intent.projects.models import Project
|
||||||
|
|
||||||
|
from .models import Feature, FeatureRevision, FeatureStatus
|
||||||
|
|
||||||
|
TRACKED_FIELDS = ["title", "description", "parent_id", "status", "superseded_by_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_edit(by, project):
|
||||||
|
if not project_access(by, project).can_edit:
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
def build_tree(features) -> list[dict]:
|
||||||
|
"""Nest features under their parents: [{"feature": f, "children": [...]}, ...]."""
|
||||||
|
nodes = {f.pk: {"feature": f, "children": []} for f in features}
|
||||||
|
roots = []
|
||||||
|
for f in features:
|
||||||
|
siblings = nodes[f.parent_id]["children"] if f.parent_id in nodes else roots
|
||||||
|
siblings.append(nodes[f.pk])
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def descendant_ids(feature) -> set:
|
||||||
|
"""IDs of every feature below this one in its project's tree."""
|
||||||
|
children = {}
|
||||||
|
for pk, parent_id in Feature.objects.filter(project_id=feature.project_id).values_list(
|
||||||
|
"pk", "parent_id"
|
||||||
|
):
|
||||||
|
children.setdefault(parent_id, []).append(pk)
|
||||||
|
found, stack = set(), [feature.pk]
|
||||||
|
while stack:
|
||||||
|
for child in children.get(stack.pop(), []):
|
||||||
|
if child not in found:
|
||||||
|
found.add(child)
|
||||||
|
stack.append(child)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(feature) -> None:
|
||||||
|
if feature.parent_id:
|
||||||
|
if feature.parent.project_id != feature.project_id:
|
||||||
|
raise ValidationError("The parent must be in the same project.")
|
||||||
|
if feature.pk and (
|
||||||
|
feature.parent_id == feature.pk or feature.parent_id in descendant_ids(feature)
|
||||||
|
):
|
||||||
|
raise ValidationError("A feature can't be placed under itself or its own children.")
|
||||||
|
if feature.status == FeatureStatus.SUPERSEDED:
|
||||||
|
if not feature.superseded_by_id:
|
||||||
|
raise ValidationError("Choose the feature that supersedes this one.")
|
||||||
|
if feature.superseded_by.project_id != feature.project_id:
|
||||||
|
raise ValidationError("The replacement must be in the same project.")
|
||||||
|
if feature.superseded_by_id == feature.pk:
|
||||||
|
raise ValidationError("A feature can't supersede itself.")
|
||||||
|
else:
|
||||||
|
feature.superseded_by = None
|
||||||
|
|
||||||
|
|
||||||
|
def _record(feature, by, note) -> FeatureRevision:
|
||||||
|
return FeatureRevision.objects.create(
|
||||||
|
feature=feature,
|
||||||
|
revision=feature.current_revision,
|
||||||
|
title=feature.title,
|
||||||
|
description=feature.description,
|
||||||
|
parent_id=feature.parent_id,
|
||||||
|
status=feature.status,
|
||||||
|
superseded_by_id=feature.superseded_by_id,
|
||||||
|
note=note,
|
||||||
|
author=by,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_feature(project, by, *, title, description="", parent=None, note="") -> Feature:
|
||||||
|
_require_edit(by, project)
|
||||||
|
with transaction.atomic():
|
||||||
|
Project.objects.filter(pk=project.pk).update(
|
||||||
|
next_feature_number=F("next_feature_number") + 1
|
||||||
|
)
|
||||||
|
project.refresh_from_db(fields=["next_feature_number"])
|
||||||
|
feature = Feature(
|
||||||
|
project=project,
|
||||||
|
number=project.next_feature_number - 1,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
parent=parent,
|
||||||
|
created_by=by,
|
||||||
|
)
|
||||||
|
_validate(feature)
|
||||||
|
feature.save()
|
||||||
|
_record(feature, by, note.strip() or "Created.")
|
||||||
|
return feature
|
||||||
|
|
||||||
|
|
||||||
|
def update_feature(feature, by, *, note, **changes) -> FeatureRevision:
|
||||||
|
"""Apply changes to tracked fields and record a revision. `note` explains why."""
|
||||||
|
_require_edit(by, feature.project)
|
||||||
|
note = note.strip()
|
||||||
|
if not note:
|
||||||
|
raise ValidationError("Explain the change in the change note.")
|
||||||
|
with transaction.atomic():
|
||||||
|
feature.refresh_from_db()
|
||||||
|
before = {f: getattr(feature, f) for f in TRACKED_FIELDS}
|
||||||
|
for name, value in changes.items():
|
||||||
|
setattr(feature, name, value)
|
||||||
|
_validate(feature)
|
||||||
|
if {f: getattr(feature, f) for f in TRACKED_FIELDS} == before:
|
||||||
|
raise ValidationError("Nothing changed.")
|
||||||
|
feature.current_revision += 1
|
||||||
|
feature.save()
|
||||||
|
return _record(feature, by, note)
|
||||||
15
intent/features/urls.py
Normal file
15
intent/features/urls.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "features"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.feature_list, name="list"),
|
||||||
|
path("new/", views.feature_create, name="create"),
|
||||||
|
path("<str:feature_key>/", views.feature_detail, name="detail"),
|
||||||
|
path("<str:feature_key>/edit/", views.feature_edit, name="edit"),
|
||||||
|
path("<str:feature_key>/history/", views.feature_history, name="history"),
|
||||||
|
path("<str:feature_key>/history/<int:revision>/", views.feature_revision, name="revision"),
|
||||||
|
path("<str:feature_key>/compare/", views.feature_compare, name="compare"),
|
||||||
|
]
|
||||||
218
intent/features/views.py
Normal file
218
intent/features/views.py
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.http import Http404
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
|
||||||
|
from intent.markdown import render as render_markdown
|
||||||
|
from intent.projects.decorators import project_view
|
||||||
|
|
||||||
|
from . import services
|
||||||
|
from .diff import line_diff
|
||||||
|
from .forms import CompareForm, FeatureForm
|
||||||
|
from .models import FEATURE_KEY_RE, Feature
|
||||||
|
|
||||||
|
|
||||||
|
def get_feature(project, feature_key) -> Feature:
|
||||||
|
match = FEATURE_KEY_RE.match(feature_key)
|
||||||
|
if not match or match.group(1).upper() != project.feature_prefix:
|
||||||
|
raise Http404
|
||||||
|
return get_object_or_404(
|
||||||
|
Feature.objects.select_related("project__org", "parent", "superseded_by", "created_by"),
|
||||||
|
project=project,
|
||||||
|
number=int(match.group(2)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _feature_redirect(feature, name="detail"):
|
||||||
|
return redirect(
|
||||||
|
f"features:{name}",
|
||||||
|
org_slug=feature.project.org.slug,
|
||||||
|
project_slug=feature.project.slug,
|
||||||
|
feature_key=feature.key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
def feature_list(request, project, access):
|
||||||
|
features = project.features.select_related("project", "parent__project")
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"features/list.html",
|
||||||
|
{"project": project, "access": access, "features": features},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_edit")
|
||||||
|
def feature_create(request, project, access):
|
||||||
|
initial = {}
|
||||||
|
if parent_key := request.GET.get("parent"):
|
||||||
|
initial["parent"] = get_feature(project, parent_key)
|
||||||
|
form = FeatureForm(request.POST or None, project=project, initial=initial)
|
||||||
|
preview = None
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
data = form.cleaned_data
|
||||||
|
if "preview" in request.POST:
|
||||||
|
preview = render_markdown(data["description"])
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
feature = services.create_feature(
|
||||||
|
project,
|
||||||
|
request.user,
|
||||||
|
title=data["title"],
|
||||||
|
description=data["description"],
|
||||||
|
parent=data["parent"],
|
||||||
|
note=data["note"],
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
form.add_error(None, exc)
|
||||||
|
else:
|
||||||
|
messages.success(request, f"Created {feature.key}.")
|
||||||
|
return _feature_redirect(feature)
|
||||||
|
context = {"project": project, "access": access, "form": form, "preview": preview}
|
||||||
|
return render(request, "features/form.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
def feature_detail(request, project, access, feature_key):
|
||||||
|
feature = get_feature(project, feature_key)
|
||||||
|
latest = feature.revisions.select_related("author").last()
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"access": access,
|
||||||
|
"feature": feature,
|
||||||
|
"latest": latest,
|
||||||
|
"ancestors": _ancestors(feature),
|
||||||
|
"children": feature.children.select_related("project"),
|
||||||
|
"supersedes": feature.supersedes.select_related("project"),
|
||||||
|
"description": render_markdown(feature.description),
|
||||||
|
}
|
||||||
|
return render(request, "features/detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def _ancestors(feature) -> list[Feature]:
|
||||||
|
chain, node = [], feature.parent
|
||||||
|
while node:
|
||||||
|
chain.insert(0, node)
|
||||||
|
node = node.parent
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_edit")
|
||||||
|
def feature_edit(request, project, access, feature_key):
|
||||||
|
feature = get_feature(project, feature_key)
|
||||||
|
initial = {
|
||||||
|
"title": feature.title,
|
||||||
|
"description": feature.description,
|
||||||
|
"parent": feature.parent,
|
||||||
|
"status": feature.status,
|
||||||
|
"superseded_by": feature.superseded_by,
|
||||||
|
}
|
||||||
|
form = FeatureForm(
|
||||||
|
request.POST or None,
|
||||||
|
project=project,
|
||||||
|
feature=feature,
|
||||||
|
exclude_ids=services.descendant_ids(feature),
|
||||||
|
initial=initial,
|
||||||
|
)
|
||||||
|
preview = None
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
data = form.cleaned_data
|
||||||
|
if "preview" in request.POST:
|
||||||
|
preview = render_markdown(data["description"])
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
services.update_feature(feature, request.user, **data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
form.add_error(None, exc)
|
||||||
|
else:
|
||||||
|
messages.success(request, f"Saved revision {feature.current_revision}.")
|
||||||
|
return _feature_redirect(feature)
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"access": access,
|
||||||
|
"feature": feature,
|
||||||
|
"form": form,
|
||||||
|
"preview": preview,
|
||||||
|
}
|
||||||
|
return render(request, "features/form.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def _changes(revision, previous) -> list[str]:
|
||||||
|
"""Plain-language summary of what a revision changed."""
|
||||||
|
if previous is None:
|
||||||
|
return ["Created"]
|
||||||
|
changes = []
|
||||||
|
if revision.title != previous.title:
|
||||||
|
changes.append("Title")
|
||||||
|
if revision.description != previous.description:
|
||||||
|
changes.append("Description")
|
||||||
|
if revision.parent_id != previous.parent_id:
|
||||||
|
changes.append("Parent")
|
||||||
|
if revision.status != previous.status or revision.superseded_by_id != previous.superseded_by_id:
|
||||||
|
changes.append(f"Status: {revision.get_status_display()}")
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
def feature_history(request, project, access, feature_key):
|
||||||
|
feature = get_feature(project, feature_key)
|
||||||
|
revisions = list(feature.revisions.select_related("author"))
|
||||||
|
rows = [
|
||||||
|
{"revision": rev, "changes": _changes(rev, revisions[i - 1] if i else None)}
|
||||||
|
for i, rev in enumerate(revisions)
|
||||||
|
]
|
||||||
|
rows.reverse()
|
||||||
|
context = {"project": project, "access": access, "feature": feature, "rows": rows}
|
||||||
|
return render(request, "features/history.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
def feature_revision(request, project, access, feature_key, revision):
|
||||||
|
feature = get_feature(project, feature_key)
|
||||||
|
snapshot = get_object_or_404(
|
||||||
|
feature.revisions.select_related("author", "parent__project", "superseded_by__project"),
|
||||||
|
revision=revision,
|
||||||
|
)
|
||||||
|
if revision == feature.current_revision:
|
||||||
|
return _feature_redirect(feature)
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"access": access,
|
||||||
|
"feature": feature,
|
||||||
|
"snapshot": snapshot,
|
||||||
|
"description": render_markdown(snapshot.description),
|
||||||
|
}
|
||||||
|
return render(request, "features/revision.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
def feature_compare(request, project, access, feature_key):
|
||||||
|
feature = get_feature(project, feature_key)
|
||||||
|
current = feature.current_revision
|
||||||
|
form = CompareForm(request.GET or {"old": max(current - 1, 1), "new": current})
|
||||||
|
if not form.is_valid():
|
||||||
|
raise Http404
|
||||||
|
old_no, new_no = sorted([form.cleaned_data["old"], form.cleaned_data["new"]])
|
||||||
|
revisions = feature.revisions.select_related("parent__project", "superseded_by__project")
|
||||||
|
old = get_object_or_404(revisions, revision=old_no)
|
||||||
|
new = get_object_or_404(revisions, revision=new_no)
|
||||||
|
fields = [
|
||||||
|
("Title", old.title, new.title),
|
||||||
|
("Parent", _label(old.parent), _label(new.parent)),
|
||||||
|
("Status", old.get_status_display(), new.get_status_display()),
|
||||||
|
("Superseded by", _label(old.superseded_by), _label(new.superseded_by)),
|
||||||
|
]
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"access": access,
|
||||||
|
"feature": feature,
|
||||||
|
"old": old,
|
||||||
|
"new": new,
|
||||||
|
"field_changes": [(name, a, b) for name, a, b in fields if a != b],
|
||||||
|
"hunks": line_diff(old.description, new.description),
|
||||||
|
}
|
||||||
|
return render(request, "features/compare.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def _label(feature) -> str:
|
||||||
|
return f"{feature.key} {feature.title}" if feature else "None"
|
||||||
36
intent/markdown.py
Normal file
36
intent/markdown.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Markdown to safe HTML for user-written text (feature descriptions, later issues and comments).
|
||||||
|
|
||||||
|
Block elements carry `data-source-lines="start-end"` (1-based, inclusive) so comment threads can
|
||||||
|
anchor to lines of the source. Headings shift down one level so user text never adds a second
|
||||||
|
<h1> to the page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import nh3
|
||||||
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
|
from markdown_it import MarkdownIt
|
||||||
|
|
||||||
|
_ALLOWED_ATTRIBUTES = {tag: set(attrs) for tag, attrs in nh3.ALLOWED_ATTRIBUTES.items()}
|
||||||
|
_ALLOWED_ATTRIBUTES["*"] = {"data-source-lines"}
|
||||||
|
|
||||||
|
|
||||||
|
def _annotate(state):
|
||||||
|
for token in state.tokens:
|
||||||
|
if token.nesting == 1 and token.map:
|
||||||
|
start, end = token.map
|
||||||
|
token.attrSet("data-source-lines", f"{start + 1}-{end}")
|
||||||
|
if token.type in ("heading_open", "heading_close"):
|
||||||
|
token.tag = f"h{min(int(token.tag[1]) + 1, 6)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build() -> MarkdownIt:
|
||||||
|
md = MarkdownIt("commonmark", {"html": False}).enable(["table", "strikethrough"])
|
||||||
|
md.core.ruler.push("intent_annotate", _annotate)
|
||||||
|
return md
|
||||||
|
|
||||||
|
|
||||||
|
_md = _build()
|
||||||
|
|
||||||
|
|
||||||
|
def render(text: str) -> SafeString:
|
||||||
|
html = _md.render(text or "")
|
||||||
|
return mark_safe(nh3.clean(html, attributes=_ALLOWED_ATTRIBUTES))
|
||||||
|
|
@ -147,4 +147,6 @@ def remove_member(membership, by) -> None:
|
||||||
membership.refresh_from_db()
|
membership.refresh_from_db()
|
||||||
if _is_last_owner(membership):
|
if _is_last_owner(membership):
|
||||||
raise ValidationError(LAST_OWNER_MESSAGE)
|
raise ValidationError(LAST_OWNER_MESSAGE)
|
||||||
|
# Project roles only make sense for team members.
|
||||||
|
membership.user.project_memberships.filter(project__org=membership.org).delete()
|
||||||
membership.delete()
|
membership.delete()
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from django.template.loader import render_to_string
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from intent.access import org_access
|
from intent.access import org_access, visible_projects
|
||||||
|
|
||||||
from . import services
|
from . import services
|
||||||
from .forms import InviteForm, MembershipForm
|
from .forms import InviteForm, MembershipForm
|
||||||
|
|
@ -21,9 +21,12 @@ def _error(request, exc: ValidationError):
|
||||||
|
|
||||||
def org_detail(request, org_slug):
|
def org_detail(request, org_slug):
|
||||||
org = get_object_or_404(Organization, slug=org_slug)
|
org = get_object_or_404(Organization, slug=org_slug)
|
||||||
return render(
|
context = {
|
||||||
request, "orgs/detail.html", {"org": org, "access": org_access(request.user, org)}
|
"org": org,
|
||||||
)
|
"access": org_access(request.user, org),
|
||||||
|
"projects": visible_projects(request.user, org),
|
||||||
|
}
|
||||||
|
return render(request, "orgs/detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
def members(request, org_slug):
|
def members(request, org_slug):
|
||||||
|
|
|
||||||
0
intent/projects/__init__.py
Normal file
0
intent/projects/__init__.py
Normal file
17
intent/projects/admin.py
Normal file
17
intent/projects/admin.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Project, ProjectMembership
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMembershipInline(admin.TabularInline):
|
||||||
|
model = ProjectMembership
|
||||||
|
extra = 0
|
||||||
|
autocomplete_fields = ["user"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Project)
|
||||||
|
class ProjectAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "org", "slug", "feature_prefix", "visibility"]
|
||||||
|
list_filter = ["visibility"]
|
||||||
|
search_fields = ["name", "slug"]
|
||||||
|
inlines = [ProjectMembershipInline]
|
||||||
6
intent/projects/apps.py
Normal file
6
intent/projects/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectsConfig(AppConfig):
|
||||||
|
name = "intent.projects"
|
||||||
|
label = "projects"
|
||||||
37
intent/projects/decorators.py
Normal file
37
intent/projects/decorators.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.contrib.auth.views import redirect_to_login
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import Http404
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from intent.access import project_access
|
||||||
|
|
||||||
|
from .models import Project
|
||||||
|
|
||||||
|
|
||||||
|
def project_view(require="can_view"):
|
||||||
|
"""Load the project from the URL, check access, and call view(request, project, access, ...).
|
||||||
|
|
||||||
|
Projects a user can't see are 404s, so private projects don't reveal they exist.
|
||||||
|
Signed-out visitors are sent to sign in instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapper(request, org_slug, project_slug, **kwargs):
|
||||||
|
project = get_object_or_404(
|
||||||
|
Project.objects.select_related("org"), org__slug=org_slug, slug=project_slug
|
||||||
|
)
|
||||||
|
access = project_access(request.user, project)
|
||||||
|
if not access.can_view or not getattr(access, require):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return redirect_to_login(request.get_full_path())
|
||||||
|
if not access.can_view:
|
||||||
|
raise Http404
|
||||||
|
raise PermissionDenied
|
||||||
|
return view(request, project, access, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
64
intent/projects/forms.py
Normal file
64
intent/projects/forms.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from intent.orgs.models import Role
|
||||||
|
|
||||||
|
from .models import Project
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreateForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Project
|
||||||
|
fields = ["name", "slug", "feature_prefix", "visibility", "open_contribution"]
|
||||||
|
labels = {
|
||||||
|
"slug": "URL name",
|
||||||
|
"feature_prefix": "Feature ID prefix",
|
||||||
|
"open_contribution": "Let any signed-in user comment and open issues",
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
"slug": "Lowercase letters, numbers and hyphens. Can't be changed later.",
|
||||||
|
"feature_prefix": "Features are numbered like AUTH-12. Can't be changed later.",
|
||||||
|
}
|
||||||
|
widgets = {"visibility": forms.RadioSelect}
|
||||||
|
|
||||||
|
def clean_feature_prefix(self):
|
||||||
|
return self.cleaned_data["feature_prefix"].upper()
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSettingsForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Project
|
||||||
|
fields = ["name", "visibility", "open_contribution"]
|
||||||
|
labels = ProjectCreateForm.Meta.labels
|
||||||
|
widgets = ProjectCreateForm.Meta.widgets
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectDeleteForm(forms.Form):
|
||||||
|
confirm = forms.CharField(label="Type the project's URL name to confirm")
|
||||||
|
|
||||||
|
def __init__(self, *args, project, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.project = project
|
||||||
|
|
||||||
|
def clean_confirm(self):
|
||||||
|
if self.cleaned_data["confirm"] != self.project.slug:
|
||||||
|
raise forms.ValidationError(f"Type “{self.project.slug}” exactly.")
|
||||||
|
return self.cleaned_data["confirm"]
|
||||||
|
|
||||||
|
|
||||||
|
class AddMemberForm(forms.Form):
|
||||||
|
user = forms.ModelChoiceField(queryset=get_user_model().objects.none(), label="Team member")
|
||||||
|
role = forms.ChoiceField(choices=Role.choices, initial=Role.COLLABORATOR)
|
||||||
|
|
||||||
|
def __init__(self, *args, project, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["user"].queryset = (
|
||||||
|
get_user_model()
|
||||||
|
.objects.filter(org_memberships__org=project.org)
|
||||||
|
.exclude(project_memberships__project=project)
|
||||||
|
.order_by("username")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MemberRoleForm(forms.Form):
|
||||||
|
role = forms.ChoiceField(choices=Role.choices)
|
||||||
71
intent/projects/migrations/0001_initial.py
Normal file
71
intent/projects/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Generated by Django 5.2.18 on 2026-10-27 11:33
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import intent.core.models
|
||||||
|
import intent.projects.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('orgs', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Project',
|
||||||
|
fields=[
|
||||||
|
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('slug', models.SlugField(max_length=40, validators=[intent.projects.models.validate_project_slug])),
|
||||||
|
('feature_prefix', models.CharField(max_length=8, validators=[django.core.validators.RegexValidator('^[A-Z][A-Z0-9]{1,7}$', 'Use 2–8 uppercase letters or digits, starting with a letter.')])),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('visibility', models.CharField(choices=[('private', 'Private: project members only'), ('authenticated', 'Signed-in users'), ('public', 'Public: anyone, including signed-out visitors')], default='private', max_length=20)),
|
||||||
|
('open_contribution', models.BooleanField(default=False)),
|
||||||
|
('next_feature_number', models.PositiveIntegerField(default=1)),
|
||||||
|
('next_issue_number', models.PositiveIntegerField(default=1)),
|
||||||
|
('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='orgs.organization')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ProjectMembership',
|
||||||
|
fields=[
|
||||||
|
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('role', models.CharField(choices=[('owner', 'Owner'), ('collaborator', 'Collaborator')], default='collaborator', max_length=20)),
|
||||||
|
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='projects.project')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_memberships', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['user__username'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='project',
|
||||||
|
name='members',
|
||||||
|
field=models.ManyToManyField(related_name='projects', through='projects.ProjectMembership', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='projectmembership',
|
||||||
|
constraint=models.UniqueConstraint(fields=('project', 'user'), name='projectmembership_unique'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='project',
|
||||||
|
constraint=models.UniqueConstraint(fields=('org', 'slug'), name='project_unique_slug'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='project',
|
||||||
|
constraint=models.UniqueConstraint(fields=('org', 'feature_prefix'), name='project_unique_prefix'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
intent/projects/migrations/__init__.py
Normal file
0
intent/projects/migrations/__init__.py
Normal file
74
intent/projects/models.py
Normal file
74
intent/projects/models.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import RegexValidator
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from intent.core.models import BaseModel
|
||||||
|
from intent.orgs.models import Organization, Role
|
||||||
|
|
||||||
|
# Project slugs share the URL space under /<org>/ with org pages.
|
||||||
|
RESERVED_SLUGS = {"invites", "members", "new", "projects", "settings"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_project_slug(value):
|
||||||
|
if value.lower() in RESERVED_SLUGS:
|
||||||
|
raise ValidationError(f"“{value}” is reserved.")
|
||||||
|
|
||||||
|
|
||||||
|
validate_feature_prefix = RegexValidator(
|
||||||
|
r"^[A-Z][A-Z0-9]{1,7}$",
|
||||||
|
"Use 2–8 uppercase letters or digits, starting with a letter.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Visibility(models.TextChoices):
|
||||||
|
PRIVATE = "private", "Private: project members only"
|
||||||
|
AUTHENTICATED = "authenticated", "Signed-in users"
|
||||||
|
PUBLIC = "public", "Public: anyone, including signed-out visitors"
|
||||||
|
|
||||||
|
|
||||||
|
class Project(BaseModel):
|
||||||
|
org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="projects")
|
||||||
|
# Slug and prefix are fixed after creation: they appear in URLs and feature IDs.
|
||||||
|
slug = models.SlugField(max_length=40, validators=[validate_project_slug])
|
||||||
|
feature_prefix = models.CharField(max_length=8, validators=[validate_feature_prefix])
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
visibility = models.CharField(
|
||||||
|
max_length=20, choices=Visibility.choices, default=Visibility.PRIVATE
|
||||||
|
)
|
||||||
|
# Lets signed-in non-members comment and open issues.
|
||||||
|
open_contribution = models.BooleanField(default=False)
|
||||||
|
next_feature_number = models.PositiveIntegerField(default=1)
|
||||||
|
next_issue_number = models.PositiveIntegerField(default=1)
|
||||||
|
members = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL, through="ProjectMembership", related_name="projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["org", "slug"], name="project_unique_slug"),
|
||||||
|
models.UniqueConstraint(fields=["org", "feature_prefix"], name="project_unique_prefix"),
|
||||||
|
]
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMembership(BaseModel):
|
||||||
|
"""Grants an org team member a role on one project. Org owners need no row."""
|
||||||
|
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="memberships")
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="project_memberships"
|
||||||
|
)
|
||||||
|
role = models.CharField(max_length=20, choices=Role.choices, default=Role.COLLABORATOR)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["project", "user"], name="projectmembership_unique"),
|
||||||
|
]
|
||||||
|
ordering = ["user__username"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} on {self.project} ({self.role})"
|
||||||
71
intent/projects/services.py
Normal file
71
intent/projects/services.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Project rules. Same conventions as intent.orgs.services."""
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from intent.access import org_access, project_access
|
||||||
|
from intent.orgs.models import Role
|
||||||
|
|
||||||
|
from .models import Project, ProjectMembership
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(by, project):
|
||||||
|
if not project_access(by, project).can_admin:
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
def create_project(org, by, *, slug, name, feature_prefix, visibility, open_contribution):
|
||||||
|
if not org_access(by, org).can_create_projects:
|
||||||
|
raise PermissionDenied
|
||||||
|
project = Project(
|
||||||
|
org=org,
|
||||||
|
slug=slug,
|
||||||
|
name=name,
|
||||||
|
feature_prefix=feature_prefix.upper(),
|
||||||
|
visibility=visibility,
|
||||||
|
open_contribution=open_contribution,
|
||||||
|
)
|
||||||
|
project.full_clean()
|
||||||
|
with transaction.atomic():
|
||||||
|
project.save()
|
||||||
|
ProjectMembership.objects.create(project=project, user=by, role=Role.OWNER)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def update_settings(project, by, *, name, visibility, open_contribution) -> None:
|
||||||
|
_require_admin(by, project)
|
||||||
|
project.name = name
|
||||||
|
project.visibility = visibility
|
||||||
|
project.open_contribution = open_contribution
|
||||||
|
project.full_clean()
|
||||||
|
project.save(update_fields=["name", "visibility", "open_contribution", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def delete_project(project, by) -> None:
|
||||||
|
_require_admin(by, project)
|
||||||
|
project.delete()
|
||||||
|
|
||||||
|
|
||||||
|
def add_member(project, by, *, user, role=Role.COLLABORATOR) -> ProjectMembership:
|
||||||
|
_require_admin(by, project)
|
||||||
|
if not org_access(user, project.org).is_member:
|
||||||
|
raise ValidationError(f"{user.username} isn't on the {project.org} team.")
|
||||||
|
if project.memberships.filter(user=user).exists():
|
||||||
|
raise ValidationError(f"{user.username} is already on this project.")
|
||||||
|
return ProjectMembership.objects.create(project=project, user=user, role=role)
|
||||||
|
|
||||||
|
|
||||||
|
def update_member(membership, by, *, role) -> None:
|
||||||
|
_require_admin(by, membership.project)
|
||||||
|
membership.role = role
|
||||||
|
membership.save(update_fields=["role", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def remove_member(membership, by) -> None:
|
||||||
|
"""Admins can remove anyone; members can remove themselves.
|
||||||
|
|
||||||
|
No last-owner guard: org owners can always administer the project.
|
||||||
|
"""
|
||||||
|
if membership.user_id != by.pk:
|
||||||
|
_require_admin(by, membership.project)
|
||||||
|
membership.delete()
|
||||||
18
intent/projects/urls.py
Normal file
18
intent/projects/urls.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "projects"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.project_home, name="home"),
|
||||||
|
path("settings/", views.project_settings, name="settings"),
|
||||||
|
path("delete/", views.project_delete, name="delete"),
|
||||||
|
path("members/", views.project_members, name="members"),
|
||||||
|
path("members/<str:membership_id>/", views.project_member_update, name="member_update"),
|
||||||
|
path(
|
||||||
|
"members/<str:membership_id>/remove/",
|
||||||
|
views.project_member_remove,
|
||||||
|
name="member_remove",
|
||||||
|
),
|
||||||
|
]
|
||||||
118
intent/projects/views.py
Normal file
118
intent/projects/views.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
|
||||||
|
from intent.access import org_access
|
||||||
|
from intent.features.services import build_tree
|
||||||
|
from intent.orgs.models import Organization
|
||||||
|
|
||||||
|
from . import services
|
||||||
|
from .decorators import project_view
|
||||||
|
from .forms import (
|
||||||
|
AddMemberForm,
|
||||||
|
MemberRoleForm,
|
||||||
|
ProjectCreateForm,
|
||||||
|
ProjectDeleteForm,
|
||||||
|
ProjectSettingsForm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_redirect(project, name="home"):
|
||||||
|
return redirect(f"projects:{name}", org_slug=project.org.slug, project_slug=project.slug)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def project_create(request, org_slug):
|
||||||
|
org = get_object_or_404(Organization, slug=org_slug)
|
||||||
|
if not org_access(request.user, org).can_create_projects:
|
||||||
|
raise PermissionDenied
|
||||||
|
form = ProjectCreateForm(request.POST or None)
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
try:
|
||||||
|
project = services.create_project(org, request.user, **form.cleaned_data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
form.add_error(None, exc)
|
||||||
|
else:
|
||||||
|
messages.success(request, f"Created {project.name}.")
|
||||||
|
return _project_redirect(project)
|
||||||
|
return render(request, "projects/new.html", {"org": org, "form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@project_view()
|
||||||
|
def project_home(request, project, access):
|
||||||
|
features = list(project.features.select_related("project"))
|
||||||
|
context = {"project": project, "access": access, "tree": build_tree(features)}
|
||||||
|
return render(request, "projects/home.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_admin")
|
||||||
|
def project_settings(request, project, access):
|
||||||
|
form = ProjectSettingsForm(request.POST or None, instance=project)
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
services.update_settings(project, request.user, **form.cleaned_data)
|
||||||
|
messages.success(request, "Settings saved.")
|
||||||
|
return _project_redirect(project, "settings")
|
||||||
|
return render(
|
||||||
|
request, "projects/settings.html", {"project": project, "access": access, "form": form}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_admin")
|
||||||
|
def project_delete(request, project, access):
|
||||||
|
form = ProjectDeleteForm(request.POST or None, project=project)
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
services.delete_project(project, request.user)
|
||||||
|
messages.success(request, f"Deleted {project.name}.")
|
||||||
|
return redirect("orgs:detail", org_slug=project.org.slug)
|
||||||
|
return render(
|
||||||
|
request, "projects/delete.html", {"project": project, "access": access, "form": form}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_edit")
|
||||||
|
def project_members(request, project, access):
|
||||||
|
form = AddMemberForm(request.POST or None, project=project)
|
||||||
|
if request.method == "POST":
|
||||||
|
if not access.can_admin:
|
||||||
|
raise PermissionDenied
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
membership = services.add_member(project, request.user, **form.cleaned_data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
form.add_error(None, exc)
|
||||||
|
else:
|
||||||
|
messages.success(request, f"Added {membership.user.username}.")
|
||||||
|
return _project_redirect(project, "members")
|
||||||
|
context = {
|
||||||
|
"project": project,
|
||||||
|
"access": access,
|
||||||
|
"memberships": project.memberships.select_related("user"),
|
||||||
|
"form": form,
|
||||||
|
}
|
||||||
|
return render(request, "projects/members.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_edit")
|
||||||
|
def project_member_update(request, project, access, membership_id):
|
||||||
|
if request.method != "POST":
|
||||||
|
return _project_redirect(project, "members")
|
||||||
|
membership = get_object_or_404(project.memberships.select_related("user"), pk=membership_id)
|
||||||
|
form = MemberRoleForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
services.update_member(membership, request.user, role=form.cleaned_data["role"])
|
||||||
|
messages.success(request, f"Updated {membership.user.username}.")
|
||||||
|
return _project_redirect(project, "members")
|
||||||
|
|
||||||
|
|
||||||
|
@project_view(require="can_edit")
|
||||||
|
def project_member_remove(request, project, access, membership_id):
|
||||||
|
if request.method != "POST":
|
||||||
|
return _project_redirect(project, "members")
|
||||||
|
membership = get_object_or_404(project.memberships.select_related("user"), pk=membership_id)
|
||||||
|
services.remove_member(membership, request.user)
|
||||||
|
if membership.user_id == request.user.pk:
|
||||||
|
messages.success(request, f"You left {project.name}.")
|
||||||
|
return redirect("orgs:detail", org_slug=project.org.slug)
|
||||||
|
messages.success(request, f"Removed {membership.user.username}.")
|
||||||
|
return _project_redirect(project, "members")
|
||||||
|
|
@ -8,6 +8,8 @@ dependencies = [
|
||||||
"django-allauth[socialaccount]>=65.21",
|
"django-allauth[socialaccount]>=65.21",
|
||||||
"django-storages[s3]>=1.14.6",
|
"django-storages[s3]>=1.14.6",
|
||||||
"gunicorn>=26.2.0",
|
"gunicorn>=26.2.0",
|
||||||
|
"markdown-it-py>=4.2.0",
|
||||||
|
"nh3>=0.3.7",
|
||||||
"python-ulid>=4.0.1",
|
"python-ulid>=4.0.1",
|
||||||
"sentry-sdk[django]>=2.73.0",
|
"sentry-sdk[django]>=2.73.0",
|
||||||
"whitenoise>=6.12.0",
|
"whitenoise>=6.12.0",
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,40 @@ nav form {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb li + li::before {
|
||||||
|
content: "/";
|
||||||
|
content: "/" / "";
|
||||||
|
margin-inline-end: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 80ch;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diff tbody + tbody {
|
||||||
|
border-top: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff td:last-child {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: 0.25rem 0.75rem 0.25rem 0;
|
padding: 0.25rem 0.75rem 0.25rem 0;
|
||||||
|
|
|
||||||
11
templates/features/_header.html
Normal file
11
templates/features/_header.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{% comment %}Feature heading shared by the feature, edit, history and compare pages.{% endcomment %}
|
||||||
|
{% if ancestors %}
|
||||||
|
<nav aria-label="Parent features">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
{% for a in ancestors %}
|
||||||
|
<li><a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=a.key %}">{{ a.key }} {{ a.title }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
<h1><span class="muted">{{ feature.key }}</span> {{ heading|default:feature.title }}</h1>
|
||||||
11
templates/features/_tree.html
Normal file
11
templates/features/_tree.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<ul>
|
||||||
|
{% for node in nodes %}
|
||||||
|
{% with f=node.feature %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=f.key %}">{{ f.key }} {{ f.title }}</a>
|
||||||
|
{% if f.status != "active" %}<span class="muted">({{ f.get_status_display|lower }})</span>{% endif %}
|
||||||
|
{% if node.children %}{% include "features/_tree.html" with nodes=node.children %}{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
61
templates/features/compare.html
Normal file
61
templates/features/compare.html
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}{{ feature.key }} revisions {{ old.revision }}–{{ new.revision }}{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<p><a href="{% url 'features:history' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">Back to history</a></p>
|
||||||
|
<h1>Changes to <span class="muted">{{ feature.key }}</span> from revision {{ old.revision }} to {{ new.revision }}</h1>
|
||||||
|
|
||||||
|
{% if old.revision == new.revision %}
|
||||||
|
<p>Choose two different revisions to compare.</p>
|
||||||
|
{% else %}
|
||||||
|
{% if field_changes %}
|
||||||
|
<table>
|
||||||
|
<caption>Details</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Field</th>
|
||||||
|
<th scope="col">Revision {{ old.revision }}</th>
|
||||||
|
<th scope="col">Revision {{ new.revision }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for name, before, after in field_changes %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{{ name }}</th>
|
||||||
|
<td><del>{{ before }}</del></td>
|
||||||
|
<td><ins>{{ after }}</ins></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Description</h2>
|
||||||
|
{% if hunks %}
|
||||||
|
<table class="diff">
|
||||||
|
<caption class="visually-hidden">Line-by-line changes to the description</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Old line</th>
|
||||||
|
<th scope="col">New line</th>
|
||||||
|
<th scope="col">Text</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{% for hunk in hunks %}
|
||||||
|
<tbody>
|
||||||
|
{% for line in hunk %}
|
||||||
|
<tr class="diff-{{ line.kind }}">
|
||||||
|
<td>{{ line.old_number|default_if_none:"" }}</td>
|
||||||
|
<td>{{ line.new_number|default_if_none:"" }}</td>
|
||||||
|
<td>{% if line.kind == "removed" %}<del><span class="visually-hidden">Removed: </span>- {{ line.text }}</del>{% elif line.kind == "added" %}<ins><span class="visually-hidden">Added: </span>+ {{ line.text }}</ins>{% else %} {{ line.text }}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>The description didn't change.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
69
templates/features/detail.html
Normal file
69
templates/features/detail.html
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}{{ feature.key }} {{ feature.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
{% include "features/_header.html" %}
|
||||||
|
|
||||||
|
{% if feature.status == "withdrawn" %}
|
||||||
|
<p role="note"><strong>Withdrawn.</strong> This feature is no longer planned.</p>
|
||||||
|
{% elif feature.status == "superseded" %}
|
||||||
|
<p role="note"><strong>Superseded</strong> by <a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.superseded_by.key %}">{{ feature.superseded_by.key }} {{ feature.superseded_by.title }}</a>.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<dl>
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>{{ feature.get_status_display }}</dd>
|
||||||
|
<dt>Revision</dt>
|
||||||
|
<dd>
|
||||||
|
{{ feature.current_revision }}
|
||||||
|
(<a href="{% url 'features:history' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">history</a>)
|
||||||
|
</dd>
|
||||||
|
{% if latest %}
|
||||||
|
<dt>Last change</dt>
|
||||||
|
<dd>
|
||||||
|
<time datetime="{{ latest.created_at|date:'c' }}">{{ latest.created_at|date:"j M Y" }}</time>
|
||||||
|
by {{ latest.author.username|default:"unknown" }}: {{ latest.note }}
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if access.can_edit %}
|
||||||
|
<ul>
|
||||||
|
<li><a href="{% url 'features:edit' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">Edit</a></li>
|
||||||
|
<li><a href="{% url 'features:create' org_slug=project.org.slug project_slug=project.slug %}?parent={{ feature.key }}">Add a sub-feature</a></li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section aria-labelledby="description-heading">
|
||||||
|
<h2 id="description-heading" class="visually-hidden">Description</h2>
|
||||||
|
<div class="prose">
|
||||||
|
{% if feature.description %}{{ description }}{% else %}<p class="muted">No description yet.</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if children %}
|
||||||
|
<section aria-labelledby="children-heading">
|
||||||
|
<h2 id="children-heading">Sub-features</h2>
|
||||||
|
<ul>
|
||||||
|
{% for child in children %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=child.key %}">{{ child.key }} {{ child.title }}</a>
|
||||||
|
{% if child.status != "active" %}<span class="muted">({{ child.get_status_display|lower }})</span>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if supersedes %}
|
||||||
|
<section aria-labelledby="supersedes-heading">
|
||||||
|
<h2 id="supersedes-heading">Replaces</h2>
|
||||||
|
<ul>
|
||||||
|
{% for old in supersedes %}
|
||||||
|
<li><a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=old.key %}">{{ old.key }} {{ old.title }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
32
templates/features/form.html
Normal file
32
templates/features/form.html
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}{% if feature %}Edit {{ feature.key }}{% else %}New feature{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
{% if feature %}
|
||||||
|
<h1>Edit <span class="muted">{{ feature.key }}</span> {{ feature.title }}</h1>
|
||||||
|
<p>Saving creates revision {{ feature.current_revision|add:1 }}.</p>
|
||||||
|
{% else %}
|
||||||
|
<h1>New feature</h1>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if preview %}
|
||||||
|
<section aria-labelledby="preview-heading">
|
||||||
|
<h2 id="preview-heading">Preview</h2>
|
||||||
|
<div class="prose preview">{{ preview }}</div>
|
||||||
|
<p class="muted">Not saved yet.</p>
|
||||||
|
</section>
|
||||||
|
{% elif preview is not None %}
|
||||||
|
<p class="muted">Nothing to preview: the description is empty.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
|
<p>
|
||||||
|
{# Save comes first so pressing Enter in a text field saves rather than previews. #}
|
||||||
|
<button type="submit">{% if feature %}Save revision{% else %}Create feature{% endif %}</button>
|
||||||
|
<button type="submit" name="preview" value="1">Preview description</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
59
templates/features/history.html
Normal file
59
templates/features/history.html
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}History of {{ feature.key }}{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<p><a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">Back to {{ feature.key }}</a></p>
|
||||||
|
<h1>History of <span class="muted">{{ feature.key }}</span> {{ feature.title }}</h1>
|
||||||
|
|
||||||
|
<form method="get" action="{% url 'features:compare' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">
|
||||||
|
<table>
|
||||||
|
<caption>{{ rows|length }} revision{{ rows|length|pluralize }}, newest first</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Revision</th>
|
||||||
|
<th scope="col">Date</th>
|
||||||
|
<th scope="col">Author</th>
|
||||||
|
<th scope="col">Note</th>
|
||||||
|
<th scope="col">Changed</th>
|
||||||
|
{% if rows|length > 1 %}
|
||||||
|
<th scope="col">From</th>
|
||||||
|
<th scope="col">To</th>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
{% with rev=row.revision %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">
|
||||||
|
{% if rev.revision == feature.current_revision %}
|
||||||
|
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">{{ rev.revision }}</a> (current)
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'features:revision' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key revision=rev.revision %}">{{ rev.revision }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
<td><time datetime="{{ rev.created_at|date:'c' }}">{{ rev.created_at|date:"j M Y, H:i" }}</time></td>
|
||||||
|
<td>{{ rev.author.username|default:"unknown" }}</td>
|
||||||
|
<td>{{ rev.note }}</td>
|
||||||
|
<td>{{ row.changes|join:", " }}</td>
|
||||||
|
{% if rows|length > 1 %}
|
||||||
|
<td>
|
||||||
|
<input type="radio" name="old" value="{{ rev.revision }}" id="old-{{ rev.revision }}"{% if rev.revision == feature.current_revision|add:-1 %} checked{% endif %}>
|
||||||
|
<label for="old-{{ rev.revision }}" class="visually-hidden">Compare from revision {{ rev.revision }}</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="radio" name="new" value="{{ rev.revision }}" id="new-{{ rev.revision }}"{% if rev.revision == feature.current_revision %} checked{% endif %}>
|
||||||
|
<label for="new-{{ rev.revision }}" class="visually-hidden">Compare to revision {{ rev.revision }}</label>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if rows|length > 1 %}
|
||||||
|
<p><button type="submit">Compare selected revisions</button></p>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
36
templates/features/list.html
Normal file
36
templates/features/list.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}All features{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<h1>All features</h1>
|
||||||
|
{% if access.can_edit %}
|
||||||
|
<p><a href="{% url 'features:create' org_slug=project.org.slug project_slug=project.slug %}">New feature</a></p>
|
||||||
|
{% endif %}
|
||||||
|
{% if features %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Feature</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Parent</th>
|
||||||
|
<th scope="col">Revision</th>
|
||||||
|
<th scope="col">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in features %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=f.key %}">{{ f.key }} {{ f.title }}</a></th>
|
||||||
|
<td>{{ f.get_status_display }}</td>
|
||||||
|
<td>{% if f.parent %}{{ f.parent.key }}{% else %}—{% endif %}</td>
|
||||||
|
<td>{{ f.current_revision }}</td>
|
||||||
|
<td><time datetime="{{ f.updated_at|date:'c' }}">{{ f.updated_at|date:"j M Y" }}</time></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No features yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
30
templates/features/revision.html
Normal file
30
templates/features/revision.html
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}{{ feature.key }} revision {{ snapshot.revision }}{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<p role="note">
|
||||||
|
<strong>Old revision.</strong>
|
||||||
|
You're viewing revision {{ snapshot.revision }} of {{ feature.current_revision }}.
|
||||||
|
<a href="{% url 'features:detail' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">View the current version</a>
|
||||||
|
or <a href="{% url 'features:compare' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}?old={{ snapshot.revision }}&new={{ feature.current_revision }}">compare with it</a>.
|
||||||
|
</p>
|
||||||
|
<h1><span class="muted">{{ feature.key }}</span> {{ snapshot.title }}</h1>
|
||||||
|
<dl>
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>{{ snapshot.get_status_display }}{% if snapshot.superseded_by %}, by {{ snapshot.superseded_by.key }} {{ snapshot.superseded_by.title }}{% endif %}</dd>
|
||||||
|
<dt>Parent</dt>
|
||||||
|
<dd>{% if snapshot.parent %}{{ snapshot.parent.key }} {{ snapshot.parent.title }}{% else %}None (top level){% endif %}</dd>
|
||||||
|
<dt>Change</dt>
|
||||||
|
<dd>
|
||||||
|
<time datetime="{{ snapshot.created_at|date:'c' }}">{{ snapshot.created_at|date:"j M Y" }}</time>
|
||||||
|
by {{ snapshot.author.username|default:"unknown" }}: {{ snapshot.note }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
<section aria-labelledby="description-heading">
|
||||||
|
<h2 id="description-heading" class="visually-hidden">Description</h2>
|
||||||
|
<div class="prose">
|
||||||
|
{% if snapshot.description %}{{ description }}{% else %}<p class="muted">No description.</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -5,19 +5,41 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{ org.name }}</h1>
|
<h1>{{ org.name }}</h1>
|
||||||
|
|
||||||
{% if access.is_member %}
|
<section aria-labelledby="projects-heading">
|
||||||
<p>You're {% if access.is_owner %}an owner{% else %}a collaborator{% endif %} of this organization.</p>
|
<h2 id="projects-heading">Projects</h2>
|
||||||
<ul>
|
{% if projects %}
|
||||||
<li><a href="{% url 'orgs:members' org_slug=org.slug %}">Members</a></li>
|
<ul>
|
||||||
</ul>
|
{% for project in projects %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'projects:home' org_slug=org.slug project_slug=project.slug %}">{{ project.name }}</a>
|
||||||
|
{% if project.visibility != "public" %}<span class="muted">({{ project.visibility }})</span>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>No projects{% if not access.is_member %} you can see{% endif %} yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if access.can_create_projects %}
|
||||||
|
<p><a href="{% url 'project_create' org_slug=org.slug %}">New project</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
<details>
|
{% if access.is_member %}
|
||||||
<summary>Leave {{ org.name }}</summary>
|
<section aria-labelledby="team-heading">
|
||||||
<form method="post" action="{% url 'orgs:member_remove' org_slug=org.slug membership_id=access.membership.pk %}">
|
<h2 id="team-heading">Team</h2>
|
||||||
{% csrf_token %}
|
<p>You're {% if access.is_owner %}an owner{% else %}a collaborator{% endif %} of this organization.</p>
|
||||||
<p>You'll need a new invitation to rejoin.</p>
|
<ul>
|
||||||
<p><button type="submit">Leave {{ org.name }}</button></p>
|
<li><a href="{% url 'orgs:members' org_slug=org.slug %}">Members</a></li>
|
||||||
</form>
|
</ul>
|
||||||
</details>
|
|
||||||
|
<details>
|
||||||
|
<summary>Leave {{ org.name }}</summary>
|
||||||
|
<form method="post" action="{% url 'orgs:member_remove' org_slug=org.slug membership_id=access.membership.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p>You'll need a new invitation to rejoin, and you'll lose your project roles.</p>
|
||||||
|
<p><button type="submit">Leave {{ org.name }}</button></p>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
29
templates/projects/base.html
Normal file
29
templates/projects/base.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
Shared frame for every page inside a project.
|
||||||
|
Pages set `page_title` and fill `project_content`.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block title %}{% block page_title %}{% endblock %} · {{ project.name }} · Intent{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav aria-label="Breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="{% url 'orgs:detail' org_slug=project.org.slug %}">{{ project.org.name }}</a></li>
|
||||||
|
<li><a href="{% url 'projects:home' org_slug=project.org.slug project_slug=project.slug %}">{{ project.name }}</a></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<nav aria-label="Project">
|
||||||
|
<ul>
|
||||||
|
<li><a href="{% url 'projects:home' org_slug=project.org.slug project_slug=project.slug %}">Feature tree</a></li>
|
||||||
|
<li><a href="{% url 'features:list' org_slug=project.org.slug project_slug=project.slug %}">All features</a></li>
|
||||||
|
{% if access.can_edit %}
|
||||||
|
<li><a href="{% url 'projects:members' org_slug=project.org.slug project_slug=project.slug %}">Members</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if access.can_admin %}
|
||||||
|
<li><a href="{% url 'projects:settings' org_slug=project.org.slug project_slug=project.slug %}">Settings</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% block project_content %}{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
13
templates/projects/delete.html
Normal file
13
templates/projects/delete.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}Delete{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<h1>Delete {{ project.name }}</h1>
|
||||||
|
<p><strong>This permanently deletes {{ project.features.count }} feature{{ project.features.count|pluralize }} and all of their history.</strong> It can't be undone.</p>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
|
<p><button type="submit">Delete this project</button></p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
20
templates/projects/home.html
Normal file
20
templates/projects/home.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ project.name }} · Intent{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<h1>{{ project.name }}</h1>
|
||||||
|
<p class="muted">{{ project.get_visibility_display }}{% if project.open_contribution %}. Open to contributions from signed-in users.{% endif %}</p>
|
||||||
|
|
||||||
|
<section aria-labelledby="tree-heading">
|
||||||
|
<h2 id="tree-heading">Features</h2>
|
||||||
|
{% if access.can_edit %}
|
||||||
|
<p><a href="{% url 'features:create' org_slug=project.org.slug project_slug=project.slug %}">New feature</a></p>
|
||||||
|
{% endif %}
|
||||||
|
{% if tree %}
|
||||||
|
{% include "features/_tree.html" with nodes=tree %}
|
||||||
|
{% else %}
|
||||||
|
<p>No features yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
68
templates/projects/members.html
Normal file
68
templates/projects/members.html
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}Members{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<h1>Members</h1>
|
||||||
|
<p>Owners of {{ project.org.name }} can also administer this project.</p>
|
||||||
|
|
||||||
|
{% if access.can_admin %}
|
||||||
|
{% for m in memberships %}
|
||||||
|
<form id="member-{{ m.pk }}" method="post" action="{% url 'projects:member_update' org_slug=project.org.slug project_slug=project.slug membership_id=m.pk %}">{% csrf_token %}</form>
|
||||||
|
<form id="remove-{{ m.pk }}" method="post" action="{% url 'projects:member_remove' org_slug=project.org.slug project_slug=project.slug membership_id=m.pk %}">{% csrf_token %}</form>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<caption>{{ memberships|length }} member{{ memberships|length|pluralize }}</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Member</th>
|
||||||
|
<th scope="col">Role</th>
|
||||||
|
{% if access.can_admin %}<th scope="col">Actions</th>{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in memberships %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{{ m.user.username }}{% if m.user == user %} (you){% endif %}</th>
|
||||||
|
{% if access.can_admin %}
|
||||||
|
<td>
|
||||||
|
<label for="role-{{ m.pk }}" class="visually-hidden">Role for {{ m.user.username }}</label>
|
||||||
|
<select id="role-{{ m.pk }}" name="role" form="member-{{ m.pk }}">
|
||||||
|
<option value="owner"{% if m.role == "owner" %} selected{% endif %}>Owner</option>
|
||||||
|
<option value="collaborator"{% if m.role == "collaborator" %} selected{% endif %}>Collaborator</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="submit" form="member-{{ m.pk }}">Save <span class="visually-hidden">{{ m.user.username }}</span></button>
|
||||||
|
<button type="submit" form="remove-{{ m.pk }}">Remove <span class="visually-hidden">{{ m.user.username }}</span></button>
|
||||||
|
</td>
|
||||||
|
{% else %}
|
||||||
|
<td>{{ m.get_role_display }}</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if access.can_admin %}
|
||||||
|
<section aria-labelledby="add-heading">
|
||||||
|
<h2 id="add-heading">Add a team member</h2>
|
||||||
|
{% if form.fields.user.queryset.exists %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
|
<p><button type="submit">Add to project</button></p>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>Everyone on the {{ project.org.name }} team is already on this project. <a href="{% url 'orgs:members' org_slug=project.org.slug %}">Invite people to the team</a> first.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% elif access.membership %}
|
||||||
|
<form method="post" action="{% url 'projects:member_remove' org_slug=project.org.slug project_slug=project.slug membership_id=access.membership.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p><button type="submit">Leave this project</button></p>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
13
templates/projects/new.html
Normal file
13
templates/projects/new.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}New project · {{ org.name }} · Intent{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="{% url 'orgs:detail' org_slug=org.slug %}">{{ org.name }}</a></p>
|
||||||
|
<h1>New project</h1>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
|
<p><button type="submit">Create project</button></p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
22
templates/projects/settings.html
Normal file
22
templates/projects/settings.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "projects/base.html" %}
|
||||||
|
|
||||||
|
{% block page_title %}Settings{% endblock %}
|
||||||
|
|
||||||
|
{% block project_content %}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<dl>
|
||||||
|
<dt>URL name</dt><dd><code>{{ project.slug }}</code></dd>
|
||||||
|
<dt>Feature ID prefix</dt><dd><code>{{ project.feature_prefix }}</code></dd>
|
||||||
|
</dl>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
|
<p><button type="submit">Save settings</button></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section aria-labelledby="danger-heading">
|
||||||
|
<h2 id="danger-heading">Delete project</h2>
|
||||||
|
<p>Deleting removes every feature and its history. It can't be undone.</p>
|
||||||
|
<p><a href="{% url 'projects:delete' org_slug=project.org.slug project_slug=project.slug %}">Delete {{ project.name }}…</a></p>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
67
tests/a11y.py
Normal file
67
tests/a11y.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Structural accessibility checks for rendered pages.
|
||||||
|
|
||||||
|
Not a substitute for axe or a screen reader, but cheap enough to run on every page in unit tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
_UNLABELLED_INPUT_TYPES = {"hidden", "submit", "button", "reset", "image"}
|
||||||
|
|
||||||
|
|
||||||
|
class _Collector(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.html_lang = None
|
||||||
|
self.headings = []
|
||||||
|
self.ids = []
|
||||||
|
self.label_fors = set()
|
||||||
|
self.controls = []
|
||||||
|
self.images_without_alt = 0
|
||||||
|
self._label_depth = 0
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
attrs = dict(attrs)
|
||||||
|
if tag == "html":
|
||||||
|
self.html_lang = attrs.get("lang")
|
||||||
|
if "id" in attrs:
|
||||||
|
self.ids.append(attrs["id"])
|
||||||
|
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
|
||||||
|
self.headings.append(int(tag[1]))
|
||||||
|
if tag == "label":
|
||||||
|
self._label_depth += 1
|
||||||
|
if attrs.get("for"):
|
||||||
|
self.label_fors.add(attrs["for"])
|
||||||
|
if tag == "img" and "alt" not in attrs:
|
||||||
|
self.images_without_alt += 1
|
||||||
|
if tag in {"input", "select", "textarea"}:
|
||||||
|
if tag == "input" and attrs.get("type", "text") in _UNLABELLED_INPUT_TYPES:
|
||||||
|
return
|
||||||
|
wrapped = self._label_depth > 0
|
||||||
|
named = bool(attrs.get("aria-label") or attrs.get("aria-labelledby"))
|
||||||
|
self.controls.append((attrs.get("id"), attrs.get("name"), wrapped or named))
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag == "label":
|
||||||
|
self._label_depth -= 1
|
||||||
|
|
||||||
|
|
||||||
|
def assert_accessible_html(html: str) -> None:
|
||||||
|
page = _Collector()
|
||||||
|
page.feed(html)
|
||||||
|
problems = []
|
||||||
|
if not page.html_lang:
|
||||||
|
problems.append("<html> has no lang")
|
||||||
|
if page.headings.count(1) != 1:
|
||||||
|
problems.append(f"expected one <h1>, found {page.headings.count(1)}")
|
||||||
|
for before, after in zip(page.headings, page.headings[1:], strict=False):
|
||||||
|
if after > before + 1:
|
||||||
|
problems.append(f"heading level skips from h{before} to h{after}")
|
||||||
|
duplicates = {i for i in page.ids if page.ids.count(i) > 1}
|
||||||
|
if duplicates:
|
||||||
|
problems.append(f"duplicate ids: {sorted(duplicates)}")
|
||||||
|
for control_id, name, labelled in page.controls:
|
||||||
|
if not (labelled or control_id in page.label_fors):
|
||||||
|
problems.append(f"form control without a label: id={control_id} name={name}")
|
||||||
|
if page.images_without_alt:
|
||||||
|
problems.append(f"{page.images_without_alt} <img> without alt")
|
||||||
|
assert not problems, "\n".join(problems)
|
||||||
|
|
@ -6,6 +6,8 @@ from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from intent.orgs.models import OrgMembership, Role
|
from intent.orgs.models import OrgMembership, Role
|
||||||
from intent.orgs.services import create_org
|
from intent.orgs.services import create_org
|
||||||
|
from intent.projects.models import ProjectMembership, Visibility
|
||||||
|
from intent.projects.services import create_project
|
||||||
|
|
||||||
_counter = itertools.count()
|
_counter = itertools.count()
|
||||||
|
|
||||||
|
|
@ -39,3 +41,26 @@ def collaborator(make_user, org):
|
||||||
user = make_user("cole")
|
user = make_user("cole")
|
||||||
OrgMembership.objects.create(org=org, user=user, role=Role.COLLABORATOR)
|
OrgMembership.objects.create(org=org, user=user, role=Role.COLLABORATOR)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def project(org, owner):
|
||||||
|
"""A private project created by the org owner, who is therefore its project owner too."""
|
||||||
|
return create_project(
|
||||||
|
org,
|
||||||
|
owner,
|
||||||
|
slug="intent",
|
||||||
|
name="Intent",
|
||||||
|
feature_prefix="INT",
|
||||||
|
visibility=Visibility.PRIVATE,
|
||||||
|
open_contribution=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def editor(make_user, org, project):
|
||||||
|
"""An org collaborator who is a collaborator on the project."""
|
||||||
|
user = make_user("eddie")
|
||||||
|
OrgMembership.objects.create(org=org, user=user)
|
||||||
|
ProjectMembership.objects.create(project=project, user=user)
|
||||||
|
return user
|
||||||
|
|
|
||||||
73
tests/test_a11y.py
Normal file
73
tests/test_a11y.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from intent.features.services import create_feature, update_feature
|
||||||
|
from intent.orgs.services import invite
|
||||||
|
|
||||||
|
from .a11y import assert_accessible_html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def populated(project, owner, make_user, org):
|
||||||
|
parent = create_feature(project, owner, title="Accounts", description="# Goal\n\nText\n")
|
||||||
|
child = create_feature(project, owner, title="Sign in", parent=parent)
|
||||||
|
update_feature(child, owner, note="Detail", description="Steps\n\n1. One\n2. Two\n")
|
||||||
|
invite(org, owner, email="new@example.com")
|
||||||
|
return child
|
||||||
|
|
||||||
|
|
||||||
|
def _pages(org, project, feature):
|
||||||
|
o, p, k = org.slug, project.slug, feature.key
|
||||||
|
pk = {"org_slug": o, "project_slug": p}
|
||||||
|
fk = {**pk, "feature_key": k}
|
||||||
|
return [
|
||||||
|
reverse("home"),
|
||||||
|
reverse("invites:list"),
|
||||||
|
reverse("account_email"),
|
||||||
|
reverse("orgs:detail", kwargs={"org_slug": o}),
|
||||||
|
reverse("orgs:members", kwargs={"org_slug": o}),
|
||||||
|
reverse("project_create", kwargs={"org_slug": o}),
|
||||||
|
reverse("projects:home", kwargs=pk),
|
||||||
|
reverse("projects:settings", kwargs=pk),
|
||||||
|
reverse("projects:delete", kwargs=pk),
|
||||||
|
reverse("projects:members", kwargs=pk),
|
||||||
|
reverse("features:list", kwargs=pk),
|
||||||
|
reverse("features:create", kwargs=pk),
|
||||||
|
reverse("features:detail", kwargs=fk),
|
||||||
|
reverse("features:edit", kwargs=fk),
|
||||||
|
reverse("features:history", kwargs=fk),
|
||||||
|
reverse("features:revision", kwargs={**fk, "revision": 1}),
|
||||||
|
reverse("features:compare", kwargs=fk),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_in_pages(client, owner, org, project, populated):
|
||||||
|
client.force_login(owner)
|
||||||
|
for path in _pages(org, project, populated):
|
||||||
|
response = client.get(path)
|
||||||
|
assert response.status_code == 200, path
|
||||||
|
try:
|
||||||
|
assert_accessible_html(response.content.decode())
|
||||||
|
except AssertionError as exc:
|
||||||
|
raise AssertionError(f"{path}: {exc}") from None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"html, problem",
|
||||||
|
[
|
||||||
|
("<html><body><h1>a</h1></body></html>", "no lang"),
|
||||||
|
('<html lang="en"><h2>a</h2></html>', "one <h1>"),
|
||||||
|
('<html lang="en"><h1>a</h1><h3>b</h3></html>', "skips"),
|
||||||
|
('<html lang="en"><h1 id="x">a</h1><p id="x"></p></html>', "duplicate ids"),
|
||||||
|
('<html lang="en"><h1>a</h1><input name="q"></html>', "without a label"),
|
||||||
|
('<html lang="en"><h1>a</h1><img src="x"></html>', "without alt"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_checker_catches_problems(html, problem):
|
||||||
|
with pytest.raises(AssertionError, match=problem):
|
||||||
|
assert_accessible_html(html)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["account_login", "account_signup", "account_request_login_code"])
|
||||||
|
def test_signed_out_pages(client, db, name):
|
||||||
|
assert_accessible_html(client.get(reverse(name)).content.decode())
|
||||||
148
tests/test_features.py
Normal file
148
tests/test_features.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import pytest
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from django.db import IntegrityError
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from intent.features import services
|
||||||
|
from intent.features.models import Feature, FeatureStatus
|
||||||
|
|
||||||
|
|
||||||
|
def furl(name, feature=None, project=None, **kwargs):
|
||||||
|
project = project or feature.project
|
||||||
|
kwargs = {"org_slug": project.org.slug, "project_slug": project.slug, **kwargs}
|
||||||
|
if feature:
|
||||||
|
kwargs["feature_key"] = feature.key
|
||||||
|
return reverse(f"features:{name}", kwargs=kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def feature(project, owner):
|
||||||
|
return services.create_feature(project, owner, title="Sign in", description="Line one\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_numbers_are_sequential_and_first_revision_recorded(project, owner, feature):
|
||||||
|
second = services.create_feature(project, owner, title="Second")
|
||||||
|
assert (feature.key, second.key) == ("INT-1", "INT-2")
|
||||||
|
revision = feature.revisions.get()
|
||||||
|
assert (revision.revision, revision.note) == (1, "Created.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_records_revision_with_note(owner, feature):
|
||||||
|
revision = services.update_feature(feature, owner, note="Clarify", description="Line uno\n")
|
||||||
|
assert revision.revision == feature.current_revision == 2
|
||||||
|
assert revision.note == "Clarify"
|
||||||
|
assert feature.revisions.get(revision=1).description == "Line one\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_needs_a_note_and_a_change(owner, feature):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.update_feature(feature, owner, note=" ", title="New")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.update_feature(feature, owner, note="No-op", title="Sign in")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_create_a_cycle(project, owner, feature):
|
||||||
|
child = services.create_feature(project, owner, title="Child", parent=feature)
|
||||||
|
grandchild = services.create_feature(project, owner, title="Grandchild", parent=child)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.update_feature(feature, owner, note="Loop", parent=grandchild)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.update_feature(feature, owner, note="Self", parent=feature)
|
||||||
|
|
||||||
|
|
||||||
|
def test_superseded_needs_replacement(project, owner, feature):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.update_feature(feature, owner, note="Gone", status=FeatureStatus.SUPERSEDED)
|
||||||
|
replacement = services.create_feature(project, owner, title="Sign in v2")
|
||||||
|
services.update_feature(
|
||||||
|
feature, owner, note="Replaced", status=FeatureStatus.SUPERSEDED, superseded_by=replacement
|
||||||
|
)
|
||||||
|
services.update_feature(feature, owner, note="Back", status=FeatureStatus.ACTIVE)
|
||||||
|
feature.refresh_from_db()
|
||||||
|
assert feature.superseded_by is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_enforces_superseded_by(feature):
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
Feature.objects.filter(pk=feature.pk).update(status=FeatureStatus.SUPERSEDED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_collaborator_without_project_role_cannot_edit(collaborator, project, feature):
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.create_feature(project, collaborator, title="Nope")
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.update_feature(feature, collaborator, note="x", title="Nope")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_via_form_with_parent_prefill(client, editor, project, feature):
|
||||||
|
client.force_login(editor)
|
||||||
|
response = client.get(furl("create", project=project) + "?parent=int-1")
|
||||||
|
assert b"selected>INT-1 Sign in" in response.content
|
||||||
|
response = client.post(
|
||||||
|
furl("create", project=project),
|
||||||
|
{"title": "Child", "description": "", "parent": feature.pk, "note": ""},
|
||||||
|
)
|
||||||
|
child = Feature.objects.get(title="Child")
|
||||||
|
assert response["Location"] == furl("detail", child)
|
||||||
|
assert child.parent == feature
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_does_not_save(client, owner, feature):
|
||||||
|
client.force_login(owner)
|
||||||
|
response = client.post(
|
||||||
|
furl("edit", feature),
|
||||||
|
{
|
||||||
|
"title": "Sign in",
|
||||||
|
"description": "# Draft",
|
||||||
|
"status": "active",
|
||||||
|
"note": "x",
|
||||||
|
"preview": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert b"<h2" in response.content and b"Draft" in response.content
|
||||||
|
feature.refresh_from_db()
|
||||||
|
assert feature.current_revision == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_via_form(client, owner, feature):
|
||||||
|
client.force_login(owner)
|
||||||
|
client.post(
|
||||||
|
furl("edit", feature),
|
||||||
|
{"title": "Sign in", "description": "Line uno\n", "status": "active", "note": "Spanish"},
|
||||||
|
)
|
||||||
|
feature.refresh_from_db()
|
||||||
|
assert feature.current_revision == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_pages_render(client, owner, project, feature):
|
||||||
|
services.update_feature(feature, owner, note="Edit", description="Line uno\n", title="Login")
|
||||||
|
client.force_login(owner)
|
||||||
|
for path in [
|
||||||
|
furl("list", project=project),
|
||||||
|
reverse("projects:home", kwargs={"org_slug": "catbriar", "project_slug": "intent"}),
|
||||||
|
furl("detail", feature),
|
||||||
|
furl("history", feature),
|
||||||
|
furl("revision", feature, revision=1),
|
||||||
|
furl("compare", feature),
|
||||||
|
]:
|
||||||
|
assert client.get(path).status_code == 200, path
|
||||||
|
|
||||||
|
compare = client.get(furl("compare", feature) + "?old=1&new=2").content.decode()
|
||||||
|
assert "Line one" in compare and "Line uno" in compare and "Removed: " in compare
|
||||||
|
assert client.get(furl("revision", feature, revision=2)).status_code == 302
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_key_lookup(client, owner, feature):
|
||||||
|
client.force_login(owner)
|
||||||
|
base = furl("list", project=feature.project)
|
||||||
|
assert client.get(f"{base}int-1/").status_code == 200
|
||||||
|
assert client.get(f"{base}NOPE-1/").status_code == 404
|
||||||
|
assert client.get(f"{base}INT-99/").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewer_cannot_open_edit_page(client, make_user, project, feature):
|
||||||
|
project.visibility = "public"
|
||||||
|
project.save()
|
||||||
|
assert client.get(furl("detail", feature)).status_code == 200
|
||||||
|
client.force_login(make_user())
|
||||||
|
assert client.get(furl("edit", feature)).status_code == 403
|
||||||
37
tests/test_markdown.py
Normal file
37
tests/test_markdown.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
from intent.features.diff import line_diff
|
||||||
|
from intent.markdown import render
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocks_carry_source_lines():
|
||||||
|
html = render("Para one\ncontinues.\n\n- a\n- b\n")
|
||||||
|
assert '<p data-source-lines="1-2">' in html
|
||||||
|
assert '<li data-source-lines="4-4">' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_headings_shift_down_one_level():
|
||||||
|
html = render("# Top\n\n###### Deepest\n")
|
||||||
|
assert "<h2" in html and "<h1" not in html
|
||||||
|
assert "<h6" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_html_and_script_links_are_neutralised():
|
||||||
|
html = render("<script>alert(1)</script>\n\n[x](javascript:alert(1)) <img src=x onerror=y>")
|
||||||
|
assert "<script" not in html
|
||||||
|
assert "javascript:" not in html.replace("[x](javascript:alert(1))", "")
|
||||||
|
assert "onerror" not in html.replace("onerror=y>", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tables_and_strikethrough():
|
||||||
|
html = render("| a |\n|---|\n| 1 |\n\n~~old~~")
|
||||||
|
assert "<table" in html and "<s>old</s>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_line_diff_hunks():
|
||||||
|
old = "\n".join(f"line {i}" for i in range(1, 21))
|
||||||
|
new = old.replace("line 10", "line ten")
|
||||||
|
[hunk] = line_diff(old, new)
|
||||||
|
kinds = [(line.kind, line.old_number, line.new_number) for line in hunk]
|
||||||
|
assert ("removed", 10, None) in kinds
|
||||||
|
assert ("added", None, 10) in kinds
|
||||||
|
assert kinds[0] == ("same", 7, 7) # three lines of context
|
||||||
|
assert line_diff(old, old) == []
|
||||||
89
tests/test_project_access.py
Normal file
89
tests/test_project_access.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
from intent.access import project_access, visible_projects
|
||||||
|
from intent.orgs.models import OrgMembership
|
||||||
|
from intent.projects.models import ProjectMembership, Visibility
|
||||||
|
|
||||||
|
|
||||||
|
def _flags(user, project):
|
||||||
|
a = project_access(user, project)
|
||||||
|
return (a.can_view, a.can_contribute, a.can_edit, a.can_admin)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def people(make_user, org, project, owner, collaborator, editor):
|
||||||
|
project_owner = make_user("pam")
|
||||||
|
OrgMembership.objects.create(org=org, user=project_owner)
|
||||||
|
ProjectMembership.objects.create(project=project, user=project_owner, role="owner")
|
||||||
|
return {
|
||||||
|
"anonymous": AnonymousUser(),
|
||||||
|
"outsider": make_user(),
|
||||||
|
"staff": make_user(is_staff=True),
|
||||||
|
"org collaborator": collaborator,
|
||||||
|
"project collaborator": editor,
|
||||||
|
"project owner": project_owner,
|
||||||
|
"org owner": owner,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# (view, contribute, edit, admin) per visibility, without open contribution.
|
||||||
|
EXPECTED = {
|
||||||
|
Visibility.PRIVATE: {
|
||||||
|
"anonymous": (False, False, False, False),
|
||||||
|
"outsider": (False, False, False, False),
|
||||||
|
"staff": (False, False, False, False),
|
||||||
|
"org collaborator": (False, False, False, False),
|
||||||
|
"project collaborator": (True, True, True, False),
|
||||||
|
"project owner": (True, True, True, True),
|
||||||
|
"org owner": (True, True, True, True),
|
||||||
|
},
|
||||||
|
Visibility.AUTHENTICATED: {
|
||||||
|
"anonymous": (False, False, False, False),
|
||||||
|
"outsider": (True, False, False, False),
|
||||||
|
"staff": (False, False, False, False),
|
||||||
|
"org collaborator": (True, False, False, False),
|
||||||
|
"project collaborator": (True, True, True, False),
|
||||||
|
"project owner": (True, True, True, True),
|
||||||
|
"org owner": (True, True, True, True),
|
||||||
|
},
|
||||||
|
Visibility.PUBLIC: {
|
||||||
|
"anonymous": (True, False, False, False),
|
||||||
|
"outsider": (True, False, False, False),
|
||||||
|
"staff": (True, False, False, False),
|
||||||
|
"org collaborator": (True, False, False, False),
|
||||||
|
"project collaborator": (True, True, True, False),
|
||||||
|
"project owner": (True, True, True, True),
|
||||||
|
"org owner": (True, True, True, True),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("visibility", list(EXPECTED))
|
||||||
|
def test_access_matrix(people, project, visibility):
|
||||||
|
project.visibility = visibility
|
||||||
|
project.save()
|
||||||
|
for who, user in people.items():
|
||||||
|
assert _flags(user, project) == EXPECTED[visibility][who], who
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_contribution_lets_signed_in_viewers_contribute(people, project):
|
||||||
|
project.visibility = Visibility.PUBLIC
|
||||||
|
project.open_contribution = True
|
||||||
|
project.save()
|
||||||
|
assert project_access(people["outsider"], project).can_contribute
|
||||||
|
assert project_access(people["org collaborator"], project).can_contribute
|
||||||
|
assert not project_access(people["anonymous"], project).can_contribute
|
||||||
|
assert not project_access(people["staff"], project).can_contribute
|
||||||
|
assert not project_access(people["outsider"], project).can_edit
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_projects(people, org, project):
|
||||||
|
assert list(visible_projects(people["org owner"], org)) == [project]
|
||||||
|
assert list(visible_projects(people["project collaborator"], org)) == [project]
|
||||||
|
assert list(visible_projects(people["org collaborator"], org)) == []
|
||||||
|
assert list(visible_projects(people["anonymous"], org)) == []
|
||||||
|
project.visibility = Visibility.AUTHENTICATED
|
||||||
|
project.save()
|
||||||
|
assert list(visible_projects(people["outsider"], org)) == [project]
|
||||||
|
assert list(visible_projects(people["anonymous"], org)) == []
|
||||||
127
tests/test_projects.py
Normal file
127
tests/test_projects.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import pytest
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from intent.features.models import Feature, FeatureStatus
|
||||||
|
from intent.features.services import create_feature, update_feature
|
||||||
|
from intent.orgs.models import OrgMembership
|
||||||
|
from intent.orgs.services import remove_member as remove_org_member
|
||||||
|
from intent.projects import services
|
||||||
|
from intent.projects.models import Project, ProjectMembership
|
||||||
|
|
||||||
|
|
||||||
|
def url(name, project, **kwargs):
|
||||||
|
return reverse(
|
||||||
|
f"projects:{name}",
|
||||||
|
kwargs={"org_slug": project.org.slug, "project_slug": project.slug, **kwargs},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_requires_grant(collaborator, org):
|
||||||
|
with pytest.raises(PermissionDenied):
|
||||||
|
services.create_project(
|
||||||
|
org,
|
||||||
|
collaborator,
|
||||||
|
slug="x",
|
||||||
|
name="X",
|
||||||
|
feature_prefix="XX",
|
||||||
|
visibility="private",
|
||||||
|
open_contribution=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_granted_collaborator_creates_and_owns_project(client, collaborator, org):
|
||||||
|
OrgMembership.objects.filter(user=collaborator).update(can_create_projects=True)
|
||||||
|
client.force_login(collaborator)
|
||||||
|
response = client.post(
|
||||||
|
reverse("project_create", kwargs={"org_slug": org.slug}),
|
||||||
|
{"name": "Docs", "slug": "docs", "feature_prefix": "doc", "visibility": "public"},
|
||||||
|
)
|
||||||
|
project = Project.objects.get(slug="docs")
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert project.feature_prefix == "DOC"
|
||||||
|
assert project.memberships.get(user=collaborator).role == "owner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_prefix_is_a_form_error(client, owner, org, project):
|
||||||
|
client.force_login(owner)
|
||||||
|
response = client.post(
|
||||||
|
reverse("project_create", kwargs={"org_slug": org.slug}),
|
||||||
|
{"name": "Other", "slug": "other", "feature_prefix": "INT", "visibility": "private"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"already exists" in response.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserved_slug_rejected(owner, org):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.create_project(
|
||||||
|
org,
|
||||||
|
owner,
|
||||||
|
slug="members",
|
||||||
|
name="M",
|
||||||
|
feature_prefix="MM",
|
||||||
|
visibility="private",
|
||||||
|
open_contribution=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_project_hidden_from_outsiders(client, make_user, project):
|
||||||
|
response = client.get(url("home", project))
|
||||||
|
assert response.status_code == 302 and "/accounts/login/" in response["Location"]
|
||||||
|
client.force_login(make_user())
|
||||||
|
assert client.get(url("home", project)).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_project_visible_to_anonymous(client, project):
|
||||||
|
project.visibility = "public"
|
||||||
|
project.save()
|
||||||
|
assert client.get(url("home", project)).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_require_admin(client, editor, owner, project):
|
||||||
|
client.force_login(editor)
|
||||||
|
assert client.get(url("settings", project)).status_code == 403
|
||||||
|
client.force_login(owner)
|
||||||
|
client.post(url("settings", project), {"name": "Renamed", "visibility": "public"})
|
||||||
|
project.refresh_from_db()
|
||||||
|
assert project.name == "Renamed" and project.visibility == "public"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_needs_typed_slug_and_cascades_features(client, owner, project):
|
||||||
|
parent = create_feature(project, owner, title="Parent")
|
||||||
|
child = create_feature(project, owner, title="Child", parent=parent)
|
||||||
|
update_feature(
|
||||||
|
parent, owner, note="Replaced", status=FeatureStatus.SUPERSEDED, superseded_by=child
|
||||||
|
)
|
||||||
|
client.force_login(owner)
|
||||||
|
client.post(url("delete", project), {"confirm": "wrong"})
|
||||||
|
assert Project.objects.filter(pk=project.pk).exists()
|
||||||
|
client.post(url("delete", project), {"confirm": "intent"})
|
||||||
|
assert not Project.objects.filter(pk=project.pk).exists()
|
||||||
|
assert not Feature.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_members_must_be_on_the_org_team(owner, make_user, project):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
services.add_member(project, owner, user=make_user())
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaving_org_drops_project_roles(editor, org, project):
|
||||||
|
remove_org_member(org.memberships.get(user=editor), editor)
|
||||||
|
assert not ProjectMembership.objects.filter(user=editor).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_adds_member_via_page(client, owner, collaborator, project):
|
||||||
|
client.force_login(owner)
|
||||||
|
client.post(url("members", project), {"user": collaborator.pk, "role": "collaborator"})
|
||||||
|
assert project.memberships.filter(user=collaborator).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_org_page_lists_visible_projects(client, owner, collaborator, org, project):
|
||||||
|
org_page = reverse("orgs:detail", kwargs={"org_slug": org.slug})
|
||||||
|
link = f'href="{url("home", project)}"'
|
||||||
|
client.force_login(owner)
|
||||||
|
assert link in client.get(org_page).content.decode()
|
||||||
|
client.force_login(collaborator)
|
||||||
|
assert link not in client.get(org_page).content.decode()
|
||||||
59
uv.lock
generated
59
uv.lock
generated
|
|
@ -394,6 +394,8 @@ dependencies = [
|
||||||
{ name = "django-allauth", extra = ["socialaccount"] },
|
{ name = "django-allauth", extra = ["socialaccount"] },
|
||||||
{ name = "django-storages", extra = ["s3"] },
|
{ name = "django-storages", extra = ["s3"] },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "nh3" },
|
||||||
{ name = "python-ulid" },
|
{ name = "python-ulid" },
|
||||||
{ name = "sentry-sdk", extra = ["django"] },
|
{ name = "sentry-sdk", extra = ["django"] },
|
||||||
{ name = "whitenoise" },
|
{ name = "whitenoise" },
|
||||||
|
|
@ -412,6 +414,8 @@ requires-dist = [
|
||||||
{ name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.21" },
|
{ name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.21" },
|
||||||
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
|
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
|
||||||
{ name = "gunicorn", specifier = ">=26.2.0" },
|
{ name = "gunicorn", specifier = ">=26.2.0" },
|
||||||
|
{ name = "markdown-it-py", specifier = ">=4.2.0" },
|
||||||
|
{ name = "nh3", specifier = ">=0.3.7" },
|
||||||
{ name = "python-ulid", specifier = ">=4.0.1" },
|
{ name = "python-ulid", specifier = ">=4.0.1" },
|
||||||
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.73.0" },
|
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.73.0" },
|
||||||
{ name = "whitenoise", specifier = ">=6.12.0" },
|
{ name = "whitenoise", specifier = ">=6.12.0" },
|
||||||
|
|
@ -433,6 +437,61 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 },
|
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nh3"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz", hash = "sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848", size = 25662 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/b594f0e86856b37e182fb663283da419eea6424972506e640e890885467f/nh3-0.3.7-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba", size = 1471147 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/60/847a21339f095c4d4c655af31fa2d18b174585bcc210709facacc7ce205c/nh3-0.3.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b", size = 820463 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/7f/1a103e00aaf5e59f2dee4c2709aac609bb2d4bb74fddaf0dcfade11ed87b/nh3-0.3.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32", size = 861456 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/4a/e9c436089a0c80b928011ead0efd156aa7639a19b6064ef58dcedcab8369/nh3-0.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa", size = 1023930 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/5c/aa1468e3e281e78d2b3b7d762ccba59f681af355e971dbd255d5903f7b86/nh3-0.3.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac", size = 1102614 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/9f/57d186d9d3dd38905dc12dddb3484406cdf6aa0b1ce33639a2d277d4ee1c/nh3-0.3.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102", size = 1059915 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/53/097a5ad0b34b15d67a472ef849165a54209fa5fbd3e639801c6fe439ba28/nh3-0.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a", size = 1047402 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/a7/c57a2c70534418310889a65ccfac3525e62f0bc0a8613225903403755ce7/nh3-0.3.7-cp314-cp314t-win32.whl", hash = "sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946", size = 619895 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/b7/efda1d0a611d940bdfde6893bde1ea6b7b7d48c31273aea48e35b822fd58/nh3-0.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d", size = 633456 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/18/3ab564595cb88196f50d26e163ed0fd2acc731ab26ac615df91981885887/nh3-0.3.7-cp314-cp314t-win_arm64.whl", hash = "sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877", size = 611003 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/0d/c257754bf57f829f307aa226bbe136d3a1356b5a0d08324c7b6bd2a8aacd/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5", size = 1493959 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/42/a687e7091928806e514f89fa2666f25ec9bfe0a902fc4402b25e51ce408b/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479", size = 859615 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/05/b0e6bef633549a23347d5462aa288fcc42381e7918482062ca3cb456242a/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506", size = 839872 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/40/2a0921d45b20828708bcb56887e47dcf8cae13818de5bf9a01308d348712/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086", size = 1091325 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/d1/9d70e0e418a48280ec0ddc6c1b08b4b1136ebcc31a1625e57ff5c665fa51/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563", size = 1042482 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/a7/02dd159d4e71f98607d8d4249cddb7561e77be1a8e4dec77d76e1b68fc99/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174", size = 946868 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42", size = 832161 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/e3/3212c1a5b5745245d7f18885207bbddb34c56075f34dd682bd539aad55cc/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8", size = 849791 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/64/9e36594efad6c290de4240d02cb2bd80c339a4ab1c4de66e599ffa6d9d81/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493", size = 875473 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/0c/1a8985fd43fea5530c0ac890b6f0b423770ee72f111b70b7a77f2dec243a/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd", size = 1036463 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/5d/891e533b716cf00df76ad0ba6485dcfd14d59a6430a3cc99057c4c04004e/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac", size = 1116029 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/e5/ae8c0782fce74fb6fcf7234bb3d4017f37ce181b4f9d29369eab21c50a04/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62", size = 1076589 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/a4/c3423351e8d864ad756e85e15f0c01433361f14d34e4ed156482c0518f2a/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af", size = 1058871 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/6a/478f153f1d7c0baaa3d1e8bb5fdcee3a6235f90fe44ea969a9d4e2b8c47a/nh3-0.3.7-cp38-abi3-win32.whl", hash = "sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59", size = 630729 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/b9/34433ccb1f0fe6968dabbb7d4bf5721c6221878ef07832748c06655a6a80/nh3-0.3.7-cp38-abi3-win_amd64.whl", hash = "sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc", size = 644462 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oauthlib"
|
name = "oauthlib"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue