Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
b4aeacb
add url for agreement spage
JacobCoffee Oct 1, 2026
22a5640
activate agreements app
JacobCoffee Oct 1, 2026
22469f8
ai: docs new agreements
JacobCoffee Oct 1, 2026
9045f1d
dont source priv files
JacobCoffee Oct 1, 2026
70d9352
add doc on doc displau
JacobCoffee Oct 1, 2026
bff8ac6
document packages
JacobCoffee Oct 6, 2026
6e96630
the agreements app after a lot of review and rewritting 🫠
JacobCoffee Oct 6, 2026
f40a9a5
Merge branch 'main' into agreements
JacobCoffee Oct 6, 2026
ea2fb16
Fix published terms slug mutability from PR #3153 review
JacobCoffee Oct 6, 2026
ade1e00
Disable incomplete custom contract creation in admin (#3153)
JacobCoffee Oct 6, 2026
70d2f48
Escape legal names in signature records (#3153)
JacobCoffee Oct 6, 2026
efff62f
Lock order drafts through edits to preserve concurrent offers (#3153)
JacobCoffee Oct 6, 2026
8f9d926
Lock order drafts before deletion (#3153)
JacobCoffee Oct 6, 2026
6fb707f
Keep published edit terms versions separate from the editor (#3153)
JacobCoffee Oct 6, 2026
da9a7d2
Lock custom contract drafts throughout edits (#3153)
JacobCoffee Oct 6, 2026
634a0e1
Protect concurrently offered contracts from draft deletion (#3153)
JacobCoffee Oct 6, 2026
afb7db8
Email the actual uploaded executed PDF when available (#3153)
JacobCoffee Oct 6, 2026
488c9cf
Return custom-contract customers to accessible agreement records (#3153)
JacobCoffee Oct 6, 2026
ea0d91f
Allow staff to retry executed-copy delivery without re-signing (#3153)
JacobCoffee Oct 6, 2026
c1696cb
Refresh signing-link digest with the revised document (#3153)
JacobCoffee Oct 6, 2026
e863a15
Test delivery recovery independently of global logging configuration
JacobCoffee Oct 6, 2026
d096a45
Make custom contract admin read-only to preserve locked workflows (#3…
JacobCoffee Oct 6, 2026
30d8124
Recheck order signing authorization under the draft lock (#3153)
JacobCoffee Oct 6, 2026
18f3ff8
Recheck order offer authorization under the draft lock (#3153)
JacobCoffee Oct 6, 2026
fdf0444
Exclude URL-tracking scripts from signing-link responses (#3153)
JacobCoffee Oct 6, 2026
6f6ef9c
Fix agreement authoring, signing and order UI papercuts
JacobCoffee Oct 6, 2026
ffc190b
swap to usin groups
JacobCoffee Oct 6, 2026
82ce984
update tmplates
JacobCoffee Oct 6, 2026
edb1316
fix doc
JacobCoffee Oct 6, 2026
44c41e0
more group
JacobCoffee Oct 6, 2026
8608235
check publishability
JacobCoffee Oct 6, 2026
c544842
perm checkin
JacobCoffee Oct 6, 2026
8c950d1
source new filters
JacobCoffee Oct 6, 2026
722fa9f
helpers
JacobCoffee Oct 6, 2026
4faecec
migrate
JacobCoffee Oct 6, 2026
fee3d40
Test group-based agreements authorization
JacobCoffee Oct 6, 2026
8443330
Recover from failed agreement signing invitations
JacobCoffee Oct 6, 2026
564aab6
Include cited terms in executed agreement delivery
JacobCoffee Oct 6, 2026
e9f44bc
Preserve agreement edits through revision conflicts
JacobCoffee Oct 6, 2026
a511ef2
Keep authenticated terms responses out of shared caches
JacobCoffee Oct 6, 2026
fb2f4e1
Report skipped agreement delivery when no recipient exists
JacobCoffee Oct 6, 2026
f0418b2
Keep terms public while published programs reference them
JacobCoffee Oct 6, 2026
91b906a
mythos-red-team: restrict group creation and changes
JacobCoffee Oct 6, 2026
11003bd
mythos-red-team: reserve draft account linkage for administrators
JacobCoffee Oct 6, 2026
a43af2e
mythos-red-team: keep privilege grants and privileged accounts from d…
JacobCoffee Oct 6, 2026
c25d602
mythos-red-team: restrict group deletion to superusers
JacobCoffee Oct 6, 2026
257f1b6
Add type annotations throughout the agreements app
JacobCoffee Oct 6, 2026
9699e57
Type agreements tests and fixtures
JacobCoffee Oct 6, 2026
7dbbd2f
Enforce agreements typing in CI
JacobCoffee Oct 6, 2026
75353c2
Apply suggestion from @JacobCoffee
JacobCoffee Oct 6, 2026
258e1f8
Apply suggestion from @JacobCoffee
JacobCoffee Oct 6, 2026
43327fa
Apply suggestion from @JacobCoffee
JacobCoffee Oct 6, 2026
b01d564
Prevent caching of private agreement pages
JacobCoffee Oct 6, 2026
0fa585a
Apply suggestion from @JacobCoffee
JacobCoffee Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ jobs:
- name: Install dependencies
run: pip install --group dev -e .

- name: Check agreements types
run: python -m mypy

- name: Run tests
run: python -Wd -m coverage run manage.py test -v2
env:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

docs/build
media/*
private-media/*
static-root/
static/stylesheets/mq.css
static/stylesheets/no-mq.css
Expand Down
1 change: 1 addition & 0 deletions apps/agreements/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""PSF agreements: published terms, documents offered for signature, and their signatures."""
230 changes: 230 additions & 0 deletions apps/agreements/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
"""Manage program configuration and browse immutable signing records."""

from __future__ import annotations

from typing import TYPE_CHECKING

from django.contrib import admin
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
from django.contrib.auth.models import Group

from apps.agreements.auth import can_prepare, is_administrator
from apps.agreements.models import (
Agreement,
AgreementRevision,
CustomContract,
Order,
OrderLine,
Program,
SignedCopy,
SigningLink,
Terms,
TermsVersion,
)

if TYPE_CHECKING:
from django.db.models import Model, QuerySet
from django.http import HttpRequest

admin.site.unregister(Group)


@admin.register(Group)
class GroupAdmin(BaseGroupAdmin):
"""Reserve role and permission administration for trusted identity admins."""

def has_add_permission(self, request: HttpRequest) -> bool:
"""Prevent staff from creating groups that confer privileged roles."""
return request.user.is_superuser and super().has_add_permission(request)

def has_change_permission(self, request: HttpRequest, obj: Group | None = None) -> bool:
"""Protect role names and permissions from delegated group editors."""
return request.user.is_superuser and super().has_change_permission(request, obj)

def has_delete_permission(self, request: HttpRequest, obj: Group | None = None) -> bool:
"""Keep delegated staff from deleting roles and the memberships they confer."""
return request.user.is_superuser and super().has_delete_permission(request, obj)


class _AgreementAdmin(admin.ModelAdmin):
"""Use agreement groups, never Django's model or superuser permissions."""

def has_module_permission(self, request: HttpRequest) -> bool:
return can_prepare(request.user)

def has_view_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
return can_prepare(request.user)


class _ConfigurationAdmin(_AgreementAdmin):
"""Allow only administrators to create and change configuration."""

def has_add_permission(self, request: HttpRequest) -> bool:
return is_administrator(request.user)

def has_change_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
return is_administrator(request.user)

def has_delete_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
return False


class _ReadOnlyInline(admin.TabularInline):
extra = 0
can_delete = False

def has_view_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
return can_prepare(request.user)

def has_delete_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
return False

def has_add_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
"""Leave creating records to the workflow, which checks them."""
return False

def has_change_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
"""Keep records unchanged."""
return False


class TermsVersionInline(_ReadOnlyInline):
"""Published versions; publish new ones from the terms editor."""

model = TermsVersion
fields = ("version", "sha256", "notes", "published_by", "published_at")
readonly_fields = fields


@admin.register(Terms)
class TermsAdmin(_ConfigurationAdmin):
"""Create terms here; edit and publish their text on the site."""

list_display = ("title", "slug", "is_public", "under_review")
fields = ("slug", "title", "is_public", "under_review")
inlines = (TermsVersionInline,)

def get_readonly_fields(self, request: HttpRequest, obj: Terms | None = None) -> tuple[str, ...]:
"""Keep published addresses and catalog references stable."""
if obj is not None and obj.versions.exists():
return ("slug",)
return ()


class RevisionInline(_ReadOnlyInline):
"""Every version of the text before signature."""

model = AgreementRevision
fields = ("revision", "note", "sha256", "created_by", "created_at")
readonly_fields = fields


class SignedCopyInline(_ReadOnlyInline):
"""Signed copies; download them from the agreement page."""

model = SignedCopy
fields = ("kind", "filename", "sha256", "uploaded_by", "uploaded_at")
readonly_fields = fields

def get_queryset(self, request: HttpRequest) -> QuerySet[SignedCopy]:
"""Leave the file contents in the database."""
return super().get_queryset(request).defer("content")


class SigningLinkInline(_ReadOnlyInline):
"""Links sent; tokens are not stored."""

model = SigningLink
fields = ("name", "email", "created_by", "created_at", "expires_at", "used_at")
readonly_fields = fields


@admin.register(Agreement)
class AgreementAdmin(_AgreementAdmin):
"""Browse agreements; every field is a record."""

list_display = ("title", "counterparty_name", "kind", "status", "offered_at")
list_filter = ("kind", "status", "signature_method")
search_fields = ("title", "counterparty_name", "signer_email", "counterparty_account__email")
inlines = (RevisionInline, SignedCopyInline, SigningLinkInline)

def get_readonly_fields(self, request: HttpRequest, obj: Agreement | None = None) -> list[str]:
"""Show everything, change nothing."""
return [field.name for field in self.model._meta.get_fields() if field.concrete] # noqa: SLF001 - Django admin pattern requires _meta access

def has_add_permission(self, request: HttpRequest) -> bool:
"""Agreements are offered from their draft."""
return False

def has_change_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
"""Keep immutable records behind the signing workflow."""
return False

def has_delete_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
"""Agreements are records."""
return False


@admin.register(CustomContract)
class CustomContractAdmin(_AgreementAdmin):
"""Browse custom contracts; write and offer them on the site."""

list_display = ("title", "counterparty_name", "created_by", "created")
search_fields = ("title", "counterparty_name")
readonly_fields = ("agreement", "created_by", "created", "modified")
raw_id_fields = ("counterparty_account",)

def has_add_permission(self, request: HttpRequest) -> bool:
"""Create contracts on the site, where their author is recorded."""
return False

def has_change_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
"""Keep edits behind the locked, draft-only site workflow."""
return False

def has_delete_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
"""Discard drafts through the locked site workflow."""
return False


@admin.register(Program)
class ProgramAdmin(_ConfigurationAdmin):
"""Configure programs privately; publication is an explicit staff decision."""

list_display = ("title", "slug", "is_public")
list_filter = ("is_public",)
search_fields = ("title", "slug")
fields = ("title", "slug", "description", "is_public", "definition")


class OrderLineInline(_ReadOnlyInline):
"""Inspect selections without bypassing the builder's validation."""

model = OrderLine
fields = ("agreement", "tier", "addons", "special_terms", "pricing")
readonly_fields = fields


@admin.register(Order)
class OrderAdmin(_AgreementAdmin):
"""Browse orders; use their customer-facing page for changes."""

list_display = ("legal_name", "program", "status", "created")
search_fields = ("legal_name", "customer_account__email")
inlines = (OrderLineInline,)

def get_readonly_fields(self, request: HttpRequest, obj: Order | None = None) -> list[str]:
"""Keep selections and snapshots behind the workflow."""
return [field.name for field in self.model._meta.fields] # noqa: SLF001 - Django admin field metadata

def has_add_permission(self, request: HttpRequest) -> bool:
"""Create orders through the validated builder."""
return False

def has_change_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
"""Keep order mutations behind the validated site workflow."""
return False

def has_delete_permission(self, request: HttpRequest, obj: Model | None = None) -> bool:
"""Discard drafts through the authorized order page."""
return False
17 changes: 17 additions & 0 deletions apps/agreements/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Django app configuration for PSF agreements."""

from django.apps import AppConfig


class AgreementsConfig(AppConfig):
"""Drafts, signatures, and published terms for every agreement the PSF signs."""

name = "apps.agreements"
label = "agreements"
verbose_name = "Agreements"
default_auto_field = "django.db.models.BigAutoField"

def ready(self) -> None:
"""Register the built-in document kinds."""
from apps.agreements import kinds # noqa: F401 - registers on import
from apps.agreements.orders import kinds as order_kinds # noqa: F401 - registers on import
58 changes: 58 additions & 0 deletions apps/agreements/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Agreement management is granted by named groups, never user permissions."""

from __future__ import annotations

from functools import wraps
from typing import TYPE_CHECKING, Concatenate

if TYPE_CHECKING:
from collections.abc import Callable, Iterable

from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest, HttpResponse

from apps.users.models import User

from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied

EDITORS = "Agreements Editors"
ADMINISTRATORS = "Agreements Administrators"


def _in_groups(user: User | AnonymousUser, names: Iterable[str]) -> bool:
return user.is_authenticated and user.is_active and user.groups.filter(name__in=names).exists()


def can_prepare(user: User | AnonymousUser) -> bool:
"""Allow either group to read records and prepare unoffered drafts."""
return _in_groups(user, (EDITORS, ADMINISTRATORS))


def is_administrator(user: User | AnonymousUser) -> bool:
"""Reserve configuration and agreement transitions for the administrator group."""
return _in_groups(user, (ADMINISTRATORS,))


def _role_required[**P](
check: Callable[[User | AnonymousUser], bool],
) -> Callable[
[Callable[Concatenate[HttpRequest, P], HttpResponse]], Callable[Concatenate[HttpRequest, P], HttpResponse]
]:
def decorate(
view: Callable[Concatenate[HttpRequest, P], HttpResponse],
) -> Callable[Concatenate[HttpRequest, P], HttpResponse]:
@login_required
@wraps(view)
def guarded(request: HttpRequest, *args: P.args, **kwargs: P.kwargs) -> HttpResponse:
if not check(request.user):
raise PermissionDenied
return view(request, *args, **kwargs)

return guarded

return decorate


preparer_required = _role_required(can_prepare)
administrator_required = _role_required(is_administrator)
Loading
Loading