diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39dcdf10a..b012d774f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.gitignore b/.gitignore index cf0bec2d5..b1ce33428 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ docs/build media/* +private-media/* static-root/ static/stylesheets/mq.css static/stylesheets/no-mq.css diff --git a/apps/agreements/__init__.py b/apps/agreements/__init__.py new file mode 100644 index 000000000..ac1a63477 --- /dev/null +++ b/apps/agreements/__init__.py @@ -0,0 +1 @@ +"""PSF agreements: published terms, documents offered for signature, and their signatures.""" diff --git a/apps/agreements/admin.py b/apps/agreements/admin.py new file mode 100644 index 000000000..7b93b0788 --- /dev/null +++ b/apps/agreements/admin.py @@ -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 diff --git a/apps/agreements/apps.py b/apps/agreements/apps.py new file mode 100644 index 000000000..d12ce8712 --- /dev/null +++ b/apps/agreements/apps.py @@ -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 diff --git a/apps/agreements/auth.py b/apps/agreements/auth.py new file mode 100644 index 000000000..61e43918b --- /dev/null +++ b/apps/agreements/auth.py @@ -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) diff --git a/apps/agreements/documents.py b/apps/agreements/documents.py new file mode 100644 index 000000000..a929a6c9d --- /dev/null +++ b/apps/agreements/documents.py @@ -0,0 +1,288 @@ +"""Render agreements and published terms as markdown, HTML, PDF, and DOCX. + +A document's markdown carries two placeholders, ``SIGNATURES`` and ``EFFECTIVE_DATE``, that are +filled in when it is rendered; everything else is the text the counterparty signs, and its +SHA-256 identifies it. User input must go through ``md()``. Documents may be written by PSF +staff, so pandoc runs sandboxed with raw output disabled and a filter that rejects images. +""" + +from __future__ import annotations + +import hashlib +import re +import string +import tempfile +from datetime import UTC +from functools import lru_cache +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pypandoc +from django.utils import timezone +from django.utils.dateformat import format as date_format +from unidecode import unidecode + +from pydotorg.markup import sanitize + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Sequence + from datetime import datetime + + from apps.agreements.models import Agreement, TermsVersion + +FILTER = str(Path(__file__).parent / "pandoc_filters" / "agreement.lua") +PANDOC_ARGS = ("--sandbox",) + +# Documents are escaped or staff-written; these extensions are off as a second layer so +# nothing in a document can reach the LaTeX engine or the page as raw markup. +MARKDOWN_FORMAT = "markdown-raw_tex-raw_html-raw_attribute-tex_math_dollars-tex_math_single_backslash" + +SIGNATURES = "AGREEMENTSIGNATURES" +EFFECTIVE_DATE = "AGREEMENTEFFECTIVEDATE" +PAGEBREAK = "::: pagebreak\n:::" + +PROVIDER_NAME = "Python Software Foundation" +PROVIDER_PREAMBLE = ( + "**PYTHON SOFTWARE FOUNDATION**, a Delaware nonprofit corporation and tax-exempt organization " + "under Section 501(c)(3) of the Internal Revenue Code (EIN 04-3594598), with its principal place " + "of business at 9450 SW Gemini Drive, ECM \\#90772, Beaverton, OR 97008 " + '(the "Provider" or the "PSF")' +) + +_PUNCTUATION = re.compile(f"([{re.escape(string.punctuation)}])") +_H2 = re.compile(r'

(.*?)

', re.DOTALL) +_TAGS = re.compile(r"<[^>]+>") +_LEGACY_TERMS_FINGERPRINT = re.compile( + r"(?Ppublished at \n]+>), SHA-256 " + r"(?:`[a-f0-9]{64}`|[a-f0-9]{16}(?: [a-f0-9]{16}){3})(?=\.)" +) +_LEGACY_FINGERPRINT_DESCRIPTION = re.compile(r"(?<=identified by its permanent address) and SHA\\?-256 hash(?=\.)") + + +def md(value: object) -> str: + """Escape user-supplied text so pandoc renders it literally, on a single line.""" + return _PUNCTUATION.sub(r"\\\1", " ".join(str(value).split())) + + +def sha256(text: str) -> str: + """Hash ``text`` with SHA-256 and return the hex digest.""" + return hashlib.sha256(text.encode()).hexdigest() + + +def date(value: datetime | None) -> str: + """Format a datetime as a long local date.""" + return date_format(timezone.localtime(value), "F j, Y") + + +def _utc(value: datetime) -> str: + return f"{value.astimezone(UTC):%Y-%m-%d %H:%M:%S} UTC" + + +def table( + header: tuple[str, str], + rows: Iterable[tuple[str, str]], + *, + widths: tuple[int, int] = (28, 52), + amounts: bool = False, +) -> list[str]: + """Return a two-column pipe table; cells are already escaped markdown. + + Pandoc sets a table full width, in the proportions of the separator row, only when a source + line exceeds 72 characters; the separator is always longer, so every table lines up. + """ + right = ":" if amounts else "" + separator = f"|:{'-' * widths[0]}|{'-' * widths[1]}{right}|" + out = [f"| {header[0]} | {header[1]} |", separator] + return [*out, *(f"| {label} | {value} |" for label, value in rows), ""] + + +def amounts_table(header: tuple[str, str], rows: Iterable[tuple[str, str]]) -> list[str]: + """Return a table whose second column holds right-aligned amounts.""" + return table(header, rows, widths=(56, 24), amounts=True) + + +# ---------- Signatures ---------- + + +def _signature_column(name: str, title: str, when: datetime | None, *, on_file: bool = False) -> list[str]: + blank = "\\_" * 18 + if not when: + return [f"By: {blank}", "Name:", "Title:", "Date:"] + by = "Signed copy on file" if on_file else f"*/s/ {md(name)}*" + return [f"By: {by}", f"Name: {md(name)}", f"Title: {md(title)}", f"Date: {date(when)}"] + + +def signature_table( + counterparty_name: str, psf: Sequence[str] | None = None, counterparty: Sequence[str] | None = None +) -> str: + """Two signature columns; ``psf`` and ``counterparty`` are ``_signature_column`` rows, or blank.""" + psf = psf or _signature_column("", "", None) + counterparty = counterparty or _signature_column("", "", None) + rows = [ + f"| **{PROVIDER_NAME.upper()}** | **{md(counterparty_name.upper())}** |", + "|:------------------------------------|:------------------------------------|", + ] + rows += [f"| {p} | {c} |" for p, c in zip(psf, counterparty, strict=True)] + return "\n".join(rows) + + +def preview_markdown(markdown: str, counterparty_name: str) -> str: + """Fill a draft's placeholders with blank signature lines.""" + return markdown.replace(SIGNATURES, signature_table(counterparty_name)).replace( + EFFECTIVE_DATE, "The date of the last signature below." + ) + + +def _signature_record(agreement: Agreement) -> list[str]: + copies = set(agreement.signed_copies.values_list("kind", flat=True)) + rows = [ + ("Reference", agreement.reference), + ("Document", f"{agreement.title}, revision {agreement.revision}"), + ] + rows += [ + (f"Terms: {version.terms.title}", f"Version {version.version}") + for version in agreement.terms_versions.select_related("terms") + ] + rows.append(("Signatory", f"{agreement.signer_name}, {agreement.signer_title} ({agreement.signer_email})")) + if agreement.signature_method == agreement.SignatureMethod.OFFLINE: + intro = ( + f"{md(agreement.counterparty_name)} signed a copy of this document outside python.org. " + "The signed copy is kept with the agreement." + ) + rows.append(("Signed", f"{date(agreement.signed_at)}, outside python.org")) + rows.append(("Signed copy", "On file")) + else: + how = ( + "an emailed signing link" if agreement.signature_method == agreement.SignatureMethod.LINK else "an account" + ) + intro = f"{md(agreement.counterparty_name)} signed this document on python.org with {how}." + rows.append(("Signed", _utc(cast("datetime", agreement.signed_at)))) + if agreement.countersigned_at: + rows.append(("PSF signatory", f"{agreement.countersigner_name}, {agreement.countersigner_title}")) + rows.append(("PSF signed", _utc(agreement.countersigned_at))) + if "executed" in copies: + rows.append(("Countersigned copy", "On file")) + record = table(("Field", "Value"), [(md(label), md(value)) for label, value in rows], widths=(30, 50)) + return [PAGEBREAK, "", "## Signature Record", "", intro, "", *record] + + +def final_markdown(agreement: Agreement) -> str: + """Return an offered agreement's document with its signatures and signature record filled in.""" + on_file = agreement.signature_method == agreement.SignatureMethod.OFFLINE + psf = counterparty = None + if agreement.countersigned_at: + psf = _signature_column(agreement.countersigner_name, agreement.countersigner_title, agreement.countersigned_at) + if agreement.signed_at: + counterparty = _signature_column( + agreement.signer_name, agreement.signer_title, agreement.signed_at, on_file=on_file + ) + effective = ( + date(agreement.countersigned_at) if agreement.countersigned_at else "The date of the last signature below." + ) + text = agreement.document_markdown.replace( + SIGNATURES, signature_table(agreement.counterparty_name, psf, counterparty) + ).replace(EFFECTIVE_DATE, effective) + if agreement.signed_at: + text += "\n" + "\n".join(_signature_record(agreement)) + return text + + +def terms_download_markdown(version: TermsVersion) -> str: + """Return a published terms version with a title block, for PDF and DOCX downloads.""" + terms = version.terms + header = [f"# {terms.title}", ""] + if terms.under_review: + header += ["**DRAFT — For Attorney Review**", ""] + header += [f"*Version {md(version.version)}, published at <{version.permanent_url}>*", ""] + return "\n".join([*header, version.markdown]) + + +# ---------- Rendering ---------- + + +def _convert(markdown: str | bytes, to: str, outputfile: str | None = None, extra_args: Iterable[str] = ()) -> str: + return pypandoc.convert_text( + markdown, + to, + format=MARKDOWN_FORMAT, + outputfile=outputfile, + filters=[FILTER], + extra_args=[*PANDOC_ARGS, *extra_args], + ) + + +@lru_cache(maxsize=32) +def _html(markdown: str | bytes) -> str: + return sanitize(_convert(markdown, "html5")) + + +def _toc_title(title: str) -> str: + """Lower-case exhibit headings, which the terms set in capitals, for the contents list.""" + if not title.isupper(): + return title + label, _, rest = title.partition(" — ") + return f"{label.title()} — {rest.capitalize()}" if rest else title.title() + + +def render_html(markdown: str | bytes) -> tuple[str, list[tuple[str, str]]]: + """Render markdown to sanitized HTML plus a table of contents of its sections.""" + html = _html(markdown) + toc = [(anchor, _toc_title(" ".join(_TAGS.sub("", title).split()))) for anchor, title in _H2.findall(html)] + return html, toc + + +def render_agreement_preview(markdown: str) -> tuple[str, bool]: + """Hide generated legacy fingerprint metadata in HTML, never in stored text or downloads.""" + preview = _LEGACY_TERMS_FINGERPRINT.sub(r"\g", markdown) + preview = _LEGACY_FINGERPRINT_DESCRIPTION.sub("", preview) + html, _ = render_html(preview) + return html, preview != markdown + + +def _convert_file(markdown: str | bytes, to: str, suffix: str, extra_args: Iterable[str] = ()) -> bytes: + with tempfile.NamedTemporaryFile(suffix=suffix) as out: + _convert(markdown, to, outputfile=out.name, extra_args=extra_args) + return Path(out.name).read_bytes() + + +# pdflatex (the only TeX engine in the image) sets Latin-1, Latin Extended-A, and common typography. +_PDFLATEX_LAST_NATIVE = 0x17F +_PDFLATEX_EXTRA = frozenset("‘’‚“”„–—…•€×·§¶†‡‰") # noqa: RUF001 - typographic characters pdflatex can set + + +def _pdflatex_safe(markdown: str) -> str: + """Transliterate characters pdflatex can't set, so a counterparty's name never breaks the PDF. + + Only the PDF rendering changes; HTML, DOCX, and the signed snapshot keep the exact text. + """ + + def replace(char: str) -> str: + if ord(char) <= _PDFLATEX_LAST_NATIVE or char in _PDFLATEX_EXTRA: + return char + return _PUNCTUATION.sub(r"\\\1", unidecode(char)) or "?" + + return "".join(replace(char) for char in markdown) + + +@lru_cache(maxsize=16) +def render_pdf(markdown: str) -> bytes: + """Render markdown to PDF bytes.""" + return _convert_file( + _pdflatex_safe(markdown), + "pdf", + ".pdf", + ["-V", "geometry:margin=1in", "-V", "fontsize=11pt", "-V", "linkcolor=black"], + ) + + +@lru_cache(maxsize=16) +def render_docx(markdown: str | bytes) -> bytes: + """Render markdown to DOCX bytes.""" + return _convert_file(markdown, "docx", ".docx") + + +RENDERERS: dict[str, Callable[[str], bytes]] = {"pdf": render_pdf, "docx": render_docx} +CONTENT_TYPES = { + "pdf": "application/pdf", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +} diff --git a/apps/agreements/forms/__init__.py b/apps/agreements/forms/__init__.py new file mode 100644 index 000000000..cabdc2fab --- /dev/null +++ b/apps/agreements/forms/__init__.py @@ -0,0 +1 @@ +"""Forms for signing agreements, publishing terms, and editing contracts.""" diff --git a/apps/agreements/forms/accounts.py b/apps/agreements/forms/accounts.py new file mode 100644 index 000000000..dc405a16e --- /dev/null +++ b/apps/agreements/forms/accounts.py @@ -0,0 +1,24 @@ +"""Account lookup shared by contract and order forms.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError + +if TYPE_CHECKING: + from apps.users.models import User + + +def account_for_email(email: str) -> User: + """Return the one active python.org account using ``email``, or raise ``ValidationError``.""" + accounts = list(get_user_model().objects.filter(email__iexact=email, is_active=True)[:2]) + if len(accounts) != 1: + msg = ( + "No python.org account uses this email." + if not accounts + else "More than one account uses this email; ask which one to use." + ) + raise ValidationError(msg) + return cast("User", accounts[0]) diff --git a/apps/agreements/forms/contracts.py b/apps/agreements/forms/contracts.py new file mode 100644 index 000000000..b06a1ed5c --- /dev/null +++ b/apps/agreements/forms/contracts.py @@ -0,0 +1,96 @@ +"""Forms for custom contracts and agreement document revisions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from django import forms + +from apps.agreements.documents import EFFECTIVE_DATE, SIGNATURES +from apps.agreements.forms.accounts import account_for_email +from apps.agreements.forms.validation import _check_markdown, _check_placeholders +from apps.agreements.models import CustomContract, Terms + +if TYPE_CHECKING: + from apps.users.models import User + +MAX_NOTE_LENGTH = 255 + + +class EditDocumentForm(forms.Form): + """Edit one agreement's text before it is signed.""" + + markdown = forms.CharField( + label="Document text", + strip=False, + widget=forms.Textarea(attrs={"rows": 30, "spellcheck": "true", "class": "markdown-editor"}), + help_text=f"Markdown. {SIGNATURES} marks the signature block and {EFFECTIVE_DATE} the effective date.", + ) + note = forms.CharField( + label="What changed and why", + max_length=MAX_NOTE_LENGTH, + help_text="Kept with the revision, for example: 'Net 45 payment terms, agreed with their counsel.'", + ) + base_sha256 = forms.CharField(widget=forms.HiddenInput, max_length=64) + + def clean_markdown(self) -> str: + """Normalize line endings and keep the signature placeholder.""" + markdown: str = self.cleaned_data["markdown"].replace("\r\n", "\n") + _check_placeholders(markdown, required=True) + _check_markdown(markdown) + return markdown + + +class CustomContractForm(forms.ModelForm): + """A one-off contract: who it is with, the text, and any terms it cites.""" + + instance: CustomContract + + counterparty_account_email = forms.EmailField( + required=False, + label="Counterparty's python.org account", + help_text="Email of the account that may sign online. Leave empty to email a signing link or " + "collect a signed copy instead.", + ) + + class Meta: + """Staff-editable fields; the signature block is added automatically.""" + + model = CustomContract + fields = ("title", "counterparty_name", "terms", "body_markdown") + widgets = {"body_markdown": forms.Textarea(attrs={"rows": 24, "class": "markdown-editor"})} + help_texts = { + "body_markdown": "Markdown. The parties, any cited terms, and the signature block are added " + "above and below this text." + } + + def __init__(self, *args: Any, can_link_accounts: bool = False, **kwargs: Any) -> None: + """Offer only terms with a published version, and show the current account.""" + super().__init__(*args, **kwargs) + cast("forms.ModelChoiceField", self.fields["terms"]).queryset = Terms.objects.filter( + versions__isnull=False + ).distinct() + self.can_link_accounts = can_link_accounts + if can_link_accounts: + account = self.instance.counterparty_account + self.fields["counterparty_account_email"].initial = account.email if account else "" + else: + del self.fields["counterparty_account_email"] + + def clean_counterparty_account_email(self) -> User | None: + """Resolve the email to exactly one account.""" + email = self.cleaned_data["counterparty_account_email"] + return account_for_email(email) if email else None + + def clean_body_markdown(self) -> str: + """Normalize line endings; placeholders are added automatically.""" + markdown: str = self.cleaned_data["body_markdown"].replace("\r\n", "\n") + _check_placeholders(markdown, required=False) + _check_markdown(markdown) + return markdown + + def save(self, commit: bool = True) -> CustomContract: + """Store the resolved account.""" + if self.can_link_accounts: + self.instance.counterparty_account = self.cleaned_data["counterparty_account_email"] + return cast("CustomContract", super().save(commit=commit)) diff --git a/apps/agreements/forms/signing.py b/apps/agreements/forms/signing.py new file mode 100644 index 000000000..5921920be --- /dev/null +++ b/apps/agreements/forms/signing.py @@ -0,0 +1,111 @@ +"""Forms for signing and countersigning agreements.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from django import forms +from django.core.exceptions import ValidationError +from django.utils import timezone + +from apps.agreements.models import SignedCopy +from apps.agreements.workflow import Upload + +if TYPE_CHECKING: + from datetime import date + + from django.core.files.uploadedfile import UploadedFile + + +class _SeenDocument(forms.Form): + """Carries the SHA-256 of the text the signer was shown, so edits can't slip in unseen.""" + + document_sha256 = forms.CharField(widget=forms.HiddenInput, max_length=64) + + +class SignForm(_SeenDocument): + """Acceptance on python.org.""" + + signer_name = forms.CharField(label="Full name", max_length=255) + signer_title = forms.CharField(label="Title", max_length=255) + accept = forms.BooleanField( + error_messages={"required": "Check the box to sign on behalf of the organization named above."} + ) + + +def _pdf_upload(file: UploadedFile | None) -> Upload | None: + """Return an ``Upload`` for a PDF of at most ``SignedCopy.MAX_BYTES``, or raise ``ValidationError``.""" + if not file: + return None + if cast("int", file.size) > SignedCopy.MAX_BYTES: + msg = f"Upload at most {SignedCopy.MAX_BYTES // (1024 * 1024)} MB." + raise ValidationError(msg) + content = file.read() + if not content.startswith(b"%PDF-"): + msg = "Upload the signed copy as a PDF." + raise ValidationError(msg) + return Upload(filename=cast("str", file.name), content=content) + + +class SignedCopyForm(_SeenDocument): + """Record a signature made outside python.org, such as through DocuSign or on paper.""" + + signed_copy = forms.FileField(label="Signed copy (PDF)") + signer_name = forms.CharField(label="Signatory's full name", max_length=255) + signer_title = forms.CharField(label="Signatory's title", max_length=255) + signer_email = forms.EmailField(label="Signatory's email") + signed_on = forms.DateField(label="Date signed", widget=forms.DateInput(attrs={"type": "date"})) + matches = forms.BooleanField( + error_messages={"required": "Confirm that the signed copy is this document, unchanged."} + ) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Keep upload labels distinct from the on-page account-signing form.""" + kwargs.setdefault("auto_id", "id_copy_%s") + super().__init__(*args, **kwargs) + + def clean_signed_copy(self) -> Upload | None: + """Accept a PDF only.""" + return _pdf_upload(self.cleaned_data.get("signed_copy")) + + def clean_signed_on(self) -> date: + """Reject signature dates in the future.""" + signed_on: date = self.cleaned_data["signed_on"] + if signed_on > timezone.localdate(): + msg = "Enter the date the copy was signed; it can't be in the future." + raise ValidationError(msg) + return signed_on + + +class SigningLinkForm(forms.Form): + """Email a named signatory a one-time link to sign.""" + + name = forms.CharField(label="Signatory's full name", max_length=255) + email = forms.EmailField(label="Signatory's email") + + +class CountersignForm(forms.Form): + """PSF countersignature by an authorized officer, optionally with a fully executed copy.""" + + name = forms.CharField(label="Full name", max_length=255) + title = forms.CharField(label="Title", max_length=255) + executed_copy = forms.FileField( + label="Fully executed copy (PDF, optional)", + required=False, + help_text="If both parties signed outside python.org, keep that copy with the agreement.", + ) + accept = forms.BooleanField( + error_messages={"required": "Confirm that you are authorized to sign for the Python Software Foundation."} + ) + + def clean_executed_copy(self) -> Upload | None: + """Accept a PDF only.""" + return _pdf_upload(self.cleaned_data.get("executed_copy")) + + +class DeclineForm(forms.Form): + """The PSF declines a signed agreement.""" + + reason = forms.CharField( + widget=forms.Textarea(attrs={"rows": 3}), help_text="Shown to the counterparty on their agreement." + ) diff --git a/apps/agreements/forms/terms.py b/apps/agreements/forms/terms.py new file mode 100644 index 000000000..571fc46d8 --- /dev/null +++ b/apps/agreements/forms/terms.py @@ -0,0 +1,81 @@ +"""Forms for editing and publishing terms.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from django import forms + +from apps.agreements.forms.validation import _check_markdown, _check_placeholders +from apps.agreements.models import TermsVersion + +if TYPE_CHECKING: + from apps.agreements.models import Terms + + +class TermsForm(forms.Form): + """Edit terms: save the working copy, or publish it as a new version.""" + + SAVE = "save" + PUBLISH = "publish" + + markdown = forms.CharField( + label="Terms text", + strip=False, + widget=forms.Textarea(attrs={"rows": 30, "spellcheck": "true", "class": "markdown-editor"}), + help_text="Markdown. Headings starting with '## ' become the contents list.", + ) + under_review = forms.BooleanField(required=False, label="Draft for attorney review") + is_public = forms.BooleanField( + required=False, + label="Make published versions public", + help_text="Leave unchecked to restrict terms to staff and the parties to agreements that cite them.", + ) + version = forms.SlugField( + required=False, + max_length=64, + help_text="A new label for this version, for example today's date. Part of its permanent address.", + ) + notes = forms.CharField( + required=False, widget=forms.Textarea(attrs={"rows": 3}), help_text="What changed, for the version history." + ) + action = forms.ChoiceField(choices=((SAVE, "Save draft"), (PUBLISH, "Publish")), required=False) + + def __init__(self, *args: Any, terms: Terms, can_publish: bool, **kwargs: Any) -> None: + """Bind terms and expose publication fields only to administrators.""" + super().__init__(*args, **kwargs) + self.terms = terms + if not can_publish: + for field in ("under_review", "is_public", "version", "notes"): + del self.fields[field] + cast("forms.ChoiceField", self.fields["action"]).choices = ((self.SAVE, "Save draft"),) + + def clean_markdown(self) -> str: + """Normalize line endings; terms don't carry signature placeholders.""" + markdown: str = self.cleaned_data["markdown"].replace("\r\n", "\n") + _check_placeholders(markdown, required=False) + _check_markdown(markdown) + return markdown + + def clean_is_public(self) -> bool: + """Check visibility without changing the terms before the form is valid.""" + is_public: bool = self.cleaned_data["is_public"] + self.terms.validate_is_public(is_public) + return is_public + + def clean(self) -> dict[str, Any]: + """Require a new version label, notes, and a change before publishing.""" + cleaned = cast("dict[str, Any]", super().clean()) + if cleaned.get("action") != self.PUBLISH: + return cleaned + version = cleaned.get("version") + if not version: + self.add_error("version", "Enter a label for the new version.") + elif TermsVersion.objects.filter(terms=self.terms, version=version).exists(): + self.add_error("version", "This label is already used; published versions never change.") + if not cleaned.get("notes", "").strip(): + self.add_error("notes", "Describe what changed.") + current = self.terms.current_version + if current and cleaned.get("markdown") == current.markdown: + self.add_error("markdown", "The text is the same as the current version.") + return cleaned diff --git a/apps/agreements/forms/validation.py b/apps/agreements/forms/validation.py new file mode 100644 index 000000000..7bbb01bc8 --- /dev/null +++ b/apps/agreements/forms/validation.py @@ -0,0 +1,27 @@ +"""Placeholder validation shared by editable agreements, contracts, and terms.""" + +from django.core.exceptions import ValidationError + +from apps.agreements.documents import EFFECTIVE_DATE, SIGNATURES, render_html + + +def _check_placeholders(markdown: str, *, required: bool) -> None: + """Documents carry the signature placeholder exactly once; staff text written elsewhere, never.""" + count = markdown.count(SIGNATURES) + if required and count != 1: + msg = f"Keep {SIGNATURES} exactly once, on its own line: the signature block goes there." + raise ValidationError(msg) + if not required and (count or EFFECTIVE_DATE in markdown): + msg = f"Remove {SIGNATURES} and {EFFECTIVE_DATE}; they are added automatically." + raise ValidationError(msg) + + +def _check_markdown(markdown: str) -> None: + """Report unsupported document content without hiding renderer failures.""" + try: + render_html(markdown) + except RuntimeError as error: + if "Agreement documents cannot embed images or load external resources." not in str(error): + raise + msg = "Remove embedded images. Agreement documents cannot load images or external resources." + raise ValidationError(msg) from error diff --git a/apps/agreements/kinds.py b/apps/agreements/kinds.py new file mode 100644 index 000000000..f2a96113e --- /dev/null +++ b/apps/agreements/kinds.py @@ -0,0 +1,65 @@ +"""The built-in kind for one-off contracts written by PSF staff.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from apps.agreements.models import TermsVersion + from apps.agreements.registry import AgreementDetails + +from apps.agreements.documents import PROVIDER_PREAMBLE, SIGNATURES, md +from apps.agreements.models import CustomContract +from apps.agreements.registry import Kind, register + + +@register +class CustomContractKind(Kind[CustomContract]): + """A contract written in full by PSF staff, optionally citing published terms.""" + + slug = "custom" + name = "Custom contract" + model = CustomContract + + def details(self, subject: CustomContract) -> AgreementDetails: + """Title and counterparty come straight from the draft.""" + return { + "title": subject.title, + "counterparty_name": subject.counterparty_name, + "counterparty_account": subject.counterparty_account, + } + + def cited_terms_versions(self, subject: CustomContract) -> list[TermsVersion]: + """Cite the current version of the chosen terms, if any.""" + return [cast("TermsVersion", subject.terms.current_version)] if subject.terms else [] + + def compose(self, subject: CustomContract) -> str: + """Parties, an optional citation of the terms, the staff-written text, and signatures.""" + parties = ( + f"This agreement is entered into between {PROVIDER_PREAMBLE}, and **{md(subject.counterparty_name)}** " + '(the "Counterparty").' + ) + parts = [f"# {md(subject.title)}", "", parties, ""] + if subject.terms: + version = cast("TermsVersion", subject.terms.current_version) + parts += [ + ( + f"It incorporates by reference the **{md(subject.terms.title)}**, version {md(version.version)}, " + f"published at <{version.permanent_url}>. If this agreement " + "conflicts with those terms, this agreement controls." + ), + "", + ] + return "\n".join( + [ + *parts, + subject.body_markdown.strip(), + "", + "## Signatures", + "", + "Each party signs as of the date of the last signature below.", + "", + SIGNATURES, + "", + ] + ) diff --git a/apps/agreements/management/__init__.py b/apps/agreements/management/__init__.py new file mode 100644 index 000000000..8fc78c660 --- /dev/null +++ b/apps/agreements/management/__init__.py @@ -0,0 +1 @@ +"""Operator tools for private agreement configuration.""" diff --git a/apps/agreements/management/commands/__init__.py b/apps/agreements/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/agreements/management/commands/import_agreement_program.py b/apps/agreements/management/commands/import_agreement_program.py new file mode 100644 index 000000000..4b413ef56 --- /dev/null +++ b/apps/agreements/management/commands/import_agreement_program.py @@ -0,0 +1,72 @@ +"""Import privately supplied program configuration without committing commercial content.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from django.core.exceptions import ValidationError +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from apps.agreements.models import Program, Terms, TermsVersion + +if TYPE_CHECKING: + from django.core.management.base import CommandParser + + +class Command(BaseCommand): + """Load a program and optional immutable terms versions from an operator-supplied JSON file.""" + + help = "Import an agreement program from private JSON. Use '-' to read stdin. Existing terms versions never change." + + def add_arguments(self, parser: CommandParser) -> None: + """Accept a private file path or stdin.""" + parser.add_argument("path") + + def handle(self, *args: Any, **options: Any) -> None: + """Validate and apply the import atomically without printing its private content.""" + try: + text = sys.stdin.read() if options["path"] == "-" else Path(options["path"]).read_text(encoding="utf-8") + data = json.loads(text) + with transaction.atomic(): + self._import(data) + except (OSError, json.JSONDecodeError, ValidationError, KeyError, TypeError, ValueError) as exc: + raise CommandError(str(exc)) from exc + self.stdout.write(self.style.SUCCESS("Program configuration imported.")) + + @staticmethod + def _import(data: object) -> None: + if not isinstance(data, dict) or set(data) - {"program", "terms"}: + msg = "Expected an object with 'program' and optional 'terms'." + raise ValueError(msg) + for entry in data.get("terms", []): + metadata = {key: entry[key] for key in ("title", "under_review", "is_public") if key in entry} + terms, _ = Terms.objects.get_or_create(slug=entry["slug"], defaults=metadata) + for key, value in metadata.items(): + setattr(terms, key, value) + terms.full_clean() + terms.save() + for item in entry.get("versions", []): + existing = terms.versions.filter(version=item["version"]).first() + if existing: + if existing.markdown != item["markdown"]: + msg = "An imported terms version differs from the stored version; use a new version label." + raise ValueError(msg) + continue + version = TermsVersion( + terms=terms, version=item["version"], markdown=item["markdown"], notes=item.get("notes", "") + ) + version.full_clean(exclude=["sha256"]) + version.save() + fields = data["program"] + if not isinstance(fields, dict) or set(fields) - {"slug", "title", "description", "definition", "is_public"}: + msg = "Program fields: slug, title, description, definition, is_public." + raise ValueError(msg) + program = Program.objects.select_for_update().filter(slug=fields["slug"]).first() or Program() + for key, value in fields.items(): + setattr(program, key, value) + program.full_clean() + program.save() diff --git a/apps/agreements/migrations/0001_initial.py b/apps/agreements/migrations/0001_initial.py new file mode 100644 index 000000000..03d3a1f24 --- /dev/null +++ b/apps/agreements/migrations/0001_initial.py @@ -0,0 +1,344 @@ +# Generated by Django 5.2.16 on 2026-09-30 16:46 + +import uuid + +import django.db.models.deletion +import django.utils.timezone +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="Agreement", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("kind", models.CharField(db_index=True, max_length=32)), + ("title", models.CharField(max_length=255)), + ("counterparty_name", models.CharField(max_length=255)), + ( + "status", + models.CharField( + choices=[ + ("offered", "Awaiting signature"), + ("signed", "Awaiting countersignature"), + ("executed", "Executed"), + ("declined", "Declined"), + ("withdrawn", "Withdrawn"), + ], + db_index=True, + default="offered", + max_length=16, + ), + ), + ("document_markdown", models.TextField()), + ("document_sha256", models.CharField(max_length=64)), + ("revision", models.PositiveIntegerField(default=1)), + ("offered_at", models.DateTimeField(auto_now_add=True)), + ( + "signature_method", + models.CharField( + blank=True, + choices=[ + ("account", "Signed on python.org"), + ("link", "Signed on python.org with an emailed link"), + ("offline", "Signed copy on file"), + ], + max_length=16, + ), + ), + ("signer_name", models.CharField(blank=True, max_length=255)), + ("signer_title", models.CharField(blank=True, max_length=255)), + ("signer_email", models.EmailField(blank=True, max_length=254)), + ("signed_at", models.DateTimeField(blank=True, null=True)), + ("signed_ip", models.GenericIPAddressField(blank=True, null=True)), + ("signed_user_agent", models.CharField(blank=True, max_length=512)), + ("countersigner_name", models.CharField(blank=True, max_length=255)), + ("countersigner_title", models.CharField(blank=True, max_length=255)), + ("countersigned_at", models.DateTimeField(blank=True, null=True)), + ("declined_at", models.DateTimeField(blank=True, null=True)), + ("decline_reason", models.TextField(blank=True)), + ("withdrawn_at", models.DateTimeField(blank=True, null=True)), + ( + "counterparty_account", + models.ForeignKey( + blank=True, + help_text="The python.org account that may view and sign it online.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="agreements_to_sign", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "countersigned_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "declined_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "offered_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ( + "signature_recorded_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "withdrawn_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ("-offered_at",), + "permissions": (("manage_agreement", "Can prepare, edit, send, and countersign agreements"),), + }, + ), + migrations.CreateModel( + name="SigningLink", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("email", models.EmailField(max_length=254)), + ("token_sha256", models.CharField(max_length=64, unique=True)), + ("expires_at", models.DateTimeField()), + ("used_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "agreement", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="signing_links", + to="agreements.agreement", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Terms", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.SlugField(unique=True)), + ("title", models.CharField(max_length=255)), + ( + "under_review", + models.BooleanField( + default=False, help_text="Mark the terms as a draft for attorney review wherever they appear." + ), + ), + ("draft_markdown", models.TextField(blank=True, help_text="Working copy; not public until published.")), + ("draft_updated_at", models.DateTimeField(blank=True, null=True)), + ( + "draft_updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name_plural": "terms", + "ordering": ("title",), + }, + ), + migrations.CreateModel( + name="CustomContract", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("title", models.CharField(max_length=255)), + ("counterparty_name", models.CharField(max_length=255, verbose_name="counterparty's legal name")), + ("body_markdown", models.TextField(verbose_name="contract text")), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ( + "agreement", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="custom_contract", + to="agreements.agreement", + ), + ), + ( + "counterparty_account", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ( + "terms", + models.ForeignKey( + blank=True, + help_text="Standing terms this contract cites and incorporates, if any.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="agreements.terms", + ), + ), + ], + options={ + "ordering": ("-created",), + }, + ), + migrations.CreateModel( + name="TermsVersion", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("version", models.SlugField(max_length=64)), + ("markdown", models.TextField()), + ("sha256", models.CharField(editable=False, max_length=64)), + ("notes", models.TextField(blank=True, help_text="What changed from the previous version.")), + ("published_at", models.DateTimeField(default=django.utils.timezone.now)), + ( + "published_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "terms", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="versions", to="agreements.terms" + ), + ), + ], + options={ + "ordering": ("-published_at", "-pk"), + }, + ), + migrations.AddField( + model_name="agreement", + name="terms_versions", + field=models.ManyToManyField(blank=True, related_name="agreements", to="agreements.termsversion"), + ), + migrations.CreateModel( + name="AgreementRevision", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("revision", models.PositiveIntegerField()), + ("markdown", models.TextField()), + ("sha256", models.CharField(max_length=64)), + ("note", models.CharField(max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "agreement", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="revisions", to="agreements.agreement" + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "ordering": ("-revision",), + "constraints": [models.UniqueConstraint(fields=("agreement", "revision"), name="one_row_per_revision")], + }, + ), + migrations.CreateModel( + name="SignedCopy", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "kind", + models.CharField( + choices=[("customer", "Signed by the counterparty"), ("executed", "Signed by both parties")], + max_length=16, + ), + ), + ("filename", models.CharField(max_length=255)), + ("content", models.BinaryField()), + ("sha256", models.CharField(max_length=64)), + ("uploaded_at", models.DateTimeField(auto_now_add=True)), + ( + "agreement", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="signed_copies", + to="agreements.agreement", + ), + ), + ( + "uploaded_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "verbose_name_plural": "signed copies", + "constraints": [ + models.UniqueConstraint(fields=("agreement", "kind"), name="one_signed_copy_of_each_kind") + ], + }, + ), + migrations.AddConstraint( + model_name="termsversion", + constraint=models.UniqueConstraint(fields=("terms", "version"), name="one_terms_version_per_label"), + ), + ] diff --git a/apps/agreements/migrations/0002_configurable_programs.py b/apps/agreements/migrations/0002_configurable_programs.py new file mode 100644 index 000000000..e0d24acfd --- /dev/null +++ b/apps/agreements/migrations/0002_configurable_programs.py @@ -0,0 +1,141 @@ +# Generated by Django 5.2.16 on 2026-09-30 23:29 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import apps.agreements.orders.catalog + + +class Migration(migrations.Migration): + dependencies = [ + ("agreements", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Program", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.SlugField(unique=True)), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ( + "is_public", + models.BooleanField( + default=False, + help_text="Allow anyone to browse this program and signed-in customers to start orders. Does not publish its terms. Leave unchecked for staff-prepared orders only.", + ), + ), + ( + "definition", + models.JSONField( + help_text="Catalog, pricing rules and document copy.", + validators=[apps.agreements.orders.catalog.validate_catalog], + ), + ), + ], + options={ + "ordering": ("title",), + }, + ), + migrations.AddField( + model_name="terms", + name="is_public", + field=models.BooleanField( + default=False, + help_text="Make published versions readable by everyone. Otherwise only staff and the parties may read them.", + ), + ), + migrations.CreateModel( + name="Order", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ("legal_name", models.CharField(max_length=255, verbose_name="legal name")), + ("jurisdiction", models.CharField(max_length=100, verbose_name="state or country of organization")), + ( + "entity_type", + models.CharField( + help_text="For example: corporation or limited liability company.", max_length=100 + ), + ), + ("address", models.TextField(verbose_name="principal place of business")), + ("covered_entities", models.TextField(help_text="One name per line.", verbose_name="covered entities")), + ("discount", models.CharField(default="none", max_length=64)), + ("authorized_contacts", models.JSONField(default=list)), + ("billing_contact_name", models.CharField(max_length=255)), + ("billing_contact_email", models.EmailField(max_length=254)), + ( + "notices_contact", + models.TextField(blank=True, help_text="Only if different from the principal place of business."), + ), + ("catalog_snapshot", models.JSONField(blank=True, default=dict, editable=False)), + ("program_title_snapshot", models.CharField(blank=True, editable=False, max_length=255)), + ( + "agreement", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="order", + to="agreements.agreement", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="orders_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "customer_account", + models.ForeignKey( + blank=True, + help_text="The python.org account that may view the order and sign it online.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="agreement_orders", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "program", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="orders", to="agreements.program" + ), + ), + ], + options={ + "ordering": ("-created",), + }, + ), + migrations.CreateModel( + name="OrderLine", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("agreement", models.SlugField(max_length=64)), + ("tier", models.CharField(max_length=64)), + ("addons", models.JSONField(blank=True, default=dict)), + ("special_terms", models.TextField(blank=True, help_text="Set by staff only; leave empty for none.")), + ("pricing", models.JSONField(blank=True, default=dict, editable=False)), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="agreements", to="agreements.order" + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint(fields=("order", "agreement"), name="one_order_line_per_agreement") + ], + }, + ), + ] diff --git a/apps/agreements/migrations/0003_agreement_groups.py b/apps/agreements/migrations/0003_agreement_groups.py new file mode 100644 index 000000000..613b7daa5 --- /dev/null +++ b/apps/agreements/migrations/0003_agreement_groups.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db import migrations + +if TYPE_CHECKING: + from django.apps.registry import Apps + from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def create_agreement_groups(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + groups = apps.get_model("auth", "Group").objects.using(schema_editor.connection.alias) + for name in ("Agreements Editors", "Agreements Administrators"): + groups.get_or_create(name=name) + apps.get_model("auth", "Permission").objects.using(schema_editor.connection.alias).filter( + content_type__app_label="agreements", content_type__model="agreement", codename="manage_agreement" + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("agreements", "0002_configurable_programs"), + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.AlterModelOptions(name="agreement", options={"ordering": ("-offered_at",)}), + migrations.RunPython(create_agreement_groups, migrations.RunPython.noop), + ] diff --git a/apps/agreements/migrations/__init__.py b/apps/agreements/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/agreements/models/__init__.py b/apps/agreements/models/__init__.py new file mode 100644 index 000000000..7171500f9 --- /dev/null +++ b/apps/agreements/models/__init__.py @@ -0,0 +1,25 @@ +"""Models registered by the agreements Django app.""" + +from apps.agreements.models.agreements import ( + Agreement, + AgreementRevision, + CustomContract, + SignedCopy, + SigningLink, +) +from apps.agreements.models.orders import Order, OrderLine, Program +from apps.agreements.models.terms import CANONICAL_ORIGIN, Terms, TermsVersion + +__all__ = [ + "CANONICAL_ORIGIN", + "Agreement", + "AgreementRevision", + "CustomContract", + "Order", + "OrderLine", + "Program", + "SignedCopy", + "SigningLink", + "Terms", + "TermsVersion", +] diff --git a/apps/agreements/models/agreements.py b/apps/agreements/models/agreements.py new file mode 100644 index 000000000..24fb5015a --- /dev/null +++ b/apps/agreements/models/agreements.py @@ -0,0 +1,292 @@ +"""Agreements the PSF signs, the published terms they cite, and their signatures. + +Domain drafts (configured orders, custom contracts, or sponsorships) register +a ``Kind``. Offering freezes the document as an ``Agreement``. Until it is signed, PSF staff can +edit it for that one counterparty; every edit is kept as a revision. The counterparty signs on +python.org, with an account or a one-time emailed link, or outside it, in which case the signed +copy is uploaded. The PSF countersigns. + +Standing terms are edited here too and published as versions. A new version applies to +documents offered afterwards; a published version never changes, because signed documents +cite it by address and SHA-256. +""" + +from __future__ import annotations + +import hashlib +import re +import uuid +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from django.contrib.auth.models import AnonymousUser + + from apps.agreements.models.orders import Order + from apps.agreements.registry import Kind + from apps.users.models import User + +from django.conf import settings +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.functional import cached_property + +from apps.agreements.auth import can_prepare +from apps.agreements.registry import get_kind + +_DOCUMENT_REFERENCE = re.compile(r"^\*Reference ([A-F0-9]{8})\*$", re.MULTILINE) + + +class Agreement(models.Model): + """One document offered to one counterparty, and its signatures.""" + + class Status(models.TextChoices): + """Lifecycle after the document is offered.""" + + OFFERED = "offered", "Awaiting signature" + SIGNED = "signed", "Awaiting countersignature" + EXECUTED = "executed", "Executed" + DECLINED = "declined", "Declined" + WITHDRAWN = "withdrawn", "Withdrawn" + + class SignatureMethod(models.TextChoices): + """How the counterparty signed.""" + + ACCOUNT = "account", "Signed on python.org" + LINK = "link", "Signed on python.org with an emailed link" + OFFLINE = "offline", "Signed copy on file" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + kind = models.CharField(max_length=32, db_index=True) + title = models.CharField(max_length=255) + counterparty_name = models.CharField(max_length=255) + counterparty_account = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="agreements_to_sign", + help_text="The python.org account that may view and sign it online.", + ) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.OFFERED, db_index=True) + + document_markdown = models.TextField() + document_sha256 = models.CharField(max_length=64) + revision = models.PositiveIntegerField(default=1) + terms_versions = models.ManyToManyField("agreements.TermsVersion", blank=True, related_name="agreements") + + offered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+") + offered_at = models.DateTimeField(auto_now_add=True) + + signature_method = models.CharField(max_length=16, choices=SignatureMethod.choices, blank=True) + signer_name = models.CharField(max_length=255, blank=True) + signer_title = models.CharField(max_length=255, blank=True) + signer_email = models.EmailField(blank=True) + signed_at = models.DateTimeField(null=True, blank=True) + signed_ip = models.GenericIPAddressField(null=True, blank=True) + signed_user_agent = models.CharField(max_length=512, blank=True) + signature_recorded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+" + ) + + countersigned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+" + ) + countersigner_name = models.CharField(max_length=255, blank=True) + countersigner_title = models.CharField(max_length=255, blank=True) + countersigned_at = models.DateTimeField(null=True, blank=True) + + declined_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+" + ) + declined_at = models.DateTimeField(null=True, blank=True) + decline_reason = models.TextField(blank=True) + + withdrawn_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+" + ) + withdrawn_at = models.DateTimeField(null=True, blank=True) + + class Meta: + """Newest first; management access comes from the agreement groups.""" + + ordering = ("-offered_at",) + + def __str__(self) -> str: + """Return the title and counterparty.""" + return f"{self.title}: {self.counterparty_name}" + + def get_absolute_url(self) -> str: + """Return the agreement page.""" + return reverse("agreements:detail", kwargs={"pk": self.pk}) + + @property + def reference(self) -> str: + """Use the reference printed in the frozen document, when it has one.""" + match = _DOCUMENT_REFERENCE.search(self.document_markdown) + return match[1] if match else str(self.pk).split("-")[0].upper() + + @cached_property + def kind_obj(self) -> Kind[Any]: + """Return the registered kind.""" + return get_kind(self.kind) + + @cached_property + def subject(self) -> CustomContract | Order | None: + """Return the domain object this agreement was offered for, while it still points here.""" + return self.kind_obj.subject(self) + + @property + def subject_url(self) -> str: + """Where the counterparty and staff follow this agreement: the domain page if there is one.""" + return self.subject.get_absolute_url() if self.subject else self.get_absolute_url() + + @property + def is_editable(self) -> bool: + """The text can change, and the agreement be signed, only while it awaits signature.""" + return self.status == self.Status.OFFERED + + def is_counterparty(self, user: User | AnonymousUser) -> bool: + """Whether ``user`` is the account that may sign online.""" + return self.counterparty_account_id is not None and self.counterparty_account_id == user.pk + + def can_view(self, user: User | AnonymousUser) -> bool: + """Allow agreement groups, the counterparty, and the subject's customer.""" + return can_prepare(user) or self.is_counterparty(user) or self.kind_obj.can_view(user, self) + + +class AgreementRevision(models.Model): + """One version of an agreement's text before signature: the offer and each staff edit.""" + + agreement = models.ForeignKey(Agreement, on_delete=models.CASCADE, related_name="revisions") + revision = models.PositiveIntegerField() + markdown = models.TextField() + sha256 = models.CharField(max_length=64) + note = models.CharField(max_length=255) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + """Newest first.""" + + ordering = ("-revision",) + constraints = (models.UniqueConstraint(fields=("agreement", "revision"), name="one_row_per_revision"),) + + def __str__(self) -> str: + """Return the agreement and revision.""" + return f"{self.agreement}, revision {self.revision}" + + +class SignedCopy(models.Model): + """A signed copy received outside python.org, for example through DocuSign or on paper. + + Stored in the database rather than media storage: media is public, these are contracts. + """ + + class Kind(models.TextChoices): + """Which signatures the copy carries.""" + + CUSTOMER = "customer", "Signed by the counterparty" + EXECUTED = "executed", "Signed by both parties" + + MAX_BYTES = 20 * 1024 * 1024 + + agreement = models.ForeignKey(Agreement, on_delete=models.PROTECT, related_name="signed_copies") + kind = models.CharField(max_length=16, choices=Kind.choices) + filename = models.CharField(max_length=255) + content = models.BinaryField() + sha256 = models.CharField(max_length=64) + uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+") + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + """One copy of each kind per agreement.""" + + verbose_name_plural = "signed copies" + constraints = (models.UniqueConstraint(fields=("agreement", "kind"), name="one_signed_copy_of_each_kind"),) + + def __str__(self) -> str: + """Return the agreement and kind.""" + return f"{self.agreement}: {self.get_kind_display()}" + + +class SigningLink(models.Model): + """A single-use link emailed to a named signatory who may not have a python.org account. + + Only a hash of the token is stored; the token itself exists only in the email. + """ + + agreement = models.ForeignKey(Agreement, on_delete=models.CASCADE, related_name="signing_links") + name = models.CharField(max_length=255) + email = models.EmailField() + token_sha256 = models.CharField(max_length=64, unique=True) + expires_at = models.DateTimeField() + used_at = models.DateTimeField(null=True, blank=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + """Newest first.""" + + ordering = ("-created_at",) + + def __str__(self) -> str: + """Return the recipient.""" + return f"{self.name} <{self.email}>" + + @staticmethod + def hash_token(token: str) -> str: + """Return the stored form of a token.""" + return hashlib.sha256(token.encode()).hexdigest() + + @property + def is_usable(self) -> bool: + """Unused, unexpired, and its agreement still awaits signature.""" + return ( + self.used_at is None + and self.expires_at > timezone.now() + and self.agreement.status == Agreement.Status.OFFERED + ) + + +class CustomContract(models.Model): + """A one-off contract written by PSF staff, for example with a PyCon venue or vendor.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + title = models.CharField(max_length=255) + counterparty_name = models.CharField("counterparty's legal name", max_length=255) + counterparty_account = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+" + ) + terms = models.ForeignKey( + "agreements.Terms", + on_delete=models.PROTECT, + null=True, + blank=True, + help_text="Standing terms this contract cites and incorporates, if any.", + ) + body_markdown = models.TextField("contract text") + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+") + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + agreement = models.OneToOneField( + Agreement, on_delete=models.PROTECT, null=True, blank=True, related_name="custom_contract" + ) + + class Meta: + """Newest first.""" + + ordering = ("-created",) + + def __str__(self) -> str: + """Return the title and counterparty.""" + return f"{self.title}: {self.counterparty_name}" + + def get_absolute_url(self) -> str: + """Return the draft page, which links to the agreement once offered.""" + return reverse("agreements:custom_detail", kwargs={"pk": self.pk}) + + @property + def status(self) -> str: + """Draft until offered, then the agreement's status.""" + return self.agreement.status if self.agreement else "draft" diff --git a/apps/agreements/models/orders.py b/apps/agreements/models/orders.py new file mode 100644 index 000000000..de728adeb --- /dev/null +++ b/apps/agreements/models/orders.py @@ -0,0 +1,268 @@ +"""Database-configured programs and the orders prepared from them.""" + +from __future__ import annotations + +import uuid +from decimal import Decimal +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from django.contrib.auth.models import AnonymousUser + from django.db.models import QuerySet + + from apps.agreements.models.agreements import Agreement + from apps.agreements.models.terms import TermsVersion + from apps.agreements.orders.catalog import Agreement as CatalogAgreement + from apps.agreements.orders.pricing import FeeKey, Quote, QuoteSnapshot + from apps.users.models import User + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from django.utils.functional import cached_property + +from apps.agreements.auth import can_prepare, is_administrator +from apps.agreements.models.terms import Terms +from apps.agreements.orders.catalog import Catalog, validate_catalog +from apps.agreements.orders.pricing import build_quote, fee_totals, money + + +class Program(models.Model): + """Configure an order builder without putting its commercial content in source control.""" + + slug = models.SlugField(unique=True) + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + is_public = models.BooleanField( + default=False, + help_text="Allow anyone to browse this program and signed-in customers to start orders. " + "Does not publish its terms. Leave unchecked for staff-prepared orders only.", + ) + definition = models.JSONField(validators=[validate_catalog], help_text="Catalog, pricing rules and document copy.") + + class Meta: + """List programs by title.""" + + ordering = ("title",) + + def __str__(self) -> str: + """Return the staff-managed program title.""" + return self.title + + def get_absolute_url(self) -> str: + """Return the access-controlled program page.""" + return reverse("agreements:program_detail", kwargs={"slug": self.slug}) + + @classmethod + def visible_to(cls, user: User | AnonymousUser) -> QuerySet[Program]: + """Return programs the visitor can browse, never implicitly exposing private catalogs.""" + return cls.objects.all() if can_prepare(user) else cls.objects.filter(is_public=True) + + @cached_property + def catalog(self) -> Catalog: + """Parse the validated definition once per loaded program.""" + return Catalog(self.definition) + + def clean(self) -> None: + """Require a valid catalog and existing, versioned terms before accepting configuration.""" + super().clean() + try: + catalog = Catalog(self.definition) + except ValidationError as exc: + raise ValidationError({"definition": exc.messages}) from exc + slugs = {item.terms_slug for item in catalog.agreements.values()} + available = set(Terms.objects.filter(slug__in=slugs, versions__isnull=False).values_list("slug", flat=True)) + missing = sorted(slugs - available) + if missing: + raise ValidationError({"definition": f"Publish a terms version first for: {', '.join(missing)}."}) + if self.is_public and Terms.objects.filter(slug__in=slugs, is_public=False).exists(): + raise ValidationError({"is_public": "Make every cited set of terms public before publishing the program."}) + + +class Order(models.Model): + """One customer's selections and the frozen configuration behind their Order Form.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + program = models.ForeignKey(Program, on_delete=models.PROTECT, related_name="orders") + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="orders_created") + customer_account = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="agreement_orders", + help_text="The python.org account that may view the order and sign it online.", + ) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + legal_name = models.CharField("legal name", max_length=255) + jurisdiction = models.CharField("state or country of organization", max_length=100) + entity_type = models.CharField(max_length=100, help_text="For example: corporation or limited liability company.") + address = models.TextField("principal place of business") + covered_entities = models.TextField("covered entities", help_text="One name per line.") + discount = models.CharField(max_length=64, default="none") + authorized_contacts = models.JSONField(default=list) + billing_contact_name = models.CharField(max_length=255) + billing_contact_email = models.EmailField() + notices_contact = models.TextField(blank=True, help_text="Only if different from the principal place of business.") + agreement = models.OneToOneField( + "agreements.Agreement", on_delete=models.PROTECT, null=True, blank=True, related_name="order" + ) + catalog_snapshot = models.JSONField(default=dict, blank=True, editable=False) + program_title_snapshot = models.CharField(max_length=255, blank=True, editable=False) + + class Meta: + """List newest orders first.""" + + ordering = ("-created",) + + def __str__(self) -> str: + """Return the customer for listings.""" + return self.legal_name + + def get_absolute_url(self) -> str: + """Return the customer's order page.""" + return reverse("agreements:order_detail", kwargs={"pk": self.pk}) + + @property + def configuration(self) -> dict[str, Any]: + """Read offered configuration from the snapshot, not a later catalog revision.""" + return self.catalog_snapshot or self.program.definition + + @cached_property + def catalog(self) -> Catalog: + """Parse the configuration used by this order.""" + return Catalog(self.configuration) + + @property + def program_title(self) -> str: + """Keep the program's name stable once offered.""" + return self.program_title_snapshot or self.program.title + + @property + def reference(self) -> str: + """Return a short reference suitable for quoting.""" + return str(self.pk).split("-")[0].upper() + + @property + def status(self) -> str: + """Use the agreement status after offering; otherwise the order is a draft.""" + return self.agreement.status if self.agreement else "draft" + + @cached_property + def agreement_list(self) -> list[OrderLine]: + """Return selections in configured order, independent of database JSON ordering.""" + positions = {slug: index for index, slug in enumerate(self.catalog.agreements)} + lines = list(self.agreements.all()) + for line in lines: + line.order = self + return sorted(lines, key=lambda line: positions.get(line.agreement, len(positions))) + + @property + def is_editable(self) -> bool: + """Allow selection changes only while the order is a draft.""" + return self.agreement_id is None + + def is_customer(self, user: User | AnonymousUser) -> bool: + """Check whether the visitor is the linked customer account.""" + return self.customer_account_id is not None and self.customer_account_id == user.pk + + def can_view(self, user: User | AnonymousUser) -> bool: + """Restrict orders to agreement preparers and their linked customer.""" + return user.is_authenticated and (can_prepare(user) or self.is_customer(user)) + + def can_edit(self, user: User | AnonymousUser) -> bool: + """Allow an authorized viewer to change a draft.""" + return self.is_editable and self.can_view(user) + + def can_offer(self, user: User | AnonymousUser) -> bool: + """Allow only administrators or the linked customer to offer a draft.""" + return self.is_editable and user.is_authenticated and (is_administrator(user) or self.is_customer(user)) + + @property + def organization_list(self) -> list[str]: + """Return the covered entity names.""" + return [line.strip() for line in self.covered_entities.splitlines() if line.strip()] + + @property + def term_months(self) -> int: + """Return the selected term from the order's configuration.""" + return self.catalog.discount(self.discount).term_months + + def total(self, key: FeeKey) -> Decimal: + """Sum annual or term totals across the selected agreements.""" + return sum((line.pricing_value(key) for line in self.agreement_list), Decimal(0)) + + @cached_property + def fee_totals(self) -> dict[str, Decimal]: + """Separate annual and one-time fees across current or frozen selections.""" + totals = {"annual": Decimal(0), "one_time": Decimal(0)} + for line in self.agreement_list: + for key, amount in line.fee_totals.items(): + totals[key] += amount + return totals + + @property + def term_total_display(self) -> str: + """Format the combined initial-term fees.""" + return money(self.total("term_total")) + + +class OrderLine(models.Model): + """One selected agreement and the fees fixed when the Order Form is offered.""" + + order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="agreements") + agreement = models.SlugField(max_length=64) + tier = models.CharField(max_length=64) + addons = models.JSONField(default=dict, blank=True) + special_terms = models.TextField(blank=True, help_text="Set by staff only; leave empty for none.") + pricing = models.JSONField(default=dict, blank=True, editable=False) + + class Meta: + """Allow one selection of each agreement per order.""" + + constraints = (models.UniqueConstraint(fields=("order", "agreement"), name="one_order_line_per_agreement"),) + + def __str__(self) -> str: + """Return the customer and selection.""" + return f"{self.order.legal_name}: {self.agreement}" + + @cached_property + def agreement_obj(self) -> CatalogAgreement: + """Return this agreement's configured definition.""" + return self.order.catalog.agreements[self.agreement] + + @property + def tier_name(self) -> str: + """Return the selected tier's name.""" + return self.agreement_obj.tier(self.tier).name + + @cached_property + def cited_terms(self) -> TermsVersion | None: + """Return the fixed version after offering or the current draft candidate.""" + if self.order.agreement_id: + return ( + cast("Agreement", self.order.agreement) + .terms_versions.select_related("terms") + .get(terms__slug=self.agreement_obj.terms_slug) + ) + return self.agreement_obj.terms.current_version + + def quote(self) -> Quote: + """Calculate fees from this order's catalog.""" + return build_quote(self.agreement_obj, self.tier, self.order.discount, self.addons) + + @property + def pricing_snapshot(self) -> QuoteSnapshot: + """Return frozen fees after offering or the current draft's quote.""" + return self.pricing or self.quote().as_dict() + + @cached_property + def fee_totals(self) -> dict[str, Decimal]: + """Read recurring fees separately from one-time charges in any snapshot.""" + return fee_totals(self.pricing_snapshot) + + def pricing_value(self, key: FeeKey) -> Decimal: + """Read a numeric fee total.""" + return Decimal(self.pricing_snapshot[key]) diff --git a/apps/agreements/models/terms.py b/apps/agreements/models/terms.py new file mode 100644 index 000000000..a318afbc4 --- /dev/null +++ b/apps/agreements/models/terms.py @@ -0,0 +1,117 @@ +"""Published terms and their immutable versions.""" + +from __future__ import annotations + +from typing import Any + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.functional import cached_property + +from apps.agreements.documents import sha256 +from apps.agreements.orders.catalog import Catalog + +# Documents are rendered on staging and locally too; they must always cite production. +CANONICAL_ORIGIN = "https://www.python.org" + + +class Terms(models.Model): + """Standing terms with explicit publication and immutable document versions.""" + + slug = models.SlugField(unique=True) + title = models.CharField(max_length=255) + is_public = models.BooleanField( + default=False, + help_text="Make published versions readable by everyone. Otherwise only staff and the parties may read them.", + ) + under_review = models.BooleanField( + default=False, help_text="Mark the terms as a draft for attorney review wherever they appear." + ) + draft_markdown = models.TextField(blank=True, help_text="Working copy; not public until published.") + draft_updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+" + ) + draft_updated_at = models.DateTimeField(null=True, blank=True) + + class Meta: + """Terms are listed by title.""" + + ordering = ("title",) + verbose_name_plural = "terms" + + def __str__(self) -> str: + """Return the title.""" + return self.title + + def get_absolute_url(self) -> str: + """Return the public page for the current version.""" + return reverse("agreements:terms", kwargs={"slug": self.slug}) + + def validate_is_public(self, is_public: bool) -> None: + """Keep terms readable while any public program cites their slug.""" + if is_public or self.pk is None: + return + from apps.agreements.models.orders import Program + + for definition in Program.objects.filter(is_public=True).values_list("definition", flat=True): + catalog = Catalog(definition) + if any(item.terms_slug == self.slug for item in catalog.agreements.values()): + message = "Make every program that cites these terms private before making the terms private." + raise ValidationError(message) + + def clean(self) -> None: + """Validate publication settings in model forms, including the admin.""" + super().clean() + try: + self.validate_is_public(self.is_public) + except ValidationError as exc: + raise ValidationError({"is_public": exc.messages}) from exc + + @cached_property + def current_version(self) -> TermsVersion | None: + """Return the version documents offered now cite.""" + return self.versions.order_by("-published_at", "-pk").first() + + +class TermsVersion(models.Model): + """One published version of terms. It never changes: signed documents cite it.""" + + terms = models.ForeignKey(Terms, on_delete=models.PROTECT, related_name="versions") + version = models.SlugField(max_length=64) + markdown = models.TextField() + sha256 = models.CharField(max_length=64, editable=False) + notes = models.TextField(blank=True, help_text="What changed from the previous version.") + published_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="+" + ) + published_at = models.DateTimeField(default=timezone.now) + + class Meta: + """Newest first; a version label is used once per terms.""" + + ordering = ("-published_at", "-pk") + constraints = (models.UniqueConstraint(fields=("terms", "version"), name="one_terms_version_per_label"),) + + def __str__(self) -> str: + """Return the terms and version.""" + return f"{self.terms.title}, version {self.version}" + + def save(self, *args: Any, **kwargs: Any) -> None: + """Hash the text on publication and refuse any later change.""" + if self.pk: + msg = "A published terms version never changes; publish a new version instead." + raise ValueError(msg) + self.sha256 = sha256(self.markdown) + super().save(*args, **kwargs) + + def get_absolute_url(self) -> str: + """Return the permanent page for this version.""" + return reverse("agreements:terms_version", kwargs={"slug": self.terms.slug, "version": self.version}) + + @property + def permanent_url(self) -> str: + """Absolute, permanent address that documents cite.""" + return CANONICAL_ORIGIN + self.get_absolute_url() diff --git a/apps/agreements/notifications.py b/apps/agreements/notifications.py new file mode 100644 index 000000000..50e284a5f --- /dev/null +++ b/apps/agreements/notifications.py @@ -0,0 +1,64 @@ +"""Emails to signatories: a one-time signing link, and the executed copy.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.conf import settings +from django.core.mail import EmailMessage +from django.template.loader import render_to_string +from django.urls import reverse + +from apps.agreements.documents import final_markdown, render_pdf, terms_download_markdown +from apps.agreements.models import SignedCopy + +if TYPE_CHECKING: + from collections.abc import Iterable + + from django.http import HttpRequest + + from apps.agreements.models import Agreement, SigningLink + + +def _send( + template: str, + context: dict[str, object], + to: str, + attachments: Iterable[tuple[str, str | bytes, str]] = (), +) -> None: + subject = " ".join(render_to_string(f"agreements/email/{template}_subject.txt", context).split()) + body = render_to_string(f"agreements/email/{template}.txt", context) + email = EmailMessage(subject=subject, body=body, from_email=settings.DEFAULT_FROM_EMAIL, to=[to]) + for attachment in attachments: + email.attach(*attachment) + email.send() + + +def send_signing_link(request: HttpRequest, link: SigningLink, token: str) -> None: + """Email a signatory their one-time link; ``token`` exists only here and in the email.""" + url = request.build_absolute_uri(reverse("agreements:sign_link", kwargs={"token": token})) + _send("signing_link", {"link": link, "agreement": link.agreement, "url": url}, link.email) + + +def send_executed_copy(agreement: Agreement) -> None: + """Email the executed PDF and every immutable terms version it incorporates.""" + if not agreement.signer_email: + return + copy = agreement.signed_copies.filter(kind=SignedCopy.Kind.EXECUTED).first() + pdf = bytes(copy.content) if copy is not None else render_pdf(final_markdown(agreement)) + versions = agreement.terms_versions.select_related("terms") + attachments = [(f"psf-agreement-{agreement.reference}.pdf", pdf, "application/pdf")] + attachments.extend( + ( + f"{version.terms.slug}-{version.version}.pdf", + render_pdf(terms_download_markdown(version)), + "application/pdf", + ) + for version in versions + ) + _send( + "executed", + {"agreement": agreement, "terms_versions": versions}, + agreement.signer_email, + attachments=attachments, + ) diff --git a/apps/agreements/orders/__init__.py b/apps/agreements/orders/__init__.py new file mode 100644 index 000000000..db64924f9 --- /dev/null +++ b/apps/agreements/orders/__init__.py @@ -0,0 +1 @@ +"""Configurable catalogs, pricing, and order preparation.""" diff --git a/apps/agreements/orders/catalog.py b/apps/agreements/orders/catalog.py new file mode 100644 index 000000000..61dae58d4 --- /dev/null +++ b/apps/agreements/orders/catalog.py @@ -0,0 +1,568 @@ +"""Validated, database-configured agreement catalogs and pricing definitions.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from typing import TYPE_CHECKING, Any, NoReturn, cast + +from django.core.exceptions import ValidationError + +if TYPE_CHECKING: + from collections.abc import Collection, Iterator + + from apps.agreements.models import Terms + + +MONTHS_PER_YEAR = 12 +PERCENT_SCALE = 100 +MAX_NESTING = 32 +RESPONSE_TARGET_COUNT = 4 +CURRENCY_WHOLE_DIGITS = 16 +CURRENCY_DECIMAL_PLACES = 2 +CHOICE_PAIR_SIZE = 2 + +RULE_FIELDS = { + "fixed": {"amount"}, + "quantity": {"parameter", "unit_amount", "base_amount", "included"}, + "brackets": {"parameter", "bands", "overflow_basis"}, + "choice": {"parameter", "options"}, + "sum": {"components"}, + "unpriced": set(), +} + + +@dataclass(frozen=True) +class Tier: + """One service tier with its annual fee and response commitments.""" + + key: str + name: str + annual_fee: Decimal + response_targets: tuple[str, str, str, str] + fair_use: int + includes: str = "" + + +@dataclass(frozen=True) +class Service: + """A service available at every tier or at an explicit subset.""" + + key: str + name: str + description: str + tiers: tuple[str, ...] = () + + def included_at(self, tier_key: str) -> bool: + """Return whether this service is included at the selected tier.""" + return not self.tiers or tier_key in self.tiers + + +@dataclass(frozen=True) +class Param: + """One typed add-on input and its optional activation conditions.""" + + key: str + label: str + kind: str + choices: tuple[tuple[str, str], ...] = () + help_text: str = "" + minimum: int = 1 + required_when: dict[str, str | int | list[str]] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AddOn: + """An additional service with declarative pricing and parameter definitions.""" + + key: str + name: str + description: str + price_summary: str + pricing: dict[str, Any] + params: tuple[Param, ...] = () + included_in_tiers: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Discount: + """A nonstacking tier-fee discount and its associated initial term.""" + + key: str + name: str + percent: int + term_months: int + requires_attestation: bool = False + + +@dataclass(frozen=True) +class Agreement: + """An agreement's ordered services, tier choices, and pricing options.""" + + slug: str + title: str + short_name: str + tagline: str + terms_slug: str + tiers: tuple[Tier, ...] + services: tuple[Service, ...] + discounts: tuple[Discount, ...] + default_tier: str + addons: tuple[AddOn, ...] = () + + @property + def terms(self) -> Terms: + """Return the published terms identified by this agreement.""" + from apps.agreements.models import Terms + + return Terms.objects.get(slug=self.terms_slug) + + def tier(self, key: str) -> Tier: + """Return the named tier or raise a selection validation error.""" + return _lookup(self.tiers, key, "tier") + + def discount(self, key: str) -> Discount: + """Return the named discount or raise a selection validation error.""" + return _lookup(self.discounts, key, "discount") + + def addon(self, key: str) -> AddOn: + """Return the named add-on or raise a selection validation error.""" + return _lookup(self.addons, key, "add-on") + + def services_at(self, tier_key: str) -> list[Service]: + """Return services included at a valid tier.""" + self.tier(tier_key) + return [service for service in self.services if service.included_at(tier_key)] + + @property + def service_rows(self) -> list[tuple[Service, str]]: + """Pair each service with its restricted tier names for display.""" + return [ + (service, " and ".join(t.name for t in self.tiers if t.key in service.tiers) if service.tiers else "") + for service in self.services + ] + + +def _lookup[CatalogItem: (Tier, Discount, AddOn)](items: tuple[CatalogItem, ...], key: str, label: str) -> CatalogItem: + for item in items: + if item.key == key: + return item + message = f"Unknown {label}: {key!r}." + raise ValidationError(message) + + +def _error(path: str, message: str) -> NoReturn: + message = f"{path}: {message}" + raise ValidationError(message) + + +def _object(value: object, path: str, allowed: Collection[str], required: Collection[str] = ()) -> dict[str, Any]: + if not isinstance(value, dict): + _error(path, "must be an object.") + unknown = value.keys() - allowed + if unknown: + _error(path, f"unknown field(s): {', '.join(sorted(map(str, unknown)))}.") + missing = set(required) - value.keys() + if missing: + _error(path, f"missing field(s): {', '.join(sorted(missing))}.") + return value + + +def _list(value: object, path: str, nonempty: bool = False) -> list[Any]: + if not isinstance(value, list) or (nonempty and not value): + _error(path, "must be a nonempty list." if nonempty else "must be a list.") + return value + + +def _text(value: object, path: str, blank: bool = False) -> str: + if not isinstance(value, str) or (not blank and not value.strip()): + _error(path, "must be text." if blank else "must be nonempty text.") + return value + + +def _key(value: Any, path: str) -> str: + _text(value, path) + if not re.fullmatch(r"[-a-zA-Z0-9_]+", value): + _error(path, "must contain only letters, numbers, hyphens, and underscores.") + return value + + +def _integer(value: object, path: str, minimum: int = 0) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < minimum: + _error(path, f"must be an integer of at least {minimum}.") + return value + + +def _boolean(value: object, path: str) -> bool: + if not isinstance(value, bool): + _error(path, "must be true or false.") + return value + + +def _decimal(value: object, path: str) -> Decimal: + if not isinstance(value, str): + _error(path, "must be a decimal string.") + try: + amount = Decimal(value) + except InvalidOperation: + _error(path, "must be a finite, nonnegative decimal amount.") + if not amount.is_finite() or amount < 0: + _error(path, "must be a finite, nonnegative decimal amount.") + # Monetary arithmetic and cent rounding must remain exact in Decimal's context. + if amount.adjusted() >= CURRENCY_WHOLE_DIGITS or cast("int", amount.as_tuple().exponent) < -CURRENCY_DECIMAL_PLACES: + _error(path, "must have at most 16 whole-number digits and two decimal places.") + return amount + + +def _unique( + records: object, path: str, key: str = "key", nonempty: bool = False +) -> Iterator[tuple[dict[str, Any], str]]: + seen = set() + for index, record in enumerate(_list(records, path, nonempty)): + here = f"{path}[{index}]" + if not isinstance(record, dict): + _error(here, "must be an object.") + name = _key(record.get(key), f"{here}.{key}") + if name in seen: + _error(here, f"duplicate {key}: {name}.") + seen.add(name) + yield record, here + + +def _tier_refs(value: object, path: str, tier_keys: set[str]) -> tuple[str, ...]: + refs = _list(value, path) + seen = set() + for ref in refs: + _key(ref, path) + if ref not in tier_keys: + _error(path, f"unknown tier: {ref}.") + if ref in seen: + _error(path, f"duplicate tier: {ref}.") + seen.add(ref) + return tuple(refs) + + +def _param(row: dict[str, Any], path: str) -> Param: + _object( + row, + path, + {"key", "label", "kind", "choices", "help_text", "minimum", "required_when"}, + {"key", "label", "kind"}, + ) + kind = _text(row["kind"], f"{path}.kind") + if kind not in {"choice", "count", "text", "lines"}: + _error(f"{path}.kind", "must be choice, count, text, or lines.") + choices: list[tuple[str, str]] = [] + for choice in _list(row.get("choices", []), f"{path}.choices", nonempty=kind == "choice"): + if not isinstance(choice, list) or len(choice) != CHOICE_PAIR_SIZE: + _error(f"{path}.choices", "each choice must be a [value, label] pair.") + value = _text(choice[0], f"{path}.choices.value") + label = _text(choice[1], f"{path}.choices.label") + if any(existing[0] == value for existing in choices): + _error(f"{path}.choices", f"duplicate choice: {value}.") + choices.append((value, label)) + if choices and kind != "choice": + _error(path, "choices are only valid for choice parameters.") + condition = row.get("required_when", {}) + if not isinstance(condition, dict): + _error(f"{path}.required_when", "must be an object of parameter values.") + return Param( + key=row["key"], + label=_text(row["label"], f"{path}.label"), + kind=kind, + choices=tuple(choices), + help_text=_text(row.get("help_text", ""), f"{path}.help_text", blank=True), + minimum=_integer(row.get("minimum", 1), f"{path}.minimum"), + required_when=condition, + ) + + +def _param_conditions(param: Param, params: dict[str, Param], path: str) -> None: + for key, value in param.required_when.items(): + if not isinstance(key, str) or key not in params: + _error(path, f"unknown parameter: {key!r}.") + other = params[key] + if other.kind == "choice": + if not isinstance(value, str) or value not in dict(other.choices): + _error(path, f"invalid choice for {key}.") + elif other.kind == "count": + _integer(value, path, other.minimum) + elif other.kind == "text": + _text(value, path) + else: + for line in _list(value, path, nonempty=True): + _text(line, path) + + +def _params(records: object, path: str) -> tuple[Param, ...]: + result = tuple(_param(row, here) for row, here in _unique(records, path)) + by_key = {param.key: param for param in result} + for param in result: + _param_conditions(param, by_key, f"{path}.{param.key}.required_when") + visited: set[str] = set() + + def visit(key: str, pending: set[str]) -> None: + if key in pending: + _error(path, "required_when dependencies must not form a cycle.") + if len(pending) > MAX_NESTING: + _error(path, f"required_when dependencies may nest at most {MAX_NESTING} levels.") + if key not in visited: + for dependency in by_key[key].required_when: + visit(dependency, pending | {key}) + visited.add(key) + + for key in by_key: + visit(key, set()) + return result + + +def _rule_shape(rule: object, path: str, depth: int) -> str: + if depth > MAX_NESTING: + _error(path, f"pricing rules may nest at most {MAX_NESTING} levels.") + if not isinstance(rule, dict): + _error(path, "must be a pricing rule object.") + kind = _text(rule.get("type"), f"{path}.type") + if kind not in RULE_FIELDS: + _error(path, f"unknown pricing rule type: {kind}.") + common = {"type"} if kind == "choice" else {"type", "recurring", "basis", "detail"} + required = RULE_FIELDS[kind] - {"base_amount", "included"} + _object(rule, path, common | RULE_FIELDS[kind], required | {"type"}) + return kind + + +def _rule_parameter(rule: dict[str, Any], params: dict[str, Param], path: str, conditions: dict[str, str]) -> None: + parameter = _key(rule["parameter"], f"{path}.parameter") + if parameter not in params: + _error(path, f"unknown parameter: {parameter}.") + param = params[parameter] + kinds = {"choice"} if rule["type"] == "choice" else {"count", "lines"} + if param.kind not in kinds: + _error(path, f"{rule['type']} cannot use a {param.kind} parameter.") + if any(conditions.get(key) != value for key, value in param.required_when.items()): + _error(path, f"parameter {parameter} is not guaranteed to be active in this pricing branch.") + + +def _leaf_rule(rule: dict[str, Any], path: str) -> None: + kind = rule["type"] + if kind == "fixed": + _decimal(rule["amount"], f"{path}.amount") + elif kind == "quantity": + _decimal(rule["unit_amount"], f"{path}.unit_amount") + _decimal(rule.get("base_amount", "0"), f"{path}.base_amount") + _integer(rule.get("included", 0), f"{path}.included") + elif kind == "brackets": + previous = -1 + for index, band in enumerate(_list(rule["bands"], f"{path}.bands", nonempty=True)): + here = f"{path}.bands[{index}]" + _object(band, here, {"up_to", "amount"}, {"up_to", "amount"}) + threshold = _integer(band["up_to"], f"{here}.up_to") + if threshold <= previous: + _error(here, "bracket limits must be strictly increasing.") + previous = threshold + _decimal(band["amount"], f"{here}.amount") + _text(rule["overflow_basis"], f"{path}.overflow_basis") + + +def _choice_rule( + rule: dict[str, Any], params: dict[str, Param], path: str, conditions: dict[str, str], depth: int +) -> set[bool]: + parameter = rule["parameter"] + options = rule["options"] + if not isinstance(options, dict) or options.keys() != dict(params[parameter].choices).keys(): + _error(f"{path}.options", "must contain exactly the parameter's choice values.") + recurrences: set[bool] = set() + for value, branch in options.items(): + recurrences.update( + _rule(branch, params, f"{path}.options.{value}", {**conditions, parameter: value}, depth + 1) + ) + return recurrences + + +def _rule( + rule: dict[str, Any], + params: dict[str, Param], + path: str, + conditions: dict[str, str] | None = None, + depth: int = 0, +) -> set[bool]: + kind = _rule_shape(rule, path, depth) + conditions = conditions or {} + if kind in {"quantity", "brackets", "choice"}: + _rule_parameter(rule, params, path, conditions) + if kind == "choice": + return _choice_rule(rule, params, path, conditions, depth) + recurring = _boolean(rule.get("recurring", True), f"{path}.recurring") + _text(rule.get("basis", ""), f"{path}.basis", blank=True) + _text(rule.get("detail", ""), f"{path}.detail", blank=True) + if kind == "sum": + for index, component in enumerate(_list(rule["components"], f"{path}.components", nonempty=True)): + recurrences = _rule(component, params, f"{path}.components[{index}]", conditions, depth + 1) + if recurrences != {recurring}: + _error(path, "summed components must have the same recurrence; use separate add-ons otherwise.") + else: + _leaf_rule(rule, path) + return {recurring} + + +def _discount(row: dict[str, Any], path: str) -> Discount: + _object( + row, + path, + {"key", "name", "percent", "term_months", "requires_attestation"}, + {"key", "name", "percent", "term_months"}, + ) + percent = _integer(row["percent"], f"{path}.percent") + if percent > PERCENT_SCALE: + _error(f"{path}.percent", f"must be between 0 and {PERCENT_SCALE}.") + months = _integer(row["term_months"], f"{path}.term_months", MONTHS_PER_YEAR) + if months % MONTHS_PER_YEAR: + _error(f"{path}.term_months", f"must be a positive multiple of {MONTHS_PER_YEAR}.") + return Discount( + key=row["key"], + name=_text(row["name"], f"{path}.name"), + percent=percent, + term_months=months, + requires_attestation=_boolean(row.get("requires_attestation", False), f"{path}.requires_attestation"), + ) + + +def _tier(row: dict[str, Any], path: str) -> Tier: + _object( + row, + path, + {"key", "name", "annual_fee", "response_targets", "fair_use", "includes"}, + {"key", "name", "annual_fee", "response_targets", "fair_use"}, + ) + targets = _list(row["response_targets"], f"{path}.response_targets") + if len(targets) != RESPONSE_TARGET_COUNT: + _error(f"{path}.response_targets", "must contain exactly four response targets.") + return Tier( + key=row["key"], + name=_text(row["name"], f"{path}.name"), + annual_fee=_decimal(row["annual_fee"], f"{path}.annual_fee"), + response_targets=cast( + "tuple[str, str, str, str]", tuple(_text(target, f"{path}.response_targets") for target in targets) + ), + fair_use=_integer(row["fair_use"], f"{path}.fair_use"), + includes=_text(row.get("includes", ""), f"{path}.includes", blank=True), + ) + + +def _service(row: dict[str, Any], path: str, tier_keys: set[str]) -> Service: + _object(row, path, {"key", "name", "description", "tiers"}, {"key", "name", "description"}) + return Service( + key=row["key"], + name=_text(row["name"], f"{path}.name"), + description=_text(row["description"], f"{path}.description", blank=True), + tiers=_tier_refs(row.get("tiers", []), f"{path}.tiers", tier_keys), + ) + + +def _addon(row: dict[str, Any], path: str, tier_keys: set[str]) -> AddOn: + _object( + row, + path, + {"key", "name", "description", "price_summary", "params", "included_in_tiers", "pricing"}, + {"key", "name", "description", "price_summary", "pricing"}, + ) + params = _params(row.get("params", []), f"{path}.params") + _rule(row["pricing"], {param.key: param for param in params}, f"{path}.pricing") + return AddOn( + key=row["key"], + name=_text(row["name"], f"{path}.name"), + description=_text(row["description"], f"{path}.description", blank=True), + price_summary=_text(row["price_summary"], f"{path}.price_summary", blank=True), + params=params, + pricing=row["pricing"], + included_in_tiers=_tier_refs(row.get("included_in_tiers", []), f"{path}.included_in_tiers", tier_keys), + ) + + +def _agreement(row: dict[str, Any], path: str, discounts: tuple[Discount, ...]) -> Agreement: + _object( + row, + path, + {"slug", "title", "short_name", "tagline", "terms_slug", "default_tier", "tiers", "services", "addons"}, + {"slug", "title", "terms_slug", "tiers"}, + ) + tiers = tuple(_tier(tier, here) for tier, here in _unique(row["tiers"], f"{path}.tiers", nonempty=True)) + tier_keys = {tier.key for tier in tiers} + default_tier = _key(row.get("default_tier", tiers[0].key), f"{path}.default_tier") + if default_tier not in tier_keys: + _error(f"{path}.default_tier", f"unknown tier: {default_tier}.") + services = tuple( + _service(service, here, tier_keys) for service, here in _unique(row.get("services", []), f"{path}.services") + ) + addons = tuple(_addon(addon, here, tier_keys) for addon, here in _unique(row.get("addons", []), f"{path}.addons")) + title = _text(row["title"], f"{path}.title") + return Agreement( + slug=row["slug"], + title=title, + short_name=_text(row.get("short_name", title), f"{path}.short_name"), + tagline=_text(row.get("tagline", ""), f"{path}.tagline", blank=True), + terms_slug=_key(row["terms_slug"], f"{path}.terms_slug"), + tiers=tiers, + services=services, + discounts=discounts, + addons=addons, + default_tier=default_tier, + ) + + +class Catalog: + """Parse a definition once, preserving its agreement, tier, and add-on ordering.""" + + order_title: str + order_intro: str + covered_entities_label: str + covered_entities_help: str + attestation_label: str + attestation_help: str + + METADATA = { + "order_title": "Service order", + "order_intro": "Select the services for this order.", + "covered_entities_label": "Covered entities", + "covered_entities_help": "Enter one entity per line.", + "attestation_label": "I confirm eligibility for the selected discount.", + "attestation_help": "Confirm that the customer qualifies for this discount.", + } + PAYMENT = { + "annual": "Annual fees are payable in advance.", + "multi_year": "Fees for the initial term are payable in advance.", + "renewal": "Renewal is subject to the applicable agreement terms.", + "other_charges": "Other charges are payable under the applicable agreement terms.", + } + + def __init__(self, definition: object) -> None: + """Validate and parse a JSON-compatible program definition.""" + definition = _object( + definition, + "catalog", + {"agreements", "discounts", "payment"} | self.METADATA.keys(), + {"agreements", "discounts"}, + ) + for key, default in self.METADATA.items(): + setattr(self, key, _text(definition.get(key, default), f"catalog.{key}", blank=True)) + payment = _object(definition.get("payment", {}), "catalog.payment", self.PAYMENT.keys()) + self.payment = { + key: _text(payment.get(key, default), f"catalog.payment.{key}", blank=True) + for key, default in self.PAYMENT.items() + } + self.discounts = tuple( + _discount(row, path) for row, path in _unique(definition["discounts"], "catalog.discounts", nonempty=True) + ) + self.agreements = { + row["slug"]: _agreement(row, path, self.discounts) + for row, path in _unique(definition["agreements"], "catalog.agreements", "slug", nonempty=True) + } + + def discount(self, key: str) -> Discount: + """Return the named shared discount or raise a validation error.""" + return _lookup(self.discounts, key, "discount") + + +def validate_catalog(value: object) -> None: + """Validate a Django JSONField value and report malformed definitions.""" + Catalog(value) diff --git a/apps/agreements/orders/documents.py b/apps/agreements/orders/documents.py new file mode 100644 index 000000000..4b14e772e --- /dev/null +++ b/apps/agreements/orders/documents.py @@ -0,0 +1,187 @@ +"""Compose an Order Form from its program configuration and frozen selections.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from django.core.exceptions import ValidationError +from num2words import num2words + +from apps.agreements.documents import ( + EFFECTIVE_DATE, + PROVIDER_PREAMBLE, + SIGNATURES, + amounts_table, + final_markdown, + md, + preview_markdown, + table, +) +from apps.agreements.orders.pricing import fee_totals, money + +if TYPE_CHECKING: + from apps.agreements.models import Order, OrderLine, TermsVersion + from apps.agreements.orders.pricing import QuoteSnapshot + +ONE_YEAR = 12 + + +def _months(n: int) -> str: + return f"{num2words(n).capitalize()} ({n}) months" + + +def _contact(name: str, email: str) -> str: + return f"{md(name)} ({md(email)})" + + +def _customer_section(order: Order) -> list[str]: + address = ", ".join(part.strip() for part in order.address.splitlines() if part.strip()) + contacts = "; ".join(_contact(c["name"], c["email"]) for c in order.authorized_contacts) + notices = md(order.notices_contact) if order.notices_contact.strip() else "The principal place of business above." + return table( + ("Customer", ""), + [ + ("Legal name", f"**{md(order.legal_name)}**"), + ("Organized as", f"A {md(order.jurisdiction)} {md(order.entity_type)}"), + ("Principal place of business", md(address)), + (md(order.catalog.covered_entities_label), ", ".join(md(entity) for entity in order.organization_list)), + ("Authorized contacts", contacts), + ("Billing contact", _contact(order.billing_contact_name, order.billing_contact_email)), + ("Notices contact", notices), + ], + ) + + +def _term_section(order: Order) -> list[str]: + first = order.agreement_list[0].pricing_snapshot + payment = order.catalog.payment + invoicing = payment["annual"] if first["term_months"] <= ONE_YEAR else payment["multi_year"] + discount = ( + f"{md(first['discount_name'])} ({first['discount_percent']}%), applied to Service Tier fees only." + if first["discount_percent"] + else "None." + ) + return table( + ("Term and payment", ""), + [ + ("Effective Date", EFFECTIVE_DATE), + ("Initial Term", f"{_months(first['term_months'])}."), + ("Renewal", md(payment["renewal"])), + ("Discount", discount), + ("Invoicing", md(invoicing)), + ("Other charges", md(payment["other_charges"])), + ], + ) + + +def _fee_rows(pricing: QuoteSnapshot) -> list[tuple[str, str]]: + rows = [(f"Service Tier fee: {md(pricing['tier_name'])}", f"{md(pricing['display']['tier_fee'])} per year")] + for item in pricing["items"]: + label = md(item["name"]) + (f" — {md(item['detail'])}" if item["detail"] else "") + amount = md(item["basis"]) if item["amount"] is None else f"{md(item['display'])} {md(item['basis'])}" + rows.append((f"Additional service: {label}", amount)) + if pricing["discount_percent"]: + rows.append((md(pricing["discount_name"]), f"less {md(pricing['display']['discount_amount'])}")) + totals = fee_totals(pricing) + rows.append(("**Annual fees**", f"**{money(totals['annual'])}**")) + if totals["one_time"]: + rows.append(("One-time fees", money(totals["one_time"]))) + if pricing["term_months"] > ONE_YEAR or any(not item["recurring"] for item in pricing["items"]): + rows.append((f"Initial Term total ({pricing['term_months']} months)", md(pricing["display"]["term_total"]))) + return rows + + +def _parameter_rows(line: OrderLine) -> list[tuple[str, str]]: + rows = [] + for addon in line.agreement_obj.addons: + selected = line.addons.get(addon.key) + if selected is None: + continue + for param in addon.params: + if param.key not in selected: + continue + value = selected[param.key] + if param.kind == "choice": + value = dict(param.choices).get(cast("str", value), value) + elif param.kind == "lines": + value = "; ".join(cast("list[str]", value)) + rows.append((f"{md(addon.name)} — {md(param.label)}", md(str(value)))) + return rows + + +def _agreement_section(line: OrderLine) -> list[str]: + agreement, terms = line.agreement_obj, cast("TermsVersion", line.cited_terms) + tier = agreement.tier(line.tier) + targets = "; ".join( + f"{severity}: {md(target)}" + for severity, target in zip(("P1", "P2", "P3", "P4"), tier.response_targets, strict=True) + ) + rows = [ + ("Service Tier", f"**{md(tier.name)}**"), + ("Response time targets", targets), + ("Fair-use threshold", f"{tier.fair_use} requests per year"), + ("Included services", "; ".join(md(service.name) for service in agreement.services_at(line.tier)) or "None."), + *_parameter_rows(line), + ("Special terms", md(line.special_terms.strip()) if line.special_terms.strip() else "None."), + ] + return [ + f"## {md(agreement.short_name)}", + "", + f"Terms: **{md(terms.terms.title)}**, version {md(terms.version)}, published at <{terms.permanent_url}>.", + "", + *table((md(agreement.short_name), ""), rows), + *amounts_table(("Fees", ""), _fee_rows(line.pricing_snapshot)), + ] + + +def _total_section(order: Order) -> list[str]: + rows = [(md(line.agreement_obj.short_name), money(line.fee_totals["annual"])) for line in order.agreement_list] + rows.append(("**Total annual fees**", f"**{money(order.fee_totals['annual'])}**")) + if order.fee_totals["one_time"]: + rows.append(("Total one-time fees", money(order.fee_totals["one_time"]))) + if order.term_months > ONE_YEAR or any( + not item["recurring"] for line in order.agreement_list for item in line.pricing_snapshot["items"] + ): + rows.append((f"Total for the Initial Term ({order.term_months} months)", md(order.term_total_display))) + return ["## Order total", "", *amounts_table(("Agreement", "Fees"), rows)] + + +def compose_order_form_markdown(order: Order) -> str: + """Return the document to hash and freeze, with date and signature placeholders.""" + lines = order.agreement_list + if not lines: + msg = "Choose at least one agreement before preparing the Order Form." + raise ValidationError(msg) + header = [f"# {md(order.catalog.order_title)}", ""] + if any(cast("TermsVersion", line.cited_terms).terms.under_review for line in lines): + header += ["**DRAFT — For Attorney Review**", ""] + header += [f"*Reference {order.reference}*", ""] + agreements = " and ".join(md(line.agreement_obj.title) for line in lines) + intro = ( + f"This Order Form is entered into between {PROVIDER_PREAMBLE}, and the Customer named below. " + f"It orders {agreements}, under the terms cited for each agreement below." + ) + parts = [*header, md(order.catalog.order_intro), "", intro, "", *_customer_section(order), *_term_section(order)] + for line in lines: + parts += _agreement_section(line) + if len(lines) > 1: + parts += _total_section(order) + parts += [ + "## Signatures", + "", + ( + "Each Party signs this Order Form, and so enters into each agreement ordered on it, as of the " + "Effective Date: the date of the last signature below." + ), + "", + SIGNATURES, + "", + ] + return "\n".join(parts) + + +def order_form_markdown(order: Order) -> str: + """Use the offered document after freezing, otherwise compose a draft preview.""" + if order.agreement: + return final_markdown(order.agreement) + return preview_markdown(compose_order_form_markdown(order), order.legal_name) diff --git a/apps/agreements/orders/forms.py b/apps/agreements/orders/forms.py new file mode 100644 index 000000000..ae32d6475 --- /dev/null +++ b/apps/agreements/orders/forms.py @@ -0,0 +1,388 @@ +"""Catalog-driven order forms; signature forms are shared with all agreements.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from django import forms +from django.core.exceptions import ValidationError +from django.db import transaction + +from apps.agreements.forms.accounts import account_for_email +from apps.agreements.models import Order, OrderLine +from apps.agreements.orders.pricing import normalize_params + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.forms import BoundField + from django.http import QueryDict + + from apps.agreements.models import Program + from apps.agreements.orders.catalog import Agreement, Catalog, Param + from apps.agreements.orders.pricing import ParamValue + from apps.users.models import User + +MAX_CONTACTS = 5 +MAX_ENTITIES = 50 +MAX_NAME_LENGTH = 100 + + +def _lines(value: str | None) -> list[str]: + return [line.strip() for line in (value or "").splitlines() if line.strip()] + + +class OrderForm(forms.ModelForm): + """Customer details, contacts, discount eligibility, and selected agreements.""" + + instance: Order + + agreements = forms.MultipleChoiceField( + widget=forms.CheckboxSelectMultiple, + error_messages={"required": "Choose at least one service package."}, + ) + discount = forms.ChoiceField(widget=forms.RadioSelect) + discount_attestation = forms.BooleanField(required=False) + + class Meta: + """Expose customer fields; configure selection fields from the catalog.""" + + model = Order + fields = ( + "legal_name", + "jurisdiction", + "entity_type", + "address", + "covered_entities", + "discount", + "billing_contact_name", + "billing_contact_email", + "notices_contact", + ) + widgets = { + "address": forms.Textarea(attrs={"rows": 3}), + "covered_entities": forms.Textarea(attrs={"rows": 3, "spellcheck": "false"}), + "notices_contact": forms.Textarea(attrs={"rows": 2}), + } + + def __init__( + self, + *args: Any, + catalog: Catalog, + agreements: dict[str, Agreement], + can_link_accounts: bool = False, + **kwargs: Any, + ) -> None: + """Configure available selections, account linkage and contact rows.""" + super().__init__(*args, **kwargs) + self.catalog = catalog + cast("forms.MultipleChoiceField", self.fields["agreements"]).choices = [ + (a.slug, a.short_name) for a in agreements.values() + ] + cast("forms.ChoiceField", self.fields["discount"]).choices = [(d.key, d.name) for d in catalog.discounts] + self.fields["covered_entities"].label = catalog.covered_entities_label + self.fields["covered_entities"].help_text = catalog.covered_entities_help + self.fields["discount_attestation"].label = catalog.attestation_label + self.fields["discount_attestation"].help_text = catalog.attestation_help + order = self.instance + editing = not order._state.adding # noqa: SLF001 - Django model state API + if can_link_accounts: + account = order.customer_account + self.fields["customer_account_email"] = forms.EmailField( + required=False, + label="Customer's python.org account", + help_text="Email of the account that may view and sign this order online. " + "Leave empty to collect the signature outside python.org.", + initial=account.email if account else "", + ) + if editing: + self.initial["agreements"] = [ + line.agreement for line in order.agreement_list if line.agreement in agreements + ] + self.fields["discount_attestation"].initial = any( + discount.key == order.discount and discount.requires_attestation for discount in catalog.discounts + ) + else: + self.initial["discount"] = catalog.discounts[0].key + existing = order.authorized_contacts if editing else [] + for i in range(1, MAX_CONTACTS + 1): + contact = existing[i - 1] if i <= len(existing) else {} + self.fields[f"contact_{i}_name"] = forms.CharField( + required=False, max_length=255, label="Name", initial=contact.get("name") + ) + self.fields[f"contact_{i}_email"] = forms.EmailField( + required=False, label="Email", initial=contact.get("email") + ) + + def contact_rows(self) -> list[tuple[BoundField, BoundField]]: + """Pair each contact's fields for the template.""" + return [(self[f"contact_{i}_name"], self[f"contact_{i}_email"]) for i in range(1, MAX_CONTACTS + 1)] + + def clean_covered_entities(self) -> str: + """Require a bounded list of covered entities.""" + entities = _lines(self.cleaned_data["covered_entities"]) + if not entities: + msg = "List at least one covered entity." + raise ValidationError(msg) + if len(entities) > MAX_ENTITIES: + msg = f"List at most {MAX_ENTITIES} covered entities." + raise ValidationError(msg) + if any(len(entity) > MAX_NAME_LENGTH for entity in entities): + msg = f"Enter one name per line; names are at most {MAX_NAME_LENGTH} characters." + raise ValidationError(msg) + return "\n".join(entities) + + def _clean_contacts(self) -> list[dict[str, str]]: + contacts: list[dict[str, str]] = [] + incomplete = False + for i in range(1, MAX_CONTACTS + 1): + name = (self.cleaned_data.get(f"contact_{i}_name") or "").strip() + email = self.cleaned_data.get(f"contact_{i}_email") or "" + if name and email: + contacts.append({"name": name, "email": email}) + elif name or email: + incomplete = True + missing = f"contact_{i}_email" if name else f"contact_{i}_name" + self.add_error(missing, "Enter both a name and an email address for this contact.") + if not contacts and not incomplete: + self.add_error("contact_1_name", "Add at least one authorized contact.") + return contacts + + def clean_customer_account_email(self) -> User | None: + """Resolve the supplied address to one customer account.""" + email = self.cleaned_data["customer_account_email"] + return account_for_email(email) if email else None + + def clean(self) -> dict[str, Any]: + """Validate discount eligibility and authorized contacts.""" + cleaned = cast("dict[str, Any]", super().clean()) + discount = cleaned.get("discount") + if ( + discount + and self.catalog.discount(discount).requires_attestation + and not cleaned.get("discount_attestation") + ): + self.add_error("discount_attestation", "Confirm eligibility to use the selected discount.") + cleaned["contacts"] = self._clean_contacts() + return cleaned + + +class AgreementForm(forms.Form): + """Tier and add-on selections generated from one catalog agreement.""" + + tier = forms.ChoiceField(widget=forms.RadioSelect) + + def __init__( + self, + *args: Any, + agreement: Agreement, + line: OrderLine | None = None, + staff: bool = False, + **kwargs: Any, + ) -> None: + """Create configured fields and identify inactive conditional parameters.""" + cast("Callable[..., None]", super().__init__)(*args, prefix=agreement.slug, **kwargs) + self.agreement = agreement + cast("forms.ChoiceField", self.fields["tier"]).choices = [(t.key, t.name) for t in agreement.tiers] + self.initial["tier"] = line.tier if line else agreement.default_tier + if staff: + self.fields["special_terms"] = forms.CharField( + required=False, + max_length=2000, + widget=forms.Textarea(attrs={"rows": 2}), + help_text="Printed on the Order Form for this agreement. Leave empty for none.", + initial=line.special_terms if line else "", + ) + selected = line.addons if line else {} + tier = self.data.get(self.add_prefix("tier")) if self.is_bound else self.initial["tier"] + self._inactive_parameters: list[forms.Field] = [] + for addon in agreement.addons: + chosen = selected.get(addon.key) + name = f"addon_{addon.key}" + self.fields[name] = forms.BooleanField(required=False, label=addon.name, initial=chosen is not None) + active = bool(self.data.get(self.add_prefix(name))) and tier not in addon.included_in_tiers + for param in addon.params: + field = self._param_field(param, (chosen or {}).get(param.key)) + if self.is_bound and ( + not active + or not all( + self.data.get(self.add_prefix(f"{name}_{key}")) == str(value) + for key, value in param.required_when.items() + ) + ): + self._inactive_parameters.append(field) + self.fields[f"{name}_{param.key}"] = field + + def full_clean(self) -> None: + """Ignore inactive values when validating, but keep rendered inputs editable.""" + for field in self._inactive_parameters: + field.disabled = True + try: + super().full_clean() + finally: + for field in self._inactive_parameters: + field.disabled = False + + @staticmethod + def _param_field(param: Param, initial: ParamValue | None) -> forms.Field: + field: forms.Field + if param.kind == "choice": + field = forms.ChoiceField(choices=param.choices, required=False, initial=initial or param.choices[0][0]) + elif param.kind == "count": + field = forms.IntegerField(required=False, min_value=param.minimum, initial=initial) + elif param.kind == "lines": + field = forms.CharField( + required=False, + widget=forms.Textarea(attrs={"rows": 3}), + initial="\n".join(cast("list[str]", initial) or []), + ) + else: + field = forms.CharField(required=False, max_length=200, initial=initial) + field.label, field.help_text = param.label, param.help_text + field.widget.attrs["data-param-key"] = param.key + return field + + def tier_options(self) -> list[dict[str, Any]]: + """Pair every tier with the services and extras its fee includes.""" + return [ + { + "tier": tier, + "services": self.agreement.services_at(tier.key), + "included_addons": [addon for addon in self.agreement.addons if tier.key in addon.included_in_tiers], + } + for tier in self.agreement.tiers + ] + + def has_included_extras(self) -> bool: + """Show the comparison row only when a tier includes something extra.""" + return any(tier.includes for tier in self.agreement.tiers) or any( + addon.included_in_tiers for addon in self.agreement.addons + ) + + def addon_fields(self) -> list[dict[str, Any]]: + """Pair each add-on with its checkbox and parameter fields.""" + return [ + { + "addon": addon, + "checkbox": self[f"addon_{addon.key}"], + "params": [self[f"addon_{addon.key}_{p.key}"] for p in addon.params], + } + for addon in self.agreement.addons + ] + + def clean(self) -> dict[str, Any]: + """Reject unknown selections and normalize active add-on parameters.""" + cleaned = cast("dict[str, Any]", super().clean()) + tier = cleaned.get("tier") + addons: dict[str, dict[str, ParamValue]] = {} + known = {self.add_prefix(name) for name in self.fields} + for name in self.data: + if name.startswith(self.add_prefix("addon_")) and name not in known: + self.add_error(None, "Unknown additional service or parameter.") + for addon in self.agreement.addons: + name = f"addon_{addon.key}" + if not cleaned.get(name) or tier in addon.included_in_tiers: + continue + params = { + param.key: cleaned.get(f"{name}_{param.key}") + for param in addon.params + if not self.fields[f"{name}_{param.key}"].disabled + } + if any(f"{name}_{param.key}" in self.errors for param in addon.params): + continue + try: + addons[addon.key] = normalize_params(addon, params) + except ValidationError as exc: + self.add_error(name, ValidationError(exc.messages)) + cleaned["addons"] = addons + return cleaned + + +class OrderBuilder: + """An order form and one form for each available agreement.""" + + def __init__( + self, + data: QueryDict | None = None, + *, + program: Program, + order: Order | None = None, + staff: bool = False, + can_link_accounts: bool = False, + ) -> None: + """Restrict the builder to the program and any private-order scope.""" + self.order, self.program, self.staff = order, program, staff + self.can_link_accounts = can_link_accounts + self.catalog = order.catalog if order else program.catalog + lines = {line.agreement: line for line in order.agreement_list} if order else {} + self.agreements = { + slug: agreement + for slug, agreement in self.catalog.agreements.items() + if not order or program.is_public or staff or slug in lines + } + self.order_form = OrderForm( + data, + instance=order, + catalog=self.catalog, + agreements=self.agreements, + can_link_accounts=can_link_accounts, + ) + self.agreement_forms = { + slug: AgreementForm(data, agreement=agreement, line=lines.get(slug), staff=staff) + for slug, agreement in self.agreements.items() + } + + @property + def chosen(self) -> list[str]: + """Return only available, selected agreement slugs.""" + if self.order_form.is_bound: + return [ + slug + for slug in self.agreements + if slug in cast("QueryDict", self.order_form.data).getlist("agreements") + ] + return list(self.order_form.initial.get("agreements", [])) + + @property + def parameter_conditions(self) -> dict[str, dict[str, dict[str, dict[str, ParamValue]]]]: + """Declarative field visibility rules for the live builder.""" + return { + slug: { + addon.key: {param.key: param.required_when for param in addon.params if param.required_when} + for addon in agreement.addons + } + for slug, agreement in self.agreements.items() + } + + def is_valid(self) -> bool: + """Validate customer details and every selected agreement.""" + forms_to_check = [self.order_form, *(self.agreement_forms[slug] for slug in self.chosen)] + return all([form.is_valid() for form in forms_to_check]) # noqa: C419 - collect every form's errors + + @property + def has_errors(self) -> bool: + """Report errors from customer details or selected agreements.""" + return bool(self.order_form.errors) or any(self.agreement_forms[slug].errors for slug in self.chosen) + + @transaction.atomic + def save(self, user: User) -> Order: + """Persist the draft and selected lines atomically.""" + order = cast("Order", self.order_form.save(commit=False)) + order.program = self.program + if order.created_by_id is None: + order.created_by = user + if self.can_link_accounts: + order.customer_account = self.order_form.cleaned_data["customer_account_email"] + elif not self.staff and order._state.adding: # noqa: SLF001 - Django model state API + order.customer_account = user + order.authorized_contacts = self.order_form.cleaned_data["contacts"] + order.save() + chosen = self.order_form.cleaned_data["agreements"] + order.agreements.exclude(agreement__in=chosen).delete() + for slug in chosen: + data = self.agreement_forms[slug].cleaned_data + defaults = {"tier": data["tier"], "addons": data["addons"]} + if self.staff: + defaults["special_terms"] = data["special_terms"].strip() + OrderLine.objects.update_or_create(order=order, agreement=slug, defaults=defaults) + return order diff --git a/apps/agreements/orders/kinds.py b/apps/agreements/orders/kinds.py new file mode 100644 index 000000000..332501908 --- /dev/null +++ b/apps/agreements/orders/kinds.py @@ -0,0 +1,77 @@ +"""Offer configured orders through the shared signing workflow.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from django.contrib.auth.models import AnonymousUser + + from apps.agreements.models import Agreement, TermsVersion + from apps.agreements.registry import AgreementDetails + from apps.users.models import User + +from django.core.exceptions import ValidationError + +from apps.agreements.models import Order, Program +from apps.agreements.orders.documents import compose_order_form_markdown +from apps.agreements.registry import Kind, register + + +@register +class OrderKind(Kind[Order]): + """Freeze configuration and fees for a customer's selected agreements.""" + + slug = "order" + name = "Order Form" + model = Order + + def details(self, subject: Order) -> AgreementDetails: + """Use the configured document title and the customer's signatory account.""" + return { + "title": subject.catalog.order_title, + "counterparty_name": subject.legal_name, + "counterparty_account": subject.customer_account, + } + + def cited_terms_versions(self, subject: Order) -> list[TermsVersion]: + """Use exactly the terms versions resolved while composing this offer.""" + return [cast("TermsVersion", line.cited_terms) for line in subject.agreement_list] + + def freeze(self, subject: Order) -> None: + """Pin the catalog and all fee schedules before generating the signed document.""" + program = Program.objects.select_for_update().get(pk=subject.program_id) + subject.catalog_snapshot = deepcopy(program.definition) + subject.program_title_snapshot = program.title + subject.__dict__.pop("catalog", None) + subject.save(update_fields=["catalog_snapshot", "program_title_snapshot"]) + lines = subject.agreement_list + if not lines: + msg = "Choose at least one agreement before offering this order." + raise ValidationError(msg) + for line in lines: + if line.agreement not in subject.catalog.agreements: + msg = "The available selections changed. Edit this draft before offering it." + raise ValidationError(msg) + if line.cited_terms is None: + msg = "Publish a terms version for every selected agreement before offering this order." + raise ValidationError(msg) + line.pricing = line.quote().as_dict() + line.save(update_fields=["pricing"]) + + def release(self, subject: Order) -> None: + """Use current configuration again after withdrawing an unsigned offer.""" + subject.agreements.update(pricing={}) + subject.catalog_snapshot = {} + subject.program_title_snapshot = "" + subject.save(update_fields=["catalog_snapshot", "program_title_snapshot"]) + + def compose(self, subject: Order) -> str: + """Compose the order from its selected agreements.""" + return compose_order_form_markdown(subject) + + def can_view(self, user: User | AnonymousUser, agreement: Agreement) -> bool: + """Use the order's group and linked-customer access rules.""" + order = self.subject(agreement) + return order is not None and order.can_view(user) diff --git a/apps/agreements/orders/pricing.py b/apps/agreements/orders/pricing.py new file mode 100644 index 000000000..c79a1936c --- /dev/null +++ b/apps/agreements/orders/pricing.py @@ -0,0 +1,365 @@ +"""Exact fee calculations from validated, declarative catalog rules.""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, localcontext +from typing import Any, Literal, NoReturn, TypedDict, cast + +from django.core.exceptions import ValidationError + +from apps.agreements.orders.catalog import MONTHS_PER_YEAR, PERCENT_SCALE, AddOn, Agreement, Param + +CENTS = Decimal("0.01") + +ParamValue = str | int | list[str] +type FeeKey = Literal[ + "tier_fee", "subtotal", "discount_amount", "total_annual", "term_total", "discount_percent", "term_months" +] + + +class LineItemSnapshot(TypedDict): + """Serialized charge retained in an offered order.""" + + name: str + detail: str + amount: str | None + display: str + recurring: bool + basis: str + + +class QuoteDisplay(TypedDict): + """Formatted monetary fields for order presentation.""" + + tier_fee: str + subtotal: str + discount_amount: str + total_annual: str + term_total: str + + +class QuoteSnapshot(TypedDict): + """Frozen quote schema stored with each order line.""" + + tier_name: str + tier_fee: str + items: list[LineItemSnapshot] + subtotal: str + discount_name: str + discount_percent: int + discount_amount: str + total_annual: str + term_months: int + term_total: str + display: QuoteDisplay + + +def _precision(*amounts: Decimal) -> int: + """Allow exact sums, products, and cent rounding beyond the default context.""" + return max( + 28, sum(len(amount.as_tuple().digits) + abs(cast("int", amount.as_tuple().exponent)) for amount in amounts) + 4 + ) + + +def money(amount: Decimal | str | float) -> str: + """Format a decimal amount as dollars, omitting whole-dollar cents.""" + amount = Decimal(amount) + with localcontext() as context: + context.prec = _precision(amount) + amount = amount.quantize(CENTS) + if amount == amount.to_integral(): + return f"${amount:,.0f}" + return f"${amount:,.2f}" + + +def fee_totals(pricing: QuoteSnapshot) -> dict[str, Decimal]: + """Separate recurring and one-time fees without changing stored first-year totals.""" + first_year = Decimal(pricing["total_annual"]) + amounts = [ + Decimal(item["amount"]) for item in pricing["items"] if item["amount"] is not None and not item["recurring"] + ] + with localcontext() as context: + context.prec = _precision(first_year, *amounts) + one_time = sum(amounts, Decimal(0)) + return {"annual": first_year - one_time, "one_time": one_time} + + +@dataclass(frozen=True) +class LineItem: + """One additional-service charge, or an explicit separately quoted item.""" + + name: str + detail: str + amount: Decimal | None + recurring: bool + basis: str + + +@dataclass(frozen=True) +class Quote: + """A complete fee schedule with tier-only discounts and term totals.""" + + tier_name: str + tier_fee: Decimal + items: tuple[LineItem, ...] + discount_name: str + discount_percent: int + discount_amount: Decimal + term_months: int + + @property + def subtotal(self) -> Decimal: + """Return the tier fee plus every priced additional service.""" + amounts = [self.tier_fee, *(item.amount for item in self.items if item.amount is not None)] + with localcontext() as context: + context.prec = _precision(*amounts) + return sum(amounts, Decimal(0)) + + @property + def total_annual(self) -> Decimal: + """Return the first year's fees after the tier discount.""" + subtotal = self.subtotal + with localcontext() as context: + context.prec = _precision(subtotal, self.discount_amount) + return subtotal - self.discount_amount + + @property + def term_years(self) -> int: + """Return the number of whole years in the initial term.""" + return self.term_months // MONTHS_PER_YEAR + + @property + def term_total(self) -> Decimal: + """Repeat recurring charges across the term and charge other items once.""" + priced = [item for item in self.items if item.amount is not None] + with localcontext() as context: + context.prec = _precision( + self.tier_fee, + self.discount_amount, + Decimal(self.term_years), + *(cast("Decimal", item.amount) for item in priced), + ) + recurring = self.tier_fee - self.discount_amount + recurring += sum((cast("Decimal", item.amount) for item in priced if item.recurring), Decimal(0)) + one_time = sum((cast("Decimal", item.amount) for item in priced if not item.recurring), Decimal(0)) + return recurring * self.term_years + one_time + + @property + def unpriced_items(self) -> list[LineItem]: + """Return selected services whose fees are not determined in advance.""" + return [item for item in self.items if item.amount is None] + + def as_dict(self) -> QuoteSnapshot: + """Return a JSON-safe fee snapshot for storage and the order builder.""" + return { + "tier_name": self.tier_name, + "tier_fee": format(self.tier_fee, "f"), + "items": [ + { + "name": item.name, + "detail": item.detail, + "amount": None if item.amount is None else format(item.amount, "f"), + "display": item.basis if item.amount is None else money(item.amount), + "recurring": item.recurring, + "basis": item.basis, + } + for item in self.items + ], + "subtotal": format(self.subtotal, "f"), + "discount_name": self.discount_name, + "discount_percent": self.discount_percent, + "discount_amount": format(self.discount_amount, "f"), + "total_annual": format(self.total_annual, "f"), + "term_months": self.term_months, + "term_total": format(self.term_total, "f"), + "display": { + "tier_fee": money(self.tier_fee), + "subtotal": money(self.subtotal), + "discount_amount": money(self.discount_amount), + "total_annual": money(self.total_annual), + "term_total": money(self.term_total), + }, + } + + +def _invalid(message: str) -> NoReturn: + raise ValidationError(message) + + +def _quantity(params: dict[str, ParamValue], key: str) -> int: + value = params[key] + return len(value) if isinstance(value, list) else cast("int", value) + + +def _validate_sum_quantities(rule: dict[str, Any], params: dict[str, ParamValue]) -> list[int]: + """Require a positive quantity in each sum, even when its price is zero.""" + kind = rule["type"] + if kind in {"quantity", "brackets"}: + return [_quantity(params, rule["parameter"])] + if kind == "choice": + return _validate_sum_quantities(rule["options"][params[rule["parameter"]]], params) + if kind == "sum": + counts = [count for child in rule["components"] for count in _validate_sum_quantities(child, params)] + if counts and not any(counts): + _invalid("Select at least one item for the additional service.") + return counts + return [] + + +def _clean_count(value: object, param: Param, addon: AddOn) -> int: + if value is None or value == "": + if param.minimum == 0: + return 0 + _invalid(f"{addon.name}: {param.label} is required.") + if not isinstance(value, int) or isinstance(value, bool): + if not isinstance(value, str): + _invalid(f"{addon.name}: {param.label} must be a whole number.") + try: + value = int(value) + except ValueError: + message = f"{addon.name}: {param.label} must be a whole number." + raise ValidationError(message) from None + if value < param.minimum: + _invalid(f"{addon.name}: {param.label} must be at least {param.minimum}.") + return value + + +def _clean_lines(value: object, param: Param, addon: AddOn) -> list[str]: + if isinstance(value, str): + value = value.splitlines() + if not isinstance(value, list) or any(not isinstance(line, str) for line in value): + _invalid(f"{addon.name}: {param.label} must contain one text value per line.") + value = [line.strip() for line in value if line.strip()] + if not value: + _invalid(f"{addon.name}: {param.label} is required.") + return value + + +def _clean_value(value: object, param: Param, addon: AddOn) -> ParamValue: + if param.kind == "count": + return _clean_count(value, param, addon) + if param.kind == "lines": + return _clean_lines(value, param, addon) + if param.kind == "choice": + if not isinstance(value, str) or value not in dict(param.choices): + _invalid(f"{addon.name}: select a valid {param.label}.") + return value + if not isinstance(value, str) or not value.strip(): + _invalid(f"{addon.name}: {param.label} is required.") + return value.strip() + + +def normalize_params(addon: AddOn, params: object) -> dict[str, ParamValue]: + """Validate selected parameters and omit those whose conditions do not apply.""" + if not isinstance(params, dict): + _invalid(f"{addon.name}: parameters must be an object.") + by_key = {param.key: param for param in addon.params} + unknown = params.keys() - by_key.keys() + if unknown: + _invalid(f"{addon.name}: unknown parameter(s): {', '.join(sorted(map(str, unknown)))}.") + cleaned: dict[str, ParamValue] = {} + visited: set[str] = set() + + def clean(key: str) -> None: + if key in visited: + return + param = by_key[key] + for dependency in param.required_when: + clean(dependency) + visited.add(key) + if any(cleaned.get(dependency) != value for dependency, value in param.required_when.items()): + return + cleaned[key] = _clean_value(params.get(key), param, addon) + + for param in addon.params: + clean(param.key) + _validate_sum_quantities(addon.pricing, cleaned) + return cleaned + + +def _price_rule(rule: dict[str, Any], params: dict[str, ParamValue]) -> tuple[Decimal | None, bool, str, str]: + kind = rule["type"] + if kind == "choice": + return _price_rule(rule["options"][params[rule["parameter"]]], params) + recurring = rule.get("recurring", True) + basis = rule.get("basis") or ("per year" if recurring else "one-time") + detail = rule.get("detail", "") + amount: Decimal | None + count: Decimal | int + if kind == "fixed": + amount = Decimal(rule["amount"]) + elif kind == "quantity": + count = Decimal(max(0, _quantity(params, rule["parameter"]) - rule.get("included", 0))) + unit = Decimal(rule["unit_amount"]) + base = Decimal(rule.get("base_amount", "0")) + with localcontext() as context: + context.prec = _precision(count, unit, base) + amount = base + count * unit + elif kind == "brackets": + count = _quantity(params, rule["parameter"]) + amount = next((Decimal(band["amount"]) for band in rule["bands"] if count <= band["up_to"]), None) + if amount is None: + basis = rule["overflow_basis"] + elif kind == "sum": + components = [_price_rule(component, params) for component in rule["components"]] + amounts = [component[0] for component in components] + if any(amount is None for amount in amounts): + amount = None + else: + with localcontext() as context: + context.prec = _precision(*cast("list[Decimal]", amounts)) + amount = sum(cast("list[Decimal]", amounts), Decimal(0)) + if not detail: + detail = "; ".join(component[3] for component in components if component[3]) + else: + amount = None + basis = rule.get("basis") or "Quoted separately" + return amount, recurring, basis, detail + + +def _price_addon(addon: AddOn, params: object, tier_key: str) -> LineItem: + params = normalize_params(addon, params) + amount, recurring, basis, detail = _price_rule(addon.pricing, params) + selections = [] + for param in addon.params: + if param.key not in params: + continue + value = params[param.key] + if param.kind == "choice": + value = dict(param.choices)[cast("str", value)] + elif param.kind == "lines": + value = ", ".join(cast("list[str]", value)) + selections.append(f"{param.label}: {value}") + if tier_key in addon.included_in_tiers: + amount, basis, detail = Decimal(0), "included", "Included at this service tier" + detail = "; ".join(part for part in (detail, *selections) if part) + return LineItem(addon.name, detail, amount, recurring, basis) + + +def build_quote(agreement: Agreement, tier_key: str, discount_key: str, addons: object = None) -> Quote: + """Price all selections in catalog order; discounts affect only the tier fee.""" + tier = agreement.tier(tier_key) + discount = agreement.discount(discount_key) + selected = {} if addons is None else addons + if not isinstance(selected, dict): + _invalid("Selected additional services must be an object.") + known = {addon.key for addon in agreement.addons} + unknown = selected.keys() - known + if unknown: + _invalid(f"Unknown additional service(s): {', '.join(sorted(map(str, unknown)))}.") + items = tuple( + _price_addon(addon, selected[addon.key], tier_key) for addon in agreement.addons if addon.key in selected + ) + with localcontext() as context: + context.prec = _precision(tier.annual_fee) + discount_amount = (tier.annual_fee * discount.percent / PERCENT_SCALE).quantize(CENTS) + return Quote( + tier_name=tier.name, + tier_fee=tier.annual_fee, + items=items, + discount_name=discount.name, + discount_percent=discount.percent, + discount_amount=discount_amount, + term_months=discount.term_months, + ) diff --git a/apps/agreements/orders/urls.py b/apps/agreements/orders/urls.py new file mode 100644 index 000000000..dedbcf91c --- /dev/null +++ b/apps/agreements/orders/urls.py @@ -0,0 +1,20 @@ +"""Program and order routes included in the agreements namespace.""" + +from django.urls import path + +from apps.agreements.orders import views + +urlpatterns = [ + path("programs/", views.program_list, name="program_list"), + path("programs//", views.program_detail, name="program_detail"), + path("programs//orders/new/", views.order_create, name="order_create"), + path("programs//quote/", views.quote, name="quote"), + path("orders/", views.order_list, name="order_list"), + path("orders/all/", views.staff_orders, name="staff_orders"), + path("orders//", views.order_detail, name="order_detail"), + path("orders//edit/", views.order_edit, name="order_edit"), + path("orders//sign/", views.order_sign, name="order_sign"), + path("orders//offer/", views.order_offer, name="order_offer"), + path("orders//delete/", views.order_delete, name="order_delete"), + path("orders//document.", views.order_document, name="order_document"), +] diff --git a/apps/agreements/orders/views.py b/apps/agreements/orders/views.py new file mode 100644 index 000000000..650d79a27 --- /dev/null +++ b/apps/agreements/orders/views.py @@ -0,0 +1,359 @@ +"""Program browsing, order selection, and shared agreement signing entry points.""" + +from __future__ import annotations + +from decimal import Decimal +from typing import TYPE_CHECKING, Any, cast +from uuid import UUID + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.contrib.auth.views import redirect_to_login +from django.core.exceptions import ValidationError +from django.db import transaction +from django.db.models import Prefetch, Q +from django.http import Http404, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.cache import never_cache +from django.views.decorators.http import require_GET, require_POST + +from apps.agreements import documents as agreement_documents +from apps.agreements import workflow +from apps.agreements.auth import can_prepare, is_administrator, preparer_required +from apps.agreements.documents import sha256 +from apps.agreements.forms.signing import SignForm +from apps.agreements.models import Order, OrderLine, Program +from apps.agreements.orders import documents +from apps.agreements.orders.forms import OrderBuilder +from apps.agreements.orders.pricing import build_quote, money +from apps.agreements.registry import get_kind +from apps.agreements.views.helpers import action_forms, file_response, signature_of + +if TYPE_CHECKING: + from collections.abc import Iterable + + from django.contrib.auth.models import AnonymousUser + from django.db.models import QuerySet + from django.http import HttpRequest, HttpResponse + + from apps.users.models import User + +KIND = "order" + + +def _orders() -> QuerySet[Order]: + return Order.objects.select_related("program", "created_by", "customer_account", "agreement").prefetch_related( + Prefetch("agreements", queryset=OrderLine.objects.all()) + ) + + +def _own_orders(user: User | AnonymousUser) -> QuerySet[Order]: + if not user.is_authenticated: + return _orders().none() + owned = Q(customer_account=user) + if can_prepare(user): + owned |= Q(created_by=user) + return _orders().filter(owned) + + +def _order_rows(orders: Iterable[Order]) -> list[dict[str, Any]]: + """Keep stale drafts reachable without presenting an invented price.""" + rows: list[dict[str, Any]] = [] + for order in orders: + row: dict[str, Any] = {"order": order} + try: + row["selections"] = [(line.agreement_obj.short_name, line.tier_name) for line in order.agreement_list] + row["fees"] = order.fee_totals + except (ValidationError, KeyError): + if not order.is_editable: + raise + row["review_selections"] = True + rows.append(row) + return rows + + +def _order_or_404(request: HttpRequest, pk: UUID, *, for_update: bool = False) -> Order: + orders = _orders() if can_prepare(request.user) else _own_orders(request.user) + if for_update: + orders = orders.select_for_update(of=("self",)) + return get_object_or_404(orders, pk=pk) + + +def _program_or_404(request: HttpRequest, slug: str) -> Program: + return get_object_or_404(Program.visible_to(request.user), slug=slug) + + +@never_cache +@require_GET +def program_list(request: HttpRequest) -> HttpResponse: + """List only programs the visitor may browse.""" + return render( + request, + "agreements/orders/program_list.html", + { + "programs": Program.visible_to(request.user), + "nav": "programs", + }, + ) + + +@never_cache +@require_GET +def program_detail(request: HttpRequest, slug: str) -> HttpResponse: + """Compare a program's configured agreements and the visitor's recent orders.""" + program = _program_or_404(request, slug) + agreements = list(program.catalog.agreements.values()) + return render( + request, + "agreements/orders/overview.html", + { + "program": program, + "catalog": program.catalog, + "agreements": agreements, + "under_review": any(agreement.terms.under_review for agreement in agreements), + "orders": _order_rows(_own_orders(request.user).filter(program=program)[:5]), + "nav": "programs", + }, + ) + + +@never_cache +@require_GET +def quote(request: HttpRequest, slug: str) -> JsonResponse: + """Price valid selections without exposing a private program to unrelated visitors.""" + order = None + if request.GET.get("order"): + try: + pk = UUID(request.GET["order"]) + except ValueError: + raise Http404 from None + order = _order_or_404(request, pk) + if order.program.slug != slug or not order.can_edit(request.user): + raise Http404 + program = order.program + else: + program = _program_or_404(request, slug) + builder = OrderBuilder( + request.GET, + program=program, + order=order, + staff=can_prepare(request.user), + can_link_accounts=is_administrator(request.user), + ) + selections = builder.order_form.fields["agreements"] + errors: dict[str, dict[str, list[dict[str, str]]] | list[str]] = {} + try: + # No selection is a useful empty quote, not a submitted order. + chosen = selections.clean(request.GET.getlist("agreements")) if request.GET.getlist("agreements") else [] + discount = builder.order_form.fields["discount"].clean(request.GET.get("discount")) + except ValidationError as exc: + return JsonResponse({"errors": {"selections": exc.messages}}, status=400) + quotes: list[dict[str, Any]] = [] + for key in builder.agreements: + if key not in chosen: + continue + form = builder.agreement_forms[key] + if not form.is_valid(): + errors[key] = form.errors.get_json_data() + continue + try: + priced = build_quote( + builder.agreements[key], form.cleaned_data["tier"], discount, form.cleaned_data["addons"] + ) + except ValidationError as exc: + errors[key] = exc.messages + continue + quotes.append({"slug": key, "name": builder.agreements[key].short_name, **priced.as_dict()}) + if errors: + return JsonResponse({"errors": errors}, status=400) + total_annual = sum((Decimal(q["total_annual"]) for q in quotes), Decimal(0)) + term_total = sum((Decimal(q["term_total"]) for q in quotes), Decimal(0)) + return JsonResponse( + { + "agreements": quotes, + "term_months": builder.catalog.discount(discount).term_months, + "display": {"total_annual": money(total_annual), "term_total": money(term_total)}, + } + ) + + +def _builder(request: HttpRequest, program: Program, order: Order | None = None) -> HttpResponse: + staff = can_prepare(request.user) + builder = OrderBuilder( + request.POST or None, + program=program, + order=order, + staff=staff, + can_link_accounts=is_administrator(request.user), + ) + if not order: + preselect = [slug for slug in request.GET.getlist("agreement") if slug in builder.agreements] + builder.order_form.initial["agreements"] = preselect + if request.method == "POST" and builder.is_valid(): + return redirect(builder.save(cast("User", request.user))) + return render( + request, + "agreements/orders/order_form.html", + { + "builder": builder, + "form": builder.order_form, + "program": program, + "catalog": builder.catalog, + "agreements": list(builder.agreements.values()), + "order": order, + "staff": staff, + "nav": "orders", + "can_browse_program": program.is_public or staff, + }, + ) + + +@never_cache +def order_create(request: HttpRequest, slug: str) -> HttpResponse: + """Start a public order, or a staff-prepared private order.""" + program = _program_or_404(request, slug) + if not request.user.is_authenticated: + return redirect_to_login(request.get_full_path()) + return _builder(request, program) + + +@never_cache +@login_required +@transaction.atomic +def order_edit(request: HttpRequest, pk: UUID) -> HttpResponse: + """Change selections until the Order Form is offered for signature.""" + order = _order_or_404(request, pk, for_update=request.method == "POST") + if not order.can_edit(request.user): + messages.error(request, "This Order Form is out for signature. Withdraw it to change the selections.") + return redirect(order) + return _builder(request, order.program, order) + + +@never_cache +@login_required +def order_list(request: HttpRequest) -> HttpResponse: + """List the customer's orders, including drafts needing revised selections.""" + return render( + request, + "agreements/orders/order_list.html", + { + "orders": _order_rows(_own_orders(request.user)), + "nav": "orders", + }, + ) + + +@never_cache +@login_required +def order_detail(request: HttpRequest, pk: UUID, sign_form: SignForm | None = None, status: int = 200) -> HttpResponse: + """Review the order and use the common account, link, or signed-copy actions.""" + order = _order_or_404(request, pk) + user = request.user + context: dict[str, Any] = { + "order": order, + "is_customer": order.is_customer(user), + "can_prepare": can_prepare(user), + "is_administrator": is_administrator(user), + "can_offer": order.can_offer(user), + "can_edit": order.can_edit(user), + "nav": "staff" if can_prepare(user) and not order.is_customer(user) else "orders", + "next": order.get_absolute_url(), + } + try: + markdown = documents.order_form_markdown(order) + except (ValidationError, KeyError): + if not order.is_editable: + raise + context["configuration_error"] = "The available selections have changed. Edit this draft before signing." + else: + context["html"], context["metadata_hidden"] = agreement_documents.render_agreement_preview(markdown) + context["lines"] = order.agreement_list + if order.agreement: + context.update(action_forms(request, order.agreement)) + elif order.is_customer(user): + context["draft_sign_form"] = sign_form or SignForm( + initial={ + "document_sha256": sha256(documents.compose_order_form_markdown(order)), + "signer_name": cast("User", user).get_full_name(), + } + ) + return render(request, "agreements/orders/order_detail.html", context, status=status) + + +@never_cache +@login_required +@require_POST +@transaction.atomic +def order_sign(request: HttpRequest, pk: UUID) -> HttpResponse: + """Freeze and sign the reviewed draft in one atomic transition.""" + order = _order_or_404(request, pk, for_update=True) + if not order.is_customer(request.user) or not order.is_editable: + raise Http404 + form = SignForm(request.POST) + if not form.is_valid(): + return order_detail(request, pk, sign_form=form, status=400) + try: + with transaction.atomic(): + agreement = workflow.offer(get_kind(KIND), order, user=cast("User", request.user)) + workflow.sign(agreement, signature_of(request, form), seen_sha256=form.cleaned_data["document_sha256"]) + except (workflow.InvalidTransitionError, ValidationError) as exc: + messages.error(request, "; ".join(exc.messages) if isinstance(exc, ValidationError) else str(exc)) + else: + messages.success(request, "Signed. The PSF will now countersign the Order Form.") + return redirect(order) + + +@never_cache +@login_required +@require_POST +@transaction.atomic +def order_offer(request: HttpRequest, pk: UUID) -> HttpResponse: + """Fix the text for an account, a signing link, or an externally signed copy.""" + order = _order_or_404(request, pk, for_update=True) + if not order.can_offer(request.user): + raise Http404 + try: + workflow.offer(get_kind(KIND), order, user=cast("User", request.user)) + except (workflow.InvalidTransitionError, ValidationError) as exc: + messages.error(request, "; ".join(exc.messages) if isinstance(exc, ValidationError) else str(exc)) + else: + messages.success(request, "The Order Form is ready to sign. Its text is fixed until it is signed or withdrawn.") + return redirect(order) + + +@login_required +@require_POST +@transaction.atomic +def order_delete(request: HttpRequest, pk: UUID) -> HttpResponse: + """Discard an authorized draft without touching offered agreements.""" + order = _order_or_404(request, pk, for_update=True) + if not order.can_edit(request.user): + raise Http404 + order.delete() + messages.success(request, "Draft discarded.") + return redirect("agreements:order_list") + + +@login_required +def order_document(request: HttpRequest, pk: UUID, fmt: str) -> HttpResponse: + """Download the authorized order's draft or frozen document.""" + order = _order_or_404(request, pk) + if fmt not in agreement_documents.RENDERERS: + raise Http404 + try: + markdown = documents.order_form_markdown(order) + except (ValidationError, KeyError): + if not order.is_editable: + raise + messages.error(request, "The available selections have changed. Edit this draft before downloading it.") + return redirect(order) + return file_response( + agreement_documents.RENDERERS[fmt](markdown), fmt, f"order-form-{order.reference}-{order.status}" + ) + + +@never_cache +@preparer_required +def staff_orders(request: HttpRequest) -> HttpResponse: + """List all orders for agreement managers.""" + return render(request, "agreements/orders/staff_queue.html", {"orders": _order_rows(_orders()), "nav": "staff"}) diff --git a/apps/agreements/pandoc_filters/agreement.lua b/apps/agreements/pandoc_filters/agreement.lua new file mode 100644 index 000000000..3c6ccc218 --- /dev/null +++ b/apps/agreements/pandoc_filters/agreement.lua @@ -0,0 +1,26 @@ +-- Layout and safety for agreement documents. +-- ::: sub third-level clauses: indented in DOCX/PDF, a styled div in HTML +-- ::: pagebreak hard page break in DOCX/PDF, dropped in HTML +-- Documents may be written by PSF staff, so they cannot embed images: pandoc would fetch them. + +local function is_paged() + return FORMAT == "docx" or FORMAT == "latex" or FORMAT == "pdf" +end + +function Image(element) + error("Agreement documents cannot embed images or load external resources.") +end + +function Div(el) + if el.classes:includes("pagebreak") then + if FORMAT == "docx" then + return pandoc.RawBlock("openxml", '') + elseif is_paged() then + return pandoc.RawBlock("latex", "\\newpage") + end + return {} + end + if el.classes:includes("sub") and is_paged() then + return pandoc.BlockQuote(el.content) + end +end diff --git a/apps/agreements/registry.py b/apps/agreements/registry.py new file mode 100644 index 000000000..abd4f3feb --- /dev/null +++ b/apps/agreements/registry.py @@ -0,0 +1,75 @@ +"""Kinds of agreement: how a domain app drafts, freezes, and follows up on its documents. + +A domain model (a configured order, a custom contract, or a sponsorship) holds the +selections and has a ``OneToOneField`` named ``agreement`` to ``agreements.Agreement``. It +stays editable until it is offered; the workflow then freezes it and sets that field. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypedDict, cast + +if TYPE_CHECKING: + from django.contrib.auth.models import AnonymousUser + + from apps.agreements.models import Agreement, CustomContract, Order, TermsVersion + from apps.users.models import User + + +class AgreementDetails(TypedDict): + """Counterparty identity supplied by a registered draft kind.""" + + title: str + counterparty_name: str + counterparty_account: User | None + + +_KINDS: dict[str, Kind[Any]] = {} + + +class Kind[Subject: (CustomContract, Order)]: + """Base class for a registered kind of agreement.""" + + slug = "" + name = "" + model: type[Subject] = cast("type[Subject]", None) + + def details(self, subject: Subject) -> AgreementDetails: + """Return ``title``, ``counterparty_name``, and ``counterparty_account`` for a new agreement.""" + raise NotImplementedError + + def compose(self, subject: Subject) -> str: + """Return the document's markdown, with ``documents.SIGNATURES`` where the signatures go.""" + raise NotImplementedError + + def cited_terms_versions(self, subject: Subject) -> list[TermsVersion]: + """Return the ``TermsVersion`` objects the document cites.""" + return [] + + def freeze(self, subject: Subject) -> None: + """Fix anything the document depends on, such as prices, when it is offered.""" + + def release(self, subject: Subject) -> None: + """Undo ``freeze`` when an offer is withdrawn and the subject becomes editable again.""" + + def executed(self, agreement: Agreement) -> None: + """Act on a fully signed agreement, for example by activating a sponsorship.""" + + def can_view(self, user: User | AnonymousUser, agreement: Agreement) -> bool: + """Whether a user other than PSF staff and the counterparty's account may view it.""" + return False + + def subject(self, agreement: Agreement) -> Subject | None: + """Return the domain object the agreement was offered for, if it still points at it.""" + return self.model.objects.filter(agreement=agreement).first() + + +def register[K: Kind[Any]](kind_class: type[K]) -> type[K]: + """Register a ``Kind`` subclass under its slug.""" + _KINDS[kind_class.slug] = kind_class() + return kind_class + + +def get_kind(slug: str) -> Kind[Any]: + """Return the registered kind for ``slug``.""" + return _KINDS[slug] diff --git a/apps/agreements/static/agreements/agreements.css b/apps/agreements/static/agreements/agreements.css new file mode 100644 index 000000000..0fdff344e --- /dev/null +++ b/apps/agreements/static/agreements/agreements.css @@ -0,0 +1,160 @@ +/* Agreement pages: documents, signing, and records. Layered on python.org's style.css. + Pages that use these pieces add the "agreements" class to . */ + +.agreements [hidden] { display: none !important; } + +/* Keep ordinary prose intact; long addresses and URLs may still wrap to fit. */ +.agreements :is(p, dd, blockquote, address) { + -webkit-hyphens: none; + hyphens: none; + overflow-wrap: break-word; +} +.agreements :is(h1, h2, h3, .breadcrumbs) { overflow-wrap: anywhere; } + +.agreements .num { text-align: right; white-space: nowrap; } +.agreements .actions { display: flex; flex-wrap: wrap; gap: 0.5em; align-items: center; } +.agreements .actions .button, +.agreements .actions form { display: inline-block; margin: 0; } +.agreements .actions button { margin: 0; } +.agreements .actions .discard-form button { min-height: 44px; padding: 0.5em; border: 0; background: none; box-shadow: none; color: #74333b !important; text-shadow: none; } +.agreements .actions .discard-form button:is(:hover, :focus) { background: none; color: #74333b !important; text-decoration: underline; } +.agreements .introduction { font-size: 1.125em; } +.agreements .aside-note { color: #666; } + +/* Primary link buttons match python.org's blue submit buttons. */ +.agreements a.button.button-primary { + color: #e6e8ea !important; + text-shadow: none; + background-color: #2b5b84; + background-image: linear-gradient(#3776ab 10%, #2b5b84 90%); + border: 1px solid #3776ab; + border-top-color: #3d83be; + border-left-color: #3d83be; +} +.agreements a.button.button-primary:hover, +.agreements a.button.button-primary:focus { + color: #f2f4f6 !important; + background-color: #244e71; + background-image: linear-gradient(#316998 10%, #244e71 90%); +} + +/* Section navigation: mark the current page like the site's sitetree menus. */ +.agreements .section-nav .selected > a { font-family: SourceSansProBold, Arial, sans-serif; color: #222; } +.agreements .account-widget p { margin-bottom: 0.65em; } +.agreements .account-widget .button { display: inline-block; } +.agreements .contents-widget .section-nav a { padding: 0.2em 0; line-height: 1.3; border-bottom: 0; } +.agreements .psf-sidebar-widget p { color: #f2f4f6; } + +/* Status pills use the site's feedback palette. */ +.status-pill { + display: inline-block; + padding: 0.1em 0.6em; + border-radius: 6px; + font-family: SourceSansProBold, Arial, sans-serif; + font-size: 0.85em; + white-space: nowrap; + vertical-align: 0.1em; + line-height: 1.4; +} +.status-success { background-color: #d6e7bf; border: 1px solid #82b043; color: #3d5a1a; } +.status-notice { background-color: #fff7dc; border: 1px solid #ffd343; color: #6b5200; } +.status-error { background-color: #ecd4d7; border: 1px solid #b55863; color: #74333b; } +.status-neutral { background-color: #e6e8ea; border: 1px solid #caccce; color: #444; } + +/* ---------- Tables ---------- */ + +.agreements .table-scroll { overflow-x: auto; } +.agreements .order-table { table-layout: fixed; min-width: 36em; } +.agreements .order-table .status-pill { white-space: normal; } + +.agreements .order-table, +.agreements .facts, +.agreements .record { margin: 0.5em 0 1.3125em; } +.agreements .order-table th, +.agreements .order-table td, +.agreements .facts th, +.agreements .facts td, +.agreements .record th, +.agreements .record td { padding: 0.45em 0.6em; border-bottom: 1px solid #e6e8ea; } +.agreements .order-table th, +.agreements .order-table td { overflow-wrap: anywhere; } +.agreements .order-table thead th, +.agreements .facts thead th { background-color: #e6e8ea; color: #444; font-family: SourceSansProBold, Arial, sans-serif; width: auto; } +.agreements .facts tbody th, +.agreements .record th { width: 32%; color: #666; font-weight: normal; font-family: SourceSansProRegular, Arial, sans-serif; } +.agreements .facts tfoot th, +.agreements .facts tfoot td { font-family: SourceSansProBold, Arial, sans-serif; color: #222; border-bottom: 0; } +.agreements .record caption { text-align: left; font-family: SourceSansProBold, Arial, sans-serif; color: #222; padding: 0.5em 0; } +.agreements .order-table .ref { display: block; font-size: 0.8em; color: #666; font-family: monospace; } + +/* ---------- Document text ---------- */ + +.agreements .terms-meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.5em 1.5em; color: #666; } +.agreements .agreement-meta { color: #666; margin-bottom: 0.5em; } +.agreements .agreement-text h1 { font-size: 1.5em; } +.agreements .agreement-text h2 { font-size: 1.3125em; font-family: SourceSansProBold, Arial, sans-serif; margin-top: 1.75em; } +.agreements .agreement-text .sub { margin-left: 1.75em; } +.agreements .agreement-text .sub p { margin-bottom: 0.4em; } +.agreements .agreement-text table th, +.agreements .agreement-text table td { border: 1px solid #caccce; padding: 0.4em 0.6em; } +.agreements .agreement-text table thead th { background-color: #e6e8ea; } +.agreements .next-step .button { display: inline-block; margin-top: 0.5em; } +.agreements .agreement-scroll { + max-height: 32em; + overflow: auto; + padding: 0 1.25em; + margin-bottom: 1.3125em; + border: 1px solid #caccce; + border-top: 5px solid #caccce; +} +.agreements .agreement-details summary { cursor: pointer; color: #3776ab; margin-bottom: 0.875em; } +.agreements .agreement-block { border-top: 5px solid #caccce; margin-top: 1.75em; padding-top: 0.25em; } + +/* ---------- Forms ---------- */ + +.agreements .help { color: #666; font-size: 0.9em; margin-bottom: 0.65em; } +.agreements .req { color: #999; font-weight: normal; font-family: SourceSansProRegular, Arial, sans-serif; } +.agreements .field label { color: #444; } +.agreements .field input, +.agreements .field textarea, +.agreements .field select { margin-bottom: 0.25em; } +.agreements .field .help { margin-bottom: 0.25em; } +.agreements .field { margin-bottom: 0.65em; } +.agreements .field-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 1em; } +.agreements .has-error input, +.agreements .has-error textarea { border-color: #b55863; } +.agreements label.choice { + display: flex; gap: 0.6em; align-items: flex-start; margin: 0; + color: #444; font-weight: normal; cursor: pointer; +} +.agreements label.choice input { flex: none; margin: 0.3em 0 0; } +.agreements .choice-help { color: #666; font-size: 0.9em; } +.agreements .form-actions { display: flex; flex-wrap: wrap; gap: 1em; align-items: center; border-top: 5px solid #caccce; padding-top: 1em; margin-top: 1.75em; } +.agreements .form-actions button { margin-bottom: 0; } +.agreements textarea.markdown-editor { font-family: monospace; font-size: 0.9em; line-height: 1.45; width: 100%; } + +/* ---------- Signing and staff actions ---------- */ + +.agreements .discard-form { margin-top: 0.5em; } +.agreements .sign-form { margin-bottom: 1.3125em; } +.agreements .sign-form .accept { padding: 0.75em; border: 1px solid #caccce; border-radius: 6px; margin-bottom: 0.875em; } +.agreements .sign-form .accept.has-error { border-color: #b55863; } +.agreements .signing-as p { margin-bottom: 0.35em; } +.agreements .countersign, +.agreements .staff-panel { border-top: 5px solid #ffd343; margin-top: 1.75em; padding-top: 0.25em; } +.agreements .decline summary { cursor: pointer; color: #b55863; } +.agreements .offline-signing details > summary { cursor: pointer; color: #3776ab; margin-bottom: 0.875em; } + +/* ---------- Revision and terms history ---------- */ + +.agreements .history { list-style: none; margin: 0; padding: 0; } +.agreements .history > li { border-bottom: 1px solid #e6e8ea; padding: 0.55em 0; } +.agreements .history summary { cursor: pointer; } +.agreements .diff { font-size: 0.8em; line-height: 1.4; max-height: 28em; overflow: auto; background-color: #f2f4f6; padding: 0.5em; white-space: pre-wrap; } +.agreements .diff .add { background-color: #d6e7bf; display: block; } +.agreements .diff .del { background-color: #ecd4d7; display: block; } +.agreements .diff .hunk { color: #666; display: block; } + +@media (max-width: 800px) { + .agreements .field-row { grid-template-columns: minmax(0, 1fr); } +} diff --git a/apps/agreements/static/agreements/package-builder.css b/apps/agreements/static/agreements/package-builder.css new file mode 100644 index 000000000..4840a9618 --- /dev/null +++ b/apps/agreements/static/agreements/package-builder.css @@ -0,0 +1,606 @@ +/* Program and order pages. Layered on python.org's style.css and agreements/agreements.css. */ + +/* ---------- Program list and overview ---------- */ + +.agreement-orders .tier-table { margin: 0.5em 0 1.3125em; } +.agreement-orders .tier-table th, +.agreement-orders .tier-table td { padding: 0.45em 0.6em; border-bottom: 1px solid #e6e8ea; } +.agreement-orders .tier-table thead th { background-color: #e6e8ea; color: #444; font-family: SourceSansProBold, Arial, sans-serif; } +.agreement-orders .agreement-summary { border-top: 5px solid #caccce; padding-top: 0.5em; margin-top: 1.75em; } +.agreement-orders .agreement-summary h2 { margin-top: 0.25em; } +.agreement-orders .agreement-services { color: #666; font-size: 0.9em; } + +.program-overview .po-invite { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 1em 2em; + margin: 1.5em 0 2.25em; + padding: 1.25em 1.5em 1.35em; + background-color: #e9f1f8; + border-top: 5px solid #ffd343; +} +.program-overview .po-invite-title { margin: 0 0 0.2em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.3125em; color: #222; } +.program-overview .po-invite p { margin: 0; color: #444; } +.program-overview .po-invite-cta { margin: 0; white-space: nowrap; padding: 0.55em 1.25em; font-size: 1.0625em; } +.program-overview .po-tagline { margin-bottom: 0.5em; } +.program-overview .po-table-wrap { max-width: 100%; overflow-x: auto; } +.program-overview .po-terms { font-size: 0.9em; color: #666; } + +@media (max-width: 40em) { + .program-overview .po-invite { grid-template-columns: minmax(0, 1fr); } + .program-overview .po-invite-cta { justify-self: start; } +} + +/* ---------- Order page ---------- */ + +.agreement-orders .order-meta { color: #666; } +.agreement-orders .order-orgs { color: #666; font-size: 0.95em; } +.agreement-orders .num { font-variant-numeric: tabular-nums; } + +@media (max-width: 800px) { + .agreement-orders .tier-table { display: block; overflow-x: auto; } +} + +/* ================================================================ + Package builder (order form) + ================================================================ */ + +.pb-page { + --pb-blue: #3776ab; + --pb-blue-dark: #2b5b84; + --pb-ink: #222; + --pb-text: #444; + --pb-muted: #666; + --pb-faint: #888; + --pb-line: #e6e8ea; + --pb-line-strong: #caccce; + --pb-wash: #f2f4f6; + --pb-tint: #e9f1f8; + --pb-tint-line: #c2d9ec; + --pb-yellow: #ffd343; + --pb-green: #3d5a1a; + --pb-green-wash: #edf4e3; + --pb-red: #b55863; + --pb-red-ink: #74333b; + --pb-amber-ink: #6b5200; + --pb-amber-wash: #fff7dc; + --pb-ease: cubic-bezier(0.22, 1, 0.36, 1); +} + +.pb-page .pb-intro { max-width: 46em; } +.pb-page .pb-intro .page-title { margin-bottom: 0.5em; } +.pb-page .pb-intro .introduction { margin-bottom: 0.5em; } +.pb-page .pb-lede { color: var(--pb-muted); margin-bottom: 1.75em; max-width: 42em; } + +.package-builder { margin: 0; } +.package-builder fieldset { border: 0; margin: 0; padding: 0; min-inline-size: 0; } +.package-builder legend { padding: 0; } +.package-builder input, +.package-builder textarea { caret-color: var(--pb-blue); } +.package-builder :focus-visible { outline: 2px solid var(--pb-blue-dark); outline-offset: 2px; } +.package-builder input[type="checkbox"], +.package-builder input[type="radio"] { padding: 0; } +/* These controls draw the focus ring on their whole card instead. */ +.package-builder :is(.pb-tier-input, .pb-include input, .pb-term-option input):focus-visible { outline: none; } +.package-builder .errorlist { list-style: none; margin: 0.25em 0 0.5em; padding: 0; font-size: 0.9em; color: var(--pb-red-ink); } +.package-builder .errorlist li { margin: 0; } +.package-builder .help, +.package-builder .pb-help { color: var(--pb-muted); font-size: 0.9375em; line-height: 1.55; } +.package-builder .pb-help { max-width: 44em; margin: 0 0 1em; } + +/* Error summary */ +.package-builder .pb-errors { margin-bottom: 2em; } +.package-builder .pb-errors:focus { outline: 3px solid var(--pb-red); outline-offset: 2px; } +.package-builder .pb-errors-title { margin: 0 0 0.35em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.125em; color: var(--pb-red-ink); } +.package-builder .pb-errors ul { margin: 0; padding-left: 1.2em; list-style: disc; } +.package-builder .pb-errors li { margin: 0.15em 0; line-height: 1.5; } + +/* One focused task beside the running package; without JavaScript, tasks stack. */ +.package-builder .pb-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2em; } +.package-builder .pb-layout > [data-step-panel] { min-width: 0; grid-column: 1; } +.package-builder.is-enhanced .pb-layout > [data-step-panel] { grid-row: 1; } +.package-builder .pb-summary { grid-column: 1; } +.package-builder.is-enhanced .pb-summary { grid-row: 2; } +.package-builder .pb-steps { position: relative; display: flex; gap: 1.5em; overflow-x: auto; scrollbar-width: thin; margin: 0 0 0.75em; border-bottom: 1px solid var(--pb-line); } +.text .package-builder .pb-steps button { + flex: 1 0 auto; + white-space: nowrap; + margin: 0; + min-height: 48px; + padding: 0.5em 0.25em; + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + background: none; + box-shadow: none; + color: var(--pb-muted) !important; + font: inherit; + font-size: 0.9375em; + text-shadow: none; +} +.text .package-builder .pb-steps button:is(:hover, :focus, :active) { background: none; box-shadow: none; color: var(--pb-blue-dark) !important; } +.text .package-builder .pb-steps button[aria-current="step"] { border-bottom-color: var(--pb-blue); color: var(--pb-blue-dark) !important; font-family: SourceSansProBold, Arial, sans-serif; } +.package-builder .pb-progress-text { margin: 0 0 1.5em; color: var(--pb-muted); font-size: 0.875em; } +.package-builder.is-enhanced .pb-section { border-top: 0; padding-top: 0; } +.package-builder.is-enhanced #review .pb-actions > button { display: none; } +@media (min-width: 60em) { + .package-builder .pb-layout { grid-template-columns: minmax(0, 1fr) minmax(17em, 20em); column-gap: 2.5em; } + .package-builder .pb-summary, + .package-builder.is-enhanced .pb-summary { grid-column: 2; grid-row: 1; position: sticky; top: 1em; align-self: start; } + .package-builder .pb-summary-panel { max-height: calc(100vh - 7em); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--pb-line-strong) transparent; } +} + +.package-builder .pb-section { border-top: 1px solid var(--pb-line-strong); padding-top: 1.25em; } +.package-builder .pb-section > h2, +.package-builder .pb-section-head h2 { margin: 0 0 0.3em; font-size: 1.5em; color: var(--pb-ink); } + +/* ---------- Service family ---------- */ + +.package-builder .pb-family { + container: pbfamily / inline-size; + margin-top: 1.5em; + padding-top: 1.5em; + border-top: 1px solid var(--pb-line-strong); + scroll-margin-top: 1em; +} +.package-builder .pb-section-head + .pb-family, +.package-builder .pb-section-head + .errorlist + .pb-family { border-top: 0; padding-top: 0.25em; margin-top: 0.5em; } + +.package-builder .pb-family-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.75em 2em; + margin-bottom: 1em; +} +.package-builder .pb-family-intro { flex: 1 1 22em; min-width: 0; } +.package-builder .pb-family-title { margin: 0; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.25em; line-height: 1.2; color: var(--pb-ink); } +.package-builder .pb-family-tagline { margin: 0.35em 0 0; color: var(--pb-muted); font-size: 0.9375em; line-height: 1.5; } +.package-builder .pb-family-terms { margin: 0.2em 0 0; font-size: 0.875em; color: var(--pb-muted); line-height: 1.5; } + +.package-builder .pb-include { + flex: none; + display: inline-flex; + align-items: center; + gap: 0.5em; + margin: 0; + padding: 0.45em 0.9em 0.45em 0.7em; + border: 1px solid var(--pb-line-strong); + border-radius: 999px; + background-color: #fff; + color: var(--pb-text); + font-family: SourceSansProBold, Arial, sans-serif; + font-weight: normal; + font-size: 0.9375em; + cursor: pointer; + transition: background-color 0.18s var(--pb-ease), border-color 0.18s var(--pb-ease), color 0.18s var(--pb-ease); +} +.package-builder .pb-include input { width: 1.05em; height: 1.05em; margin: 0; accent-color: #fff; } +.package-builder .pb-include:hover { border-color: var(--pb-blue); color: var(--pb-blue-dark); } +.package-builder .pb-include:has(input:focus-visible) { outline: 2px solid var(--pb-blue-dark); outline-offset: 2px; } +.package-builder .pb-include:has(input:checked) { background-color: var(--pb-blue); border-color: var(--pb-blue); color: #fff; } +.package-builder .pb-include input:not(:checked) { accent-color: var(--pb-blue); } + +.package-builder .pb-family > .errorlist { margin: 0 0 0.75em; } + +/* ---------- Compact tier choices and on-demand comparisons ---------- */ + +.package-builder .pb-tier-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 11em), 1fr)); gap: 0.65em; } +.package-builder .pb-tier { + position: relative; + min-width: 0; + padding: 0.85em; + background-color: #fff; + border: 1px solid var(--pb-line-strong); + border-radius: 4px; + cursor: pointer; + transition: background-color 0.18s var(--pb-ease), border-color 0.18s var(--pb-ease); +} +.package-builder .pb-tier:hover { border-color: var(--pb-blue); } +.package-builder .pb-tier:has(.pb-tier-input:focus-visible) { outline: 2px solid var(--pb-blue-dark); outline-offset: 2px; } +.package-builder .pb-family:has(.pb-include-input:checked) .pb-tier:has(.pb-tier-input:checked) { border-color: var(--pb-blue); background-color: var(--pb-tint); } +.package-builder .pb-tier-head { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0.5em; } +.package-builder .pb-tier-input { width: 1.1em; height: 1.1em; margin: 0; accent-color: var(--pb-blue); cursor: pointer; } +.package-builder .pb-tier-name { margin: 0; color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; font-weight: normal; font-size: 1em; line-height: 1.25; cursor: pointer; overflow-wrap: break-word; } +.package-builder .pb-tier-name::after { content: ""; position: absolute; inset: 0; border-radius: inherit; } +.package-builder .pb-fee { grid-column: 1 / -1; margin: 0.25em 0 0; line-height: 1.3; } +.package-builder .pb-fee-amount { color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.375em; font-variant-numeric: tabular-nums; } +.package-builder .pb-fee-unit { color: var(--pb-muted); font-size: 0.8125em; } +.package-builder .pb-tier-facts { margin: 0.8em 0 0; padding: 0; border: 0; font-size: 0.8125em; line-height: 1.45; } +.package-builder .pb-tier-facts dt { margin: 0.5em 0 0; padding: 0; border: 0; color: var(--pb-muted); font-family: SourceSansProRegular, Arial, sans-serif; } +.package-builder .pb-tier-facts dd { margin: 0; padding: 0; border: 0; color: var(--pb-text); } +@container pbfamily (max-width: 35em) { + .package-builder .pb-tier-options { grid-template-columns: minmax(0, 1fr); } + .package-builder .pb-tier-head { grid-template-columns: auto minmax(0, 1fr) auto; } + .package-builder .pb-fee { grid-column: 3; margin: 0; } + .package-builder .pb-fee-amount { font-size: 1.125em; } + .package-builder .pb-tier-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75em; margin-top: 0.65em; } + .package-builder .pb-tier-facts dt { margin-top: 0; } +} +.package-builder .pb-staff-details { margin-top: 1.5em; border-bottom: 1px solid var(--pb-line); } +.package-builder .pb-staff-details > summary { padding: 0.7em 0; color: var(--pb-blue-dark); font-size: 0.9375em; font-family: SourceSansProBold, Arial, sans-serif; cursor: pointer; } +.package-builder .pb-staff-details[open] { padding-bottom: 1em; } +.package-builder .pb-comparison-details > summary { padding: 0.75em 0; margin-top: 0.25em; color: var(--pb-blue-dark); font-size: 0.9375em; cursor: pointer; } +.package-builder .pb-comparison-details .pb-secondary { width: 100%; min-height: 44px; justify-content: center; white-space: nowrap; } +.package-builder .pb-extra-family + .pb-extra-family { margin-top: 1.5em; } +.package-builder .pb-extra-family > h3 { margin: 0 0 0.75em; font-size: 1.125em; font-family: SourceSansProBold, Arial, sans-serif; } +.package-builder #pb-extras-empty { padding: 1.5em 0; color: var(--pb-muted); } +.package-builder .pb-comparison-scroll { max-width: 100%; overflow-x: auto; } +.package-builder .pb-comparison { width: 100%; min-width: 54em; table-layout: fixed; margin: 0.75em 0; border-collapse: collapse; font-size: 0.875em; line-height: 1.4; } +.package-builder .pb-comparison-label { width: 38%; } +.package-builder .pb-comparison :is(th, td) { padding: 0.65em; border-bottom: 1px solid var(--pb-line); text-align: left; vertical-align: top; } +.package-builder .pb-comparison thead th { background: var(--pb-wash); color: var(--pb-ink); } +.package-builder .pb-comparison th[scope="row"] { width: 38%; font-family: SourceSansProRegular, Arial, sans-serif; font-weight: normal; } +.package-builder .pb-comparison td { min-width: 6em; } +.package-builder .pb-comparison .pb-yes { color: var(--pb-blue-dark); } +.package-builder .pb-comparison .pb-no { color: var(--pb-muted); } + +.package-builder .pb-mark { + flex: none; + display: inline-block; + width: 1.05em; + height: 1.05em; + margin-right: 0.3em; + vertical-align: -0.15em; + background-color: currentColor; + -webkit-mask: var(--pb-icon) center / contain no-repeat; + mask: var(--pb-icon) center / contain no-repeat; +} +.package-builder .pb-yes .pb-mark { + color: var(--pb-blue); + --pb-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.2 8.6l3 3L12.8 5' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +.package-builder .pb-no .pb-mark, +.package-builder .pb-none .pb-mark { + color: var(--pb-faint); + --pb-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 8h7' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); +} +.package-builder .pb-also-list { margin: 0; padding: 0; list-style: none; } +.package-builder .pb-also-list li { margin: 0; color: var(--pb-text); line-height: 1.4; } +.package-builder .pb-also-list li + li { margin-top: 0.3em; } +.package-builder .pb-glossary { margin: 1em 0 0; } +.package-builder .pb-glossary-title { margin: 0 0 0.35em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 0.9375em; color: var(--pb-ink); } +.package-builder .pb-glossary dl { margin: 0; font-size: 0.9em; line-height: 1.5; } +.package-builder .pb-glossary dt { margin-top: 0.4em; border: 0; color: var(--pb-ink); } +.package-builder .pb-glossary dd { margin: 0; padding: 0; border: 0; color: var(--pb-muted); } +.package-builder .pb-glossary-tiers { font-family: SourceSansProRegular, Arial, sans-serif; color: var(--pb-blue-dark); font-size: 0.9em; margin-left: 0.3em; } + + +/* ---------- Extras ---------- */ + +.package-builder .pb-extras { margin-top: 0.75em; } +.package-builder .pb-extras-title { margin: 0 0 0.2em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.125em; color: var(--pb-ink); } +.package-builder .pb-extras-help { margin: 0 0 0.85em; max-width: 46em; color: var(--pb-muted); font-size: 0.9375em; line-height: 1.55; } +.package-builder .pb-extra-list { margin: 0; padding: 0; list-style: none; } +.package-builder .pb-extra { + min-width: 0; + margin: 0; + padding: 0.85em 0; + border-bottom: 1px solid var(--pb-line); + line-height: 1.5; +} +.package-builder .pb-extra:last-child { border-bottom: 0; } +.package-builder .pb-extra.is-included { color: var(--pb-muted); } +.package-builder .pb-extra.has-error { border-bottom-color: var(--pb-red); } + +.package-builder .pb-extra-main { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 0.6em; } +.package-builder .pb-extra-toggle { grid-row: 1 / span 4; padding-top: 0.2em; } +.package-builder .pb-extra-toggle input { width: 1.1em; height: 1.1em; margin: 0; accent-color: var(--pb-blue); cursor: pointer; } +.package-builder .pb-extra-toggle input:disabled { cursor: default; } +.package-builder .pb-extra-label { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.15em 1em; margin: 0; color: var(--pb-ink); font-weight: normal; cursor: pointer; line-height: 1.4; } +.package-builder .pb-extra.is-included .pb-extra-label { cursor: default; } +.package-builder .pb-extra-name { display: block; font-family: SourceSansProBold, Arial, sans-serif; } +.package-builder .pb-extra-price { color: var(--pb-blue-dark); font-size: 0.875em; } +.package-builder .pb-extra-desc { margin: 0.3em 0 0; color: var(--pb-muted); font-size: 0.875em; line-height: 1.5; } +.package-builder .pb-extra-tiers { margin: 0.35em 0 0; color: var(--pb-green); font-size: 0.8125em; line-height: 1.45; } +.package-builder .pb-join + .pb-join::before { content: ", "; } +.package-builder .pb-extra-now { + justify-self: start; + margin: 0.45em 0 0; + padding: 0.1em 0.55em; + background-color: var(--pb-green-wash); + border: 1px solid #b9d39a; + border-radius: 6px; + color: var(--pb-green); + font-size: 0.8125em; + line-height: 1.5; +} +.package-builder .pb-extra.is-included .pb-extra-tiers { display: none; } +.package-builder .pb-extra > .errorlist { margin: 0.5em 0 0; } + +.package-builder .pb-params { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 10em), 1fr)); + gap: 0 0.9em; + margin: 0.75em 0 0; + padding-top: 0.7em; + border-top: 1px solid var(--pb-line); +} +.package-builder.is-enhanced .pb-params:not([hidden]) { animation: pb-reveal 0.22s var(--pb-ease); } +.package-builder:not(.is-enhanced) .pb-extra:not(:has(.pb-extra-toggle input:checked)) .pb-params { display: none; } +.package-builder .pb-param { margin: 0 0 0.35em; } +.package-builder .pb-param label { margin: 0 0 0.2em; color: var(--pb-text); font-family: SourceSansProBold, Arial, sans-serif; font-weight: normal; font-size: 0.875em; } +.package-builder .pb-param input, +.package-builder .pb-param select, +.package-builder .pb-param textarea { width: 100%; margin: 0; padding: 0.45em 0.6em; font-size: 0.9375em; } +.package-builder .pb-param select { padding: 0.4em; border: 1px solid var(--pb-line-strong); border-radius: 6px; background-color: #fff; } +.package-builder .pb-param textarea { min-height: 4.5em; resize: vertical; } +.package-builder .pb-param .help { margin: 0.2em 0 0; font-size: 0.8125em; } +.package-builder .pb-param.has-error :is(input, select, textarea) { border-color: var(--pb-red); } + +@keyframes pb-reveal { + from { opacity: 0; transform: translateY(-0.25em); } + to { opacity: 1; transform: none; } +} + +/* ---------- Staff-only fields ---------- */ + +.package-builder .pb-staff { margin-top: 1em; padding: 0; } +.package-builder .pb-staff > legend { float: left; width: 100%; margin: 0 0 0.5em; font-family: SourceSansProBold, Arial, sans-serif; color: var(--pb-ink); } +.package-builder .pb-staff > legend + * { clear: both; } +.package-builder .pb-staff-tag { margin-left: 0.4em; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.85em; color: var(--pb-muted); } +.package-builder .pb-staff textarea, +.package-builder .pb-staff input { background-color: #fff; } + +/* ---------- Term and discount ---------- */ + +.package-builder .pb-term-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 16em), 1fr)); gap: 0.6em; } +.package-builder .pb-term-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: baseline; + gap: 0.15em 0.55em; + margin: 0; + padding: 0.75em 0.9em; + background-color: #fff; + border: 1px solid var(--pb-line-strong); + border-radius: 6px; + color: var(--pb-text); + font-weight: normal; + cursor: pointer; + transition: border-color 0.18s var(--pb-ease), background-color 0.18s var(--pb-ease); +} +.package-builder .pb-term-option:hover { border-color: #999; } +.package-builder .pb-term-option:has(input:checked) { background-color: var(--pb-tint); border-color: var(--pb-blue); box-shadow: inset 0 0 0 1px var(--pb-blue); } +.package-builder .pb-term-option:has(input:focus-visible) { outline: 2px solid var(--pb-blue-dark); outline-offset: 2px; } +.package-builder .pb-term-option input { margin: 0; width: 1.05em; height: 1.05em; accent-color: var(--pb-blue); align-self: center; } +.package-builder .pb-term-name { color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; } +.package-builder .pb-term-detail { grid-column: 2; color: var(--pb-muted); font-size: 0.875em; line-height: 1.45; } +.package-builder .pb-attest { margin-top: 0.85em; padding: 0.75em 0.9em; background-color: var(--pb-tint); border: 1px solid var(--pb-tint-line); border-radius: 6px; } +.package-builder .pb-attest.has-error { background-color: #f8eef0; border-color: var(--pb-red); } +.package-builder:not(.is-enhanced) .pb-term:not(:has(input[data-attestation="true"]:checked)) .pb-attest:not(.has-error) { display: none; } +.package-builder .pb-attest label.choice input { width: auto; } + +/* ---------- Your package ---------- */ + +.package-builder .pb-summary { min-width: 0; scroll-margin-top: 1em; } +.package-builder .pb-summary-panel { + padding: 1em 1.15em 1.1em; + background-color: #fff; + border: 1px solid var(--pb-line); + border-top: 5px solid var(--pb-yellow); +} +.package-builder .pb-summary-title { margin: 0 0 0.35em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.3125em; color: var(--pb-ink); } +.package-builder .pb-summary-title:focus { outline: none; } +.package-builder .pb-summary-title:focus-visible { outline: 2px solid var(--pb-blue-dark); outline-offset: 2px; } +.package-builder .pb-summary-empty, +.package-builder .pb-empty p { margin: 0 0 0.5em; color: var(--pb-muted); font-size: 0.9375em; line-height: 1.55; } +.package-builder .pb-empty { padding: 0.5em 0 0.25em; } +.package-builder .pb-empty .pb-empty-title { margin-bottom: 0.15em; color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; } + +.package-builder .pb-sum-family { padding: 0.75em 0 0.6em; border-top: 1px solid var(--pb-line); animation: pb-reveal 0.2s var(--pb-ease); } +.package-builder .pb-summary-body > .pb-sum-family:first-child { border-top: 0; padding-top: 0.25em; } +.package-builder .pb-sum-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.75em; } +.package-builder .pb-sum-name { margin: 0; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.0625em; color: var(--pb-ink); } +.package-builder .pb-sum-actions { display: inline-flex; gap: 0.75em; font-size: 0.875em; white-space: nowrap; } + +.package-builder .pb-linkbutton, +.text .package-builder .pb-linkbutton { + display: inline; + margin: 0; + padding: 0; + background: none; + border: 0; + box-shadow: none; + color: var(--pb-red-ink) !important; + font: inherit; + text-shadow: none; + cursor: pointer; +} +.package-builder .pb-linkbutton:hover, +.package-builder .pb-linkbutton:focus-visible { text-decoration: underline; text-underline-offset: 0.15em; } + +.package-builder .pb-lines { margin: 0; padding: 0; list-style: none; } +.package-builder .pb-line { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0 0.75em; + margin: 0; + padding: 0.2em 0; + font-size: 0.9375em; + line-height: 1.45; +} +.package-builder .pb-line-name { color: var(--pb-text); overflow-wrap: break-word; } +.package-builder .pb-line-detail { display: block; color: var(--pb-muted); font-size: 0.8125em; } +.package-builder .pb-line-tier .pb-line-name { color: var(--pb-ink); } +.package-builder .pb-line-included .pb-amount { color: var(--pb-green); } +.package-builder .pb-line-discount .pb-amount { color: var(--pb-green); } +.package-builder .pb-amount { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--pb-ink); transition: opacity 0.2s var(--pb-ease); } +.package-builder .pb-amount-unit { color: var(--pb-muted); font-size: 0.8125em; } +.package-builder .pb-line:has(.is-unpriced) { grid-template-columns: minmax(0, 1fr); } +.package-builder .pb-amount.is-unpriced { color: var(--pb-amber-ink); white-space: normal; text-align: left; font-size: 0.875em; } +.package-builder .pb-skeleton { + display: inline-block; + width: 3.5em; + height: 0.8em; + vertical-align: -0.05em; + border-radius: 3px; + background: linear-gradient(90deg, var(--pb-line) 25%, var(--pb-wash) 50%, var(--pb-line) 75%) 0 0 / 200% 100%; + animation: pb-shimmer 1.1s ease-in-out infinite; +} +@keyframes pb-shimmer { to { background-position: -200% 0; } } + +.package-builder .pb-totals { margin-top: 0.6em; padding-top: 0.65em; border-top: 2px solid var(--pb-ink); } +.package-builder .pb-total-rows { margin: 0; } +.package-builder .pb-total { display: flex; justify-content: space-between; align-items: baseline; gap: 0.75em; padding: 0.1em 0; } +.package-builder .pb-total dt, +.package-builder .pb-total dd { margin: 0; padding: 0; border: 0; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.9375em; color: var(--pb-text); } +.package-builder .pb-total dd { font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--pb-ink); transition: opacity 0.2s var(--pb-ease); } +.package-builder .pb-total-grand { margin-top: 0.3em; align-items: flex-end; } +.package-builder .pb-total-grand dt { color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; line-height: 1.3; } +.package-builder .pb-total-grand dd { color: var(--pb-blue); font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.75em; line-height: 1.1; letter-spacing: -0.01em; } +.package-builder .pb-total-note { margin: 0.5em 0 0; color: var(--pb-amber-ink); font-size: 0.8125em; line-height: 1.45; } +.package-builder .pb-summary.is-pending :is(.pb-amount, .pb-total dd) { opacity: 0.45; } + +.package-builder .pb-summary-status { + margin-top: 0.75em; + padding: 0.55em 0.75em; + background-color: var(--pb-amber-wash); + border: 1px solid var(--pb-yellow); + border-radius: 6px; + color: var(--pb-amber-ink); + font-size: 0.875em; + line-height: 1.5; +} +.package-builder .pb-summary-status.is-error { background-color: #f8eef0; border-color: var(--pb-red); color: var(--pb-red-ink); } +.package-builder .pb-summary-status p { margin: 0; } +.package-builder .pb-summary-status .pb-status-title { font-family: SourceSansProBold, Arial, sans-serif; } +.package-builder .pb-summary-status ul { margin: 0.25em 0 0; padding-left: 1.1em; list-style: disc; } +.package-builder .pb-summary-status li { margin: 0.1em 0; } + +.package-builder .pb-summary-foot { margin: 0.85em 0 0; color: var(--pb-muted); font-size: 0.8125em; line-height: 1.5; } + +/* ---------- Organization ---------- */ + +.package-builder .pb-org { container: pborganization / inline-size; } +.package-builder .pb-org > .pb-help { margin-bottom: 1.5em; } +.package-builder .pb-group { padding-top: 1.25em; border-top: 1px solid var(--pb-line); } +.package-builder .pb-group + .pb-group { margin-top: 1.75em; } +.package-builder .pb-group > legend { float: left; width: 100%; margin: 0 0 1em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.125em; color: var(--pb-ink); } +.package-builder .pb-group > legend + * { clear: both; } +.package-builder .pb-group .pb-help { margin-bottom: 1em; } +.package-builder .pb-fields { display: grid; gap: 1.25em; } +.package-builder .pb-org .field-row { grid-template-columns: minmax(0, 1fr); gap: 1.25em; } +.package-builder .pb-org .field { min-width: 0; margin: 0; } +.package-builder .pb-org .field label { display: block; margin: 0 0 0.4em; line-height: 1.4; } +.package-builder .pb-org .req { color: var(--pb-muted); white-space: nowrap; } +.package-builder .pb-org .field :is(input, textarea) { + box-sizing: border-box; + width: 100%; + min-height: 44px; + margin: 0; + padding: 0.6em 0.75em; + border: 1px solid var(--pb-line-strong); + border-radius: 4px; + background: #fff; + color: var(--pb-ink); + font-size: 1em; + line-height: 1.5; + box-shadow: none; +} +.package-builder .pb-org .field textarea { resize: vertical; } +.package-builder .pb-org .field .help { margin: 0.4em 0 0; font-size: 0.875em; } +.package-builder .pb-org .field.has-error :is(input, textarea) { border-color: var(--pb-red); } +.package-builder .pb-contact { margin: 0 0 1em; } +.package-builder .pb-contact > legend { margin-bottom: 0.5em; color: var(--pb-muted); font-size: 0.875em; } +@container pborganization (min-width: 40em) { + .package-builder .pb-org .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +.package-builder .field label { font-weight: normal; font-family: SourceSansProBold, Arial, sans-serif; font-size: 0.9375em; } +.package-builder .field input, +.package-builder .field textarea { margin-bottom: 0.25em; } + +.package-builder .pb-secondary, +.text .package-builder .pb-secondary { + display: inline-flex; + align-items: center; + gap: 0.4em; + margin: 0.35em 0 0; + padding: 0.4em 0.9em; + background: #fff; + border: 1px solid var(--pb-line-strong); + border-radius: 6px; + box-shadow: none; + color: var(--pb-blue-dark) !important; + font: inherit; + font-size: 0.9375em; + text-shadow: none; + cursor: pointer; + transition: border-color 0.18s var(--pb-ease), background-color 0.18s var(--pb-ease); +} +.package-builder .pb-secondary:hover { border-color: var(--pb-blue); background-color: var(--pb-tint); } + +/* ---------- Review ---------- */ + +.package-builder .pb-agreements { margin: 0 0 0.75em; padding: 0; list-style: none; } +.package-builder .pb-agreements li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.2em 0.75em; + margin: 0; + padding: 0.6em 0; + border-bottom: 1px solid var(--pb-line); +} +.package-builder .pb-agreements li:first-child { border-top: 1px solid var(--pb-line); } +.package-builder .pb-agreement-title { color: var(--pb-ink); font-family: SourceSansProBold, Arial, sans-serif; } +.package-builder .pb-agreement-for { color: var(--pb-muted); font-size: 0.9em; } +.package-builder .pb-agreements a { margin-left: auto; font-size: 0.9375em; } +.package-builder .pb-agreements-empty { color: var(--pb-muted); font-style: italic; } +.package-builder .pb-actions { margin-top: 1.25em; } +.package-builder .pb-fineprint { margin: 0.6em 0 0; color: var(--pb-muted); font-size: 0.875em; } + +/* Persistent navigation: price and actions share one footer on small screens. */ +.package-builder .pb-wizard-actions { + position: sticky; + bottom: 0; + z-index: 10; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75em; + margin-top: 2em; + padding: 0.85em 0 calc(0.85em + env(safe-area-inset-bottom, 0px)); + background: #fff; + border-top: 1px solid var(--pb-line-strong); +} +.package-builder .pb-wizard-actions > button { min-height: 44px; margin: 0; } +.package-builder .pb-wizard-actions > [data-step-next] { margin-left: auto; } +.package-builder .pb-bar { display: flex; align-items: center; justify-content: space-between; gap: 1em; min-width: 0; } +.package-builder .pb-bar-text { margin: 0; line-height: 1.3; min-width: 0; } +.package-builder .pb-bar-label { color: var(--pb-muted); font-size: 0.8125em; margin-right: 0.4em; } +.package-builder .pb-bar-total { color: var(--pb-blue); font-family: SourceSansProBold, Arial, sans-serif; font-variant-numeric: tabular-nums; } +.package-builder .pb-bar-link { flex: none; font-size: 0.875em; font-family: SourceSansProBold, Arial, sans-serif; } + +@media (max-width: 59.99em) { + .package-builder .pb-wizard-actions .pb-bar { order: 3; flex-basis: 100%; } +} +@media (min-width: 60em) { + .package-builder .pb-bar { display: none !important; } +} + +@media (max-width: 30em) { + .pb-page { padding-inline: 1em; } + .package-builder .pb-family-title { font-size: 1.3125em; } + .package-builder .pb-include { width: 100%; justify-content: flex-start; border-radius: 6px; } + .package-builder .pb-summary-panel { padding: 0.85em 0.9em 1em; } + .package-builder .pb-agreements a { margin-left: 0; } +} + +/* ---------- Motion, contrast modes ---------- */ + +@media (prefers-reduced-motion: reduce) { + .package-builder *, + .package-builder *::before, + .package-builder *::after { animation: none !important; transition: none !important; } +} + +@media (forced-colors: active) { + .package-builder .pb-mark { forced-color-adjust: none; background-color: CanvasText; } + .package-builder .pb-family:has(.pb-include-input:checked) .pb-tier:has(.pb-tier-input:checked), + .package-builder .pb-extra.is-selected, + .package-builder .pb-term-option:has(input:checked) { outline: 3px solid Highlight; } + .package-builder .pb-skeleton { background: GrayText; } +} diff --git a/apps/agreements/static/agreements/package-builder.js b/apps/agreements/static/agreements/package-builder.js new file mode 100644 index 000000000..74ea2ea70 --- /dev/null +++ b/apps/agreements/static/agreements/package-builder.js @@ -0,0 +1,803 @@ +// Package builder: focused screens retain the native form and its selections, and the summary is +// rebuilt from the form itself, then priced by the server's quote endpoint. +(() => { + "use strict"; + + const form = document.getElementById("order-form"); + if (!form || !form.classList.contains("package-builder")) return; + form.classList.add("is-enhanced"); + + const $$ = (selector, root = form) => [...root.querySelectorAll(selector)]; + const conditions = JSON.parse(document.getElementById("order-parameter-conditions")?.textContent || "{}"); + + const summary = document.getElementById("package-summary"); + const summaryTitle = document.getElementById("summary-title"); + const body = document.getElementById("pb-summary-body"); + const totals = document.getElementById("pb-totals"); + const status = document.getElementById("pb-quote-status"); + const announcer = document.getElementById("pb-announce"); + const attest = document.getElementById("discount-attest"); + const bar = document.getElementById("pb-bar"); + const barTotal = document.getElementById("pb-bar-total"); + const agreementsEmpty = document.getElementById("pb-agreements-empty"); + + const families = $$("[data-family]").map((el) => { + const extraGroup = document.getElementById(`extras-${el.dataset.family}`); + return { + el, + slug: el.dataset.family, + name: el.dataset.familyName, + title: el.dataset.familyTitle, + include: el.querySelector(".pb-include-input"), + radios: $$(".pb-tier-input", el), + extraGroup, + extras: extraGroup ? $$(".pb-extra", extraGroup) : [], + }; + }); + + const extraBox = (row) => row.querySelector(".pb-extra-toggle input[type='checkbox']"); + const extraName = (row) => row.querySelector(".pb-extra-name").textContent.trim(); + const tierOf = (family) => family.radios.find((radio) => radio.checked) || null; + const tierColumn = (radio) => radio.closest(".pb-tier"); + const included = () => families.filter((family) => family.include.checked && tierOf(family)); + const familyOf = (node) => { + const group = node.closest("[data-family], [data-extras-for]"); + return group && families.find((family) => family.slug === (group.dataset.family || group.dataset.extrasFor)); + }; + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = text; + return node; + } + + function announce(message) { + // Clearing first makes repeated identical messages audible. + announcer.textContent = ""; + window.setTimeout(() => { + announcer.textContent = message; + }, 60); + } + + // ---------- Selection state ---------- + + // A family is in the package exactly when its checkbox is checked and one tier is chosen. Removing + // a family unchecks its tier but remembers it, so adding it back restores every earlier choice. + function ensureTier(family) { + if (tierOf(family)) return; + const key = family.el.dataset.lastTier || family.el.dataset.defaultTier; + const radio = family.radios.find((candidate) => candidate.value === key) || family.radios[0]; + if (radio) radio.checked = true; + } + + function setIncluded(family, on) { + family.include.checked = on; + if (on) { + ensureTier(family); + return; + } + const current = tierOf(family); + if (current) { + family.el.dataset.lastTier = current.value; + current.checked = false; + } + } + + function paramKind(field) { + if (field.tagName === "TEXTAREA") return "lines"; + if (field.tagName === "SELECT") return "choice"; + return field.type === "number" ? "count" : "text"; + } + + function syncParams(family, row, active) { + const fields = $$("[data-param-key]", row); + if (!fields.length) return; + const byKey = Object.fromEntries(fields.map((field) => [field.dataset.paramKey, field])); + const rules = conditions[family.slug]?.[row.dataset.addon] || {}; + const memo = {}; + // Mirror the server: a condition holds only when the parameter it depends on is itself submitted. + const applies = (key, seen = new Set()) => { + if (key in memo) return memo[key]; + if (seen.has(key)) return false; + seen.add(key); + memo[key] = Object.entries(rules[key] || {}).every( + ([dependency, value]) => + byKey[dependency] && applies(dependency, seen) && byKey[dependency].value === String(value), + ); + return memo[key]; + }; + fields.forEach((field) => { + const applicable = applies(field.dataset.paramKey); + field.closest(".pb-param").hidden = !applicable; + field.disabled = !active || !applicable; + }); + const params = row.querySelector(".pb-params"); + if (params) params.hidden = !active; + } + + function syncFamily(family) { + const on = family.include.checked; + const tier = on ? tierOf(family) : null; + family.el.classList.toggle("is-included", Boolean(on && tier)); + if (family.extraGroup) family.extraGroup.hidden = !tier; + family.extras.forEach((row) => { + const box = extraBox(row); + const includedIn = (row.dataset.includedIn || "").split(" ").filter(Boolean); + const withTier = Boolean(tier) && includedIn.includes(tier.value); + box.disabled = !tier || withTier; + row.classList.toggle("is-included", withTier); + row.classList.toggle("is-selected", on && box.checked && !withTier); + row.classList.toggle("is-kept", !on && box.checked); + const now = row.querySelector(".pb-extra-now"); + if (now) { + now.hidden = !withTier; + if (withTier) now.querySelector(".pb-extra-now-tier").textContent = tierColumn(tier).dataset.tierName; + } + syncParams(family, row, Boolean(tier) && box.checked && !withTier); + }); + } + + function syncDiscount() { + if (!attest) return; + const needed = form.querySelector("input[name='discount']:checked")?.dataset.attestation === "true"; + attest.hidden = !needed; + attest.querySelectorAll("input").forEach((input) => { + input.disabled = !needed; + }); + } + + function syncAgreements() { + const chosen = new Set(included().map((family) => family.slug)); + $$("[data-agreement-for]").forEach((item) => { + item.hidden = !chosen.has(item.dataset.agreementFor); + }); + if (agreementsEmpty) agreementsEmpty.hidden = chosen.size > 0; + } + + function syncAll() { + families.forEach(syncFamily); + syncDiscount(); + syncAgreements(); + const extrasEmpty = document.getElementById("pb-extras-empty"); + if (extrasEmpty) extrasEmpty.hidden = included().some((family) => family.extras.length); + } + + // ---------- Contacts ---------- + + function setupContacts() { + const rows = $$(".pb-contact"); + const button = document.getElementById("add-contact"); + if (!button || !rows.length) return; + const used = (row) => $$("input", row).some((input) => input.value.trim()) || row.querySelector(".errorlist"); + let visible = Math.max(1, ...rows.map((row, index) => (used(row) ? index + 1 : 0))); + const render = () => { + rows.forEach((row, index) => { + row.hidden = index >= visible; + }); + button.hidden = visible >= rows.length; + }; + button.addEventListener("click", () => { + visible = Math.min(rows.length, visible + 1); + render(); + rows[visible - 1].querySelector("input")?.focus(); + }); + render(); + return (target) => { + const index = rows.findIndex((row) => row.contains(target)); + if (index < visible) return; + visible = index + 1; + render(); + }; + } + + // ---------- Steps and in-page navigation ---------- + + function setupSteps(revealContact) { + const steps = ["services", "extras", "term", "organization", "contacts"]; + const names = ["Services", "Extras", "Term", "Organization", "Contacts & billing"]; + const panels = $$("[data-step-panel]"); + const navigation = form.querySelector(".pb-steps"); + const progress = document.getElementById("pb-progress-text"); + const actions = document.getElementById("pb-wizard-actions"); + const back = actions.querySelector("[data-step-back]"); + const forward = actions.querySelector("[data-step-next]"); + const errors = document.getElementById("pb-errors"); + const panelFor = (step) => panels.find((panel) => panel.dataset.stepPanel === step); + const currentIndex = () => steps.indexOf(form.dataset.step); + + function focusTarget(target) { + const heading = target.matches("section, aside, fieldset") + ? target.querySelector("h2, h3, legend") + : null; + const focus = heading || target; + if (!focus.matches("a[href], button, input, select, textarea, summary, [tabindex]")) { + focus.tabIndex = -1; + } + focus.focus({ preventScroll: true }); + requestAnimationFrame(() => { + (target.matches("[data-step-panel]") ? navigation : focus).scrollIntoView({ block: "start", behavior: "instant" }); + }); + } + + function showStep(step) { + const panel = panelFor(step); + if (!panel) return; + const index = steps.indexOf(step); + form.dataset.step = step; + panels.forEach((candidate) => { + candidate.hidden = candidate !== panel; + }); + $$("[data-step-go]", navigation).forEach((button) => { + if (button.dataset.stepGo === step) button.setAttribute("aria-current", "step"); + else button.removeAttribute("aria-current"); + }); + const current = navigation.querySelector('[aria-current="step"]'); + if (current.offsetLeft < navigation.scrollLeft || current.offsetLeft + current.offsetWidth > navigation.scrollLeft + navigation.clientWidth) { + navigation.scrollLeft = current.offsetLeft; + } + progress.textContent = `Step ${index + 1} of ${steps.length} — ${names[index]}`; + back.hidden = index === 0; + back.textContent = `Back to ${names[index - 1]?.toLowerCase() || "services"}`; + forward.type = step === "contacts" ? "submit" : "button"; + forward.textContent = step === "contacts" ? "Continue to review" : `Continue to ${names[index + 1].toLowerCase()}`; + } + + function openDisclosures(target) { + let disclosure = target.closest("details"); + while (disclosure) { + disclosure.open = true; + disclosure = disclosure.parentElement?.closest("details"); + } + } + + function revealTarget(target, focus = true) { + // An old extras link must not silently add a service that has since been removed. + const extraGroup = target.closest("[data-extras-for]"); + const family = extraGroup && familyOf(extraGroup); + if (family && (!family.include.checked || !tierOf(family))) target = family.el; + const panel = target.closest("[data-step-panel]"); + if (panel) showStep(panel.dataset.stepPanel); + openDisclosures(target); + revealContact?.(target); + if (focus) focusTarget(target); + } + + function hashTarget(hash) { + if (!hash || hash === "#") return null; + try { + const target = document.getElementById(decodeURIComponent(hash.slice(1))); + return target && form.contains(target) ? target : null; + } catch { + return null; + } + } + + function navigate(target) { + const hash = `#${encodeURIComponent(target.id)}`; + if (window.location.hash !== hash) window.history.pushState(null, "", hash); + revealTarget(target); + } + + function packageReady() { + const unresolved = families.find((family) => family.include.checked && !tierOf(family)); + if (!unresolved && included().length) return true; + const message = unresolved + ? `Choose a tier for ${unresolved.name} before continuing.` + : "Choose at least one service tier before continuing."; + state.failure = message; + render(); + const family = unresolved || families[0]; + const target = family?.radios[0] || family?.include || panelFor("services"); + revealTarget(target); + announce(message); + return false; + } + + function validFields(root) { + const invalid = $$("input, select, textarea", root) + .find((field) => field.willValidate && !field.validity.valid); + if (!invalid) return true; + revealTarget(invalid); + invalid.reportValidity(); + return false; + } + + function goToStep(step) { + const index = steps.indexOf(step); + if (index < 0) return; + if (index > 0 && !packageReady()) return; + if (index > currentIndex() && !validFields(panelFor(form.dataset.step))) return; + navigate(panelFor(step)); + } + + function advance() { + const index = currentIndex(); + if (index < steps.length - 1) goToStep(steps[index + 1]); + } + + navigation.hidden = false; + progress.hidden = false; + actions.hidden = false; + // Validate one revealed field at a time, rather than letting the browser focus hidden screens. + form.noValidate = true; + $$("[data-step-go]", navigation).forEach((button) => { + button.addEventListener("click", () => goToStep(button.dataset.stepGo)); + }); + back.addEventListener("click", () => goToStep(steps[currentIndex() - 1])); + forward.addEventListener("click", (event) => { + if (forward.type !== "submit") { + event.preventDefault(); + advance(); + } + }); + $$("[data-choose-family][data-choose-tier]").forEach((button) => { + button.hidden = false; + button.addEventListener("click", () => { + const family = families.find((candidate) => candidate.slug === button.dataset.chooseFamily); + const radio = family?.radios.find((candidate) => candidate.value === button.dataset.chooseTier); + if (!radio) return; + radio.checked = true; + family.include.checked = true; + syncAll(); + refresh(0); + announce(`${family.name} added at the ${tierColumn(radio).dataset.tierName} tier.`); + }); + }); + + document.addEventListener("click", (event) => { + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const link = event.target.closest("a[href]"); + if (!link || link.hasAttribute("download") || (link.target && link.target !== "_self")) return; + const url = new URL(link.href, window.location.href); + if (url.origin !== window.location.origin || url.pathname !== window.location.pathname || url.search !== window.location.search) return; + const target = hashTarget(url.hash); + if (!target) return; + event.preventDefault(); + navigate(target); + }); + + window.addEventListener("hashchange", () => { + revealTarget(hashTarget(window.location.hash) || panelFor("services")); + }); + + form.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.isComposing || event.defaultPrevented || form.dataset.step === "contacts") return; + if (!event.target.matches("input:not([type='button']):not([type='submit']):not([type='reset'])")) return; + event.preventDefault(); + advance(); + }); + form.addEventListener("submit", (event) => { + if (form.dataset.step !== "contacts") { + event.preventDefault(); + advance(); + } else if (!packageReady() || !validFields(form)) { + event.preventDefault(); + } + }); + + form.addEventListener("invalid", (event) => { + revealTarget(event.target, false); + }, true); + + $$(".errorlist, [aria-invalid='true']").forEach(openDisclosures); + const firstError = errors && $$("a[href^='#']", errors) + .map((link) => hashTarget(link.hash)) + .find(Boolean); + const initialTarget = firstError || hashTarget(window.location.hash); + showStep("services"); + if (initialTarget) revealTarget(initialTarget, !errors); + errors?.focus(); + } + + // ---------- Money ---------- + + // Exact cent arithmetic on the server's decimal strings; only used to split recurring from one-time. + function cents(value) { + const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(String(value ?? "").trim()); + if (!match) return 0n; + const fraction = (match[3] || "").padEnd(3, "0"); + let amount = BigInt(match[2]) * 100n + BigInt(fraction.slice(0, 2)); + if (Number(fraction[2]) >= 5) amount += 1n; + return match[1] ? -amount : amount; + } + + function formatCents(amount) { + const negative = amount < 0n; + const absolute = negative ? -amount : amount; + const whole = (absolute / 100n).toLocaleString("en-US"); + const fraction = absolute % 100n; + const text = fraction ? `$${whole}.${String(fraction).padStart(2, "0")}` : `$${whole}`; + return negative ? `−${text}` : text; + } + + // ---------- Quote ---------- + + // Free text never leaves the page: lines and text parameters are replaced by neutral placeholders + // that price identically (pricing only counts lines and checks that text is present). + function quoteValue(field) { + const kind = paramKind(field); + if (kind === "lines") { + return field.value + .split(/\r?\n/) + .filter((line) => line.trim()) + .map(() => "-") + .join("\n"); + } + if (kind === "text") return field.value.trim() ? "-" : ""; + return field.value; + } + + function quoteParams(chosen) { + const params = new URLSearchParams(); + chosen.forEach((family) => params.append(family.include.name, family.slug)); + const discount = form.querySelector("input[name='discount']:checked"); + if (discount) params.set(discount.name, discount.value); + chosen.forEach((family) => { + const tier = tierOf(family); + params.append(tier.name, tier.value); + family.extras.forEach((row) => { + const box = extraBox(row); + if (!box.checked || box.disabled) return; + params.append(box.name, "on"); + $$("[data-param-key]", row).forEach((field) => { + if (!field.disabled) params.append(field.name, quoteValue(field)); + }); + }); + }); + if (form.dataset.order) params.set("order", form.dataset.order); + return params; + } + + function fieldLabel(slug, fieldName) { + const field = form.querySelector(`[name="${CSS.escape(`${slug}-${fieldName}`)}"]`); + const row = field?.closest(".pb-extra"); + if (!row) return ""; + const param = field.closest(".pb-param")?.querySelector("label")?.textContent.trim(); + return param ? `${extraName(row)}, ${param}` : extraName(row); + } + + function quoteProblems(payload) { + const problems = []; + Object.entries(payload?.errors || {}).forEach(([slug, value]) => { + const family = families.find((candidate) => candidate.slug === slug); + const prefix = family ? `${family.name}: ` : ""; + if (Array.isArray(value)) { + value.forEach((message) => problems.push(prefix + (typeof message === "string" ? message : message.message))); + return; + } + Object.entries(value || {}).forEach(([field, errors]) => { + const label = field === "__all__" ? "" : fieldLabel(slug, field); + errors.forEach(({ message }) => { + const text = label && !message.startsWith(label.split(",")[0]) ? `${label}: ${message}` : message; + problems.push(prefix + text); + }); + }); + }); + return problems; + } + + const state = { quote: null, pending: false, problems: [], failure: "", announced: "" }; + let timer; + let controller; + let generation = 0; + + function refresh(delay = 180) { + window.clearTimeout(timer); + controller?.abort(); + const current = ++generation; + const chosen = included(); + state.problems = []; + state.failure = ""; + const unresolved = families.filter((family) => family.include.checked && !tierOf(family)); + if (unresolved.length) { + state.quote = null; + state.pending = false; + state.failure = `Choose a tier for ${unresolved.map((family) => family.name).join(", ")} to calculate the complete package.`; + render(); + return; + } + if (!chosen.length) { + state.quote = null; + state.pending = false; + render(); + return; + } + state.quote = null; + state.pending = true; + render(); + timer = window.setTimeout(async () => { + controller = new AbortController(); + try { + const response = await fetch(`${form.dataset.quoteUrl}?${quoteParams(chosen)}`, { + signal: controller.signal, + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + const payload = await response.json().catch(() => null); + if (current !== generation) return; + if (response.ok && payload) { + state.quote = payload; + } else { + state.quote = null; + state.problems = response.status === 400 ? quoteProblems(payload) : []; + if (!state.problems.length) { + state.failure = "Prices are unavailable right now. You can still continue; the next page shows every fee."; + } + } + } catch (error) { + if (error.name === "AbortError" || current !== generation) return; + state.quote = null; + state.failure = "We couldn't reach the price calculator. Check your connection; you can still continue."; + } + state.pending = false; + render(); + }, delay); + } + + // ---------- Summary ---------- + + function amountNode(text, unit, className = "") { + const cell = el("span", `pb-amount ${className}`.trim()); + if (text === null) { + cell.append(el("span", "pb-skeleton")); + cell.setAttribute("aria-label", "Calculating"); + return cell; + } + cell.append(el("span", "pb-amount-value", text)); + if (unit) cell.append(el("span", "pb-amount-unit", ` ${unit}`)); + return cell; + } + + function line(label, amount, { detail = "", className = "" } = {}) { + const item = el("li", `pb-line ${className}`.trim()); + const name = el("span", "pb-line-name", label); + if (detail) name.append(el("span", "pb-line-detail", detail)); + item.append(name, amount); + return item; + } + + // The summary repeats what the customer chose, in their own words, without sending them anywhere. + function localDetail(row) { + return $$("[data-param-key]", row) + .filter((field) => !field.disabled) + .map((field) => { + const label = field.closest(".pb-param").querySelector("label").textContent.trim(); + const kind = paramKind(field); + let value = field.value.trim(); + if (kind === "choice") value = field.selectedOptions[0]?.textContent.trim() || value; + if (kind === "lines") value = field.value.split(/\r?\n/).map((part) => part.trim()).filter(Boolean).join(", "); + return value ? `${label}: ${value}` : ""; + }) + .filter(Boolean); + } + + function itemDetail(row, item) { + const labels = $$(".pb-param label", row).map((label) => `${label.textContent.trim()}: `); + const fromRule = (item?.detail || "") + .split("; ") + .filter((part) => part && part !== "Included at this service tier" && !labels.some((label) => part.startsWith(label))); + return [...fromRule, ...localDetail(row)].join(" · "); + } + + function renderFamily(family, quoted) { + const tier = tierOf(family); + const column = tierColumn(tier); + const group = el("section", "pb-sum-family"); + group.setAttribute("aria-label", family.name); + const head = el("div", "pb-sum-head"); + head.append(el("h3", "pb-sum-name", family.name)); + const actions = el("span", "pb-sum-actions"); + const change = el("a", "pb-sum-change", "Change"); + change.href = `#family-${family.slug}`; + change.setAttribute("aria-label", `Change ${family.name}`); + change.dataset.focusKey = `change-${family.slug}`; + const remove = el("button", "pb-linkbutton", "Remove"); + remove.type = "button"; + remove.dataset.remove = family.slug; + remove.dataset.focusKey = `remove-${family.slug}`; + remove.setAttribute("aria-label", `Remove ${family.name} from your package`); + actions.append(change, remove); + head.append(actions); + group.append(head); + + const list = el("ul", "pb-lines"); + list.append( + line(`${column.dataset.tierName} tier`, amountNode(quoted?.display.tier_fee || column.dataset.fee, "a year"), { + className: "pb-line-tier", + }), + ); + + const priced = [...(quoted?.items || [])]; + family.extras.forEach((row) => { + if (row.classList.contains("is-included")) { + list.append(line(extraName(row), amountNode("Included", ""), { className: "pb-line-included" })); + } + }); + family.extras.forEach((row) => { + const box = extraBox(row); + if (!box.checked || box.disabled) return; + const name = extraName(row); + const index = priced.findIndex((item) => item.name === name); + const item = index >= 0 ? priced.splice(index, 1)[0] : null; + let amount; + if (!item) amount = amountNode(state.problems.length || state.failure ? "—" : null, ""); + else if (item.amount === null) amount = amountNode(item.display, "", "is-unpriced"); + else amount = amountNode(item.display, item.basis === "per year" ? "a year" : item.basis); + list.append(line(name, amount, { detail: itemDetail(row, item) })); + }); + + if (quoted && Number(quoted.discount_percent) > 0) { + list.append( + line(`${quoted.discount_name}, ${quoted.discount_percent}% off the tier`, amountNode(`−${quoted.display.discount_amount}`, "a year"), { + className: "pb-line-discount", + }), + ); + } + group.append(list); + return group; + } + + function renderTotals(chosen) { + const quote = state.quote; + totals.replaceChildren(); + totals.hidden = !chosen.length; + if (!chosen.length) return ""; + const rows = el("dl", "pb-total-rows"); + const add = (label, value, className = "") => { + const row = el("div", `pb-total ${className}`.trim()); + row.append(el("dt", "", label), el("dd", "num", value)); + rows.append(row); + }; + if (!quote) { + const waiting = state.pending ? "Calculating…" : "—"; + add("Total", waiting, "pb-total-grand"); + totals.append(rows); + return ""; + } + let recurring = 0n; + let oneTime = 0n; + const unpriced = []; + quote.agreements.forEach((agreement) => { + recurring += cents(agreement.tier_fee) - cents(agreement.discount_amount); + agreement.items.forEach((item) => { + if (item.amount === null) unpriced.push(item.name); + else if (item.recurring) recurring += cents(item.amount); + else oneTime += cents(item.amount); + }); + }); + const months = Number(quote.term_months) || 12; + if (months > 12) { + add("Each year", formatCents(recurring)); + if (oneTime) add("One-time", formatCents(oneTime)); + add(`${months}-month initial term, prepaid`, quote.display.term_total, "pb-total-grand"); + } else if (oneTime) { + add("Each year", formatCents(recurring)); + add("One-time", formatCents(oneTime)); + add("First year", quote.display.total_annual, "pb-total-grand"); + } else { + add("Each year", quote.display.total_annual, "pb-total-grand"); + } + totals.append(rows); + if (unpriced.length) { + totals.append(el("p", "pb-total-note", `Not included above, quoted separately: ${unpriced.join(", ")}.`)); + } + const grand = rows.querySelector(".pb-total-grand"); + return `${grand.querySelector("dt").textContent}: ${grand.querySelector("dd").textContent}`; + } + + function renderStatus() { + status.replaceChildren(); + status.classList.toggle("is-error", Boolean(state.failure)); + if (state.problems.length) { + status.append(el("p", "pb-status-title", "To price your package:")); + const list = el("ul"); + state.problems.forEach((problem) => list.append(el("li", "", problem))); + status.append(list); + } else if (state.failure) { + status.append(el("p", "", state.failure)); + } + status.hidden = !state.problems.length && !state.failure; + } + + function renderEmpty() { + const empty = el("div", "pb-empty"); + empty.append( + el("p", "pb-empty-title", "Nothing here yet"), + el( + "p", + "", + families.length > 1 + ? "Choose a tier from any service to start. You can mix tiers across services and add extras." + : "Choose a tier to start, then add any extras.", + ), + ); + return empty; + } + + function render() { + const chosen = included(); + const quoted = new Map((state.quote?.agreements || []).map((agreement) => [agreement.slug, agreement])); + summary.classList.toggle("is-pending", state.pending); + summary.setAttribute("aria-busy", String(state.pending)); + const focused = body.contains(document.activeElement) ? document.activeElement.dataset.focusKey : null; + body.replaceChildren(...(chosen.length ? chosen.map((family) => renderFamily(family, quoted.get(family.slug))) : [renderEmpty()])); + if (focused) body.querySelector(`[data-focus-key="${focused}"]`)?.focus(); + const spoken = renderTotals(chosen); + renderStatus(); + const grand = totals.querySelector(".pb-total-grand dd"); + barTotal.textContent = grand && !state.pending ? grand.textContent : ""; + updateBar(); + if (spoken && !state.pending && spoken !== state.announced) { + state.announced = spoken; + announce(spoken); + } + } + + // ---------- Small-screen package bar ---------- + + const wide = window.matchMedia("(min-width: 60em)"); + const visible = new Set(); + function updateBar() { + bar.hidden = wide.matches || !included().length || visible.size > 0; + } + if ("IntersectionObserver" in window) { + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => (entry.isIntersecting ? visible.add(entry.target) : visible.delete(entry.target))); + updateBar(); + }); + [summary, document.getElementById("review")].forEach((target) => target && observer.observe(target)); + } + wide.addEventListener?.("change", updateBar); + + // ---------- Events ---------- + + form.addEventListener("change", (event) => { + const target = event.target; + const family = familyOf(target); + if (family && target.closest(".pb-staff")) return; + if (family) { + if (target === family.include) { + setIncluded(family, target.checked); + announce( + target.checked + ? `${family.name} added at the ${tierColumn(tierOf(family)).dataset.tierName} tier.` + : `${family.name} removed. Your choices are kept if you add it again.`, + ); + } else if (target.classList.contains("pb-tier-input") && !family.include.checked) { + family.include.checked = true; + announce(`${family.name} added at the ${tierColumn(target).dataset.tierName} tier.`); + } + } else if (target.name !== "discount") { + return; + } + syncAll(); + refresh(); + }); + + form.addEventListener("input", (event) => { + if (!event.target.closest(".pb-params")) return; + syncAll(); + refresh(350); + }); + + summary.addEventListener("click", (event) => { + const button = event.target.closest("[data-remove]"); + if (!button) return; + const family = families.find((candidate) => candidate.slug === button.dataset.remove); + if (!family) return; + setIncluded(family, false); + syncAll(); + refresh(0); + announce(`${family.name} removed. Your choices are kept if you add it again.`); + summaryTitle.focus(); + }); + + families.forEach((family) => { + if (!family.include.checked) setIncluded(family, false); + }); + syncAll(); + const revealContact = setupContacts(); + setupSteps(revealContact); + refresh(0); +})(); diff --git a/apps/agreements/templates/agreements/_actions.html b/apps/agreements/templates/agreements/_actions.html new file mode 100644 index 000000000..d6f5cddca --- /dev/null +++ b/apps/agreements/templates/agreements/_actions.html @@ -0,0 +1,162 @@ +{% comment %} +Signing and staff actions for one agreement. Context comes from agreements.views.helpers.action_forms; +pass `next` to return to the including page after each action. +{% endcomment %} +{% if agreement.status == "offered" %} +
+

Ready to sign{% if agreement.revision > 1 %} (revision {{ agreement.revision }}, edited by the PSF){% endif %}. Nothing is binding until both parties sign.

+
+{% elif agreement.status == "signed" %} +

Signed by {{ agreement.signer_name }} on {{ agreement.signed_at|date:"F j, Y" }}. The PSF reviews and countersigns; it takes effect on the date of the last signature.

+{% elif agreement.status == "executed" %} +

Both parties have signed. It took effect on {{ agreement.countersigned_at|date:"F j, Y" }}.

+{% elif agreement.status == "declined" %} +

The PSF declined this: {{ agreement.decline_reason }}

+{% endif %} + +{% include "agreements/_record.html" %} + +{% if is_administrator and agreement.status == "executed" and agreement.signer_email %} +
+ {% csrf_token %} + {% if next %}{% endif %} +

Send the signed PDF to {{ agreement.signer_email }} without changing either signature.

+ +
+{% endif %} + +{% if countersign_form %} +
+

Countersign for the Python Software Foundation

+

Check the document, the signatory's identity and authority{% if agreement.signature_method == "offline" %}, the signed copy{% endif %}, and anything the counterparty claimed before you sign.

+
+ {% csrf_token %} + {% if next %}{% endif %} +
+ {% include "agreements/_field.html" with field=countersign_form.name %} + {% include "agreements/_field.html" with field=countersign_form.title %} +
+ {% include "agreements/_field.html" with field=countersign_form.executed_copy %} + + {{ countersign_form.accept.errors }} + +
+
+ Decline +
+ {% csrf_token %} + {% if next %}{% endif %} + {% include "agreements/_field.html" with field=decline_form.reason %} + +
+
+
+{% endif %} + +{% if sign_form %} +
+

Sign on python.org

+
+ {% csrf_token %} + {% if next %}{% endif %} + {{ sign_form.document_sha256 }} + {{ sign_form.non_field_errors }} + + {{ sign_form.accept.errors }} +
+ {% include "agreements/_field.html" with field=sign_form.signer_name %} + {% include "agreements/_field.html" with field=sign_form.signer_title %} +
+
+

Signing as {{ user.get_full_name|default:user.get_username }} ({{ user.email }}) for {{ agreement.counterparty_name }}, {% now "F j, Y" %}.

+

Your identity comes from your python.org account. Your email, the time, and your IP address are recorded with your signature and the exact text you sign.

+
+ +
+
+{% endif %} + +{% if copy_form %} +
+

{% if sign_form %}Or sign outside python.org{% else %}Signature outside python.org{% endif %}

+
    +
  1. Download the PDF (or DOCX) of revision {{ agreement.revision }}.
  2. +
  3. Sign it through DocuSign, a procurement system, or on paper, without changing it. If something needs to change, the PSF edits it here first.
  4. +
  5. Upload the signed copy below. The PSF then countersigns.
  6. +
+ + Upload the signed copy +
+ {% csrf_token %} + {% if next %}{% endif %} + {{ copy_form.document_sha256 }} + {{ copy_form.non_field_errors }} + {% include "agreements/_field.html" with field=copy_form.signed_copy %} +
+ {% include "agreements/_field.html" with field=copy_form.signer_name %} + {% include "agreements/_field.html" with field=copy_form.signer_title %} +
+
+ {% include "agreements/_field.html" with field=copy_form.signer_email %} + {% include "agreements/_field.html" with field=copy_form.signed_on %} +
+ + {{ copy_form.matches.errors }} + +
+ +
+{% endif %} + +{% if is_administrator and agreement.status == "offered" %} +
+

PSF staff

+

+ Edit this document + Changes apply to this {{ agreement.counterparty_name }} document only. To change the standing terms for everyone, edit the terms. +

+

Email a signing link

+

For a signatory without a python.org account. The link works once and expires after 14 days.

+
+ {% csrf_token %} + {% if next %}{% endif %} +
+ {% include "agreements/_field.html" with field=link_form.name %} + {% include "agreements/_field.html" with field=link_form.email %} +
+ +
+ {% if links %} +
+ + + + {% for link in links %} + + + + + + {% endfor %} + +
Sent toSentStatus
{{ link.name }} ({{ link.email }}){{ link.created_at|date:"M j, Y" }} by {{ link.created_by.email }}{% if link.used_at %}Used {{ link.used_at|date:"M j, Y" }}{% elif link.is_usable %}Expires {{ link.expires_at|date:"M j, Y" }}{% else %}Expired{% endif %}
+
+ {% endif %} +
+{% endif %} + +{% if can_withdraw %} +
+ {% csrf_token %} + +
+{% endif %} diff --git a/apps/agreements/templates/agreements/_agreement_table.html b/apps/agreements/templates/agreements/_agreement_table.html new file mode 100644 index 000000000..26ea2d20f --- /dev/null +++ b/apps/agreements/templates/agreements/_agreement_table.html @@ -0,0 +1,22 @@ +{% load agreements %} +{% if agreements %} +
+ + + + + + {% for agreement in agreements %} + + + + + + + {% endfor %} + +
CounterpartyDocumentStatusSince
{{ agreement.counterparty_name }}{{ agreement.reference }}{{ agreement.title }}{% if agreement.revision > 1 %} (revision {{ agreement.revision }}){% endif %}{% status_badge agreement.status %}{% if agreement.status == "signed" %}{{ agreement.signed_at|date:"M j, Y" }} by {{ agreement.signer_email }}{% elif agreement.status == "executed" %}{{ agreement.countersigned_at|date:"M j, Y" }}{% else %}{{ agreement.offered_at|date:"M j, Y" }}{% endif %}
+
+{% else %} +

{{ empty }}

+{% endif %} diff --git a/apps/agreements/templates/agreements/_field.html b/apps/agreements/templates/agreements/_field.html new file mode 100644 index 000000000..dcf6dd021 --- /dev/null +++ b/apps/agreements/templates/agreements/_field.html @@ -0,0 +1,6 @@ +
+ + {{ field }} + {% if field.help_text %}

{{ field.help_text }}

{% endif %} + {{ field.errors }} +
diff --git a/apps/agreements/templates/agreements/_record.html b/apps/agreements/templates/agreements/_record.html new file mode 100644 index 000000000..acf0fbca1 --- /dev/null +++ b/apps/agreements/templates/agreements/_record.html @@ -0,0 +1,20 @@ +{% if agreement.signed_at %} + + + + + {% if agreement.signature_method == "offline" %} + + {% else %} + + {% endif %} + {% if agreement.countersigned_at %} + + {% endif %} + + {% for copy in copies %} + + {% endfor %} + +
Signature record
Signed by{{ agreement.signer_name }}, {{ agreement.signer_title }} ({{ agreement.signer_email }})
SignedOutside python.org, {{ agreement.signed_at|date:"F j, Y" }}; recorded by {{ agreement.signature_recorded_by.email }}
Signed{{ agreement.get_signature_method_display }}, {{ agreement.signed_at|date:"F j, Y, g:i A T" }}
Countersigned by{{ agreement.countersigner_name }}, {{ agreement.countersigner_title }}, {{ agreement.countersigned_at|date:"F j, Y" }}
DocumentRevision {{ agreement.revision }}
{{ copy.get_kind_display }}Download PDF
+{% endif %} diff --git a/apps/agreements/templates/agreements/_status.html b/apps/agreements/templates/agreements/_status.html new file mode 100644 index 000000000..15555d530 --- /dev/null +++ b/apps/agreements/templates/agreements/_status.html @@ -0,0 +1 @@ +{{ label }} \ No newline at end of file diff --git a/apps/agreements/templates/agreements/base.html b/apps/agreements/templates/agreements/base.html new file mode 100644 index 000000000..76ebb6c5d --- /dev/null +++ b/apps/agreements/templates/agreements/base.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% load static sitetree agreements %} + +{% block page_title %}{% block title %}Agreements{% endblock %} | Python Software Foundation{% endblock %} +{% block og_title %}PSF Agreements{% endblock %} + +{% block body_attributes %}class="psf agreements"{% endblock %} + +{% block section-logo %}{% endblock %} + +{% block main-nav_attributes %}psf-navigation{% endblock %} + +{% block main_navigation %} +{% sitetree_menu from "main" include "psf-meta" template "sitetree/submenu.html" %} +{% endblock %} + +{% block head %}{% endblock %} + +{% block breadcrumbs %} + +{% endblock breadcrumbs %} + +{% block content_attributes %}with-left-sidebar{% endblock %} + +{% block left_sidebar %} + +{% endblock left_sidebar %} diff --git a/apps/agreements/templates/agreements/custom_detail.html b/apps/agreements/templates/agreements/custom_detail.html new file mode 100644 index 000000000..30a7c2591 --- /dev/null +++ b/apps/agreements/templates/agreements/custom_detail.html @@ -0,0 +1,38 @@ +{% extends "agreements/base.html" %} +{% load agreements %} +{% block title %}{{ contract.title }}{% endblock %} +{% block crumbs %} +
  • Agreements>>>
  • +
  • {{ contract.title }}
  • +{% endblock %} + +{% block content %} +
    +
    +

    {{ contract.title }}

    +
    +

    {% status_badge contract.status %} With {{ contract.counterparty_name }}{% if contract.terms %} · Cites {{ contract.terms.title }}, version {{ contract.terms.current_version.version }}{% endif %}

    + +

    + Edit +

    + +
    {{ html|safe }}
    + + {% if user|is_agreements_administrator %} +

    Making it ready to sign fixes this text as revision 1. You can then send a signing link, record a signed copy, or {% if contract.counterparty_account %}let {{ contract.counterparty_account.email }} sign on python.org{% else %}name an account that signs online{% endif %}, and still edit it until it is signed.

    + {% endif %} +
    + {% if user|is_agreements_administrator %} +
    + {% csrf_token %} + +
    + {% endif %} +
    + {% csrf_token %} + +
    +
    +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/custom_form.html b/apps/agreements/templates/agreements/custom_form.html new file mode 100644 index 000000000..ee9484744 --- /dev/null +++ b/apps/agreements/templates/agreements/custom_form.html @@ -0,0 +1,33 @@ +{% extends "agreements/base.html" %} +{% block title %}{% if contract %}Edit contract{% else %}New contract{% endif %}{% endblock %} +{% block crumbs %} +
  • Agreements>>>
  • +
  • {% if contract %}Edit contract{% else %}New contract{% endif %}
  • +{% endblock %} + +{% block content %} +
    +
    +

    {% if contract %}Edit {{ contract.title }}{% else %}New contract{% endif %}

    +
    +

    For one-off contracts, such as with a PyCon venue or vendor. Write the terms below; the parties, any standing terms you cite, and the signature block are added for you. Nothing is sent until you make it ready to sign.

    + +
    + {% csrf_token %} + {{ form.non_field_errors }} + {% include "agreements/_field.html" with field=form.title %} +
    + {% include "agreements/_field.html" with field=form.counterparty_name %} + {% if form.counterparty_account_email %} + {% include "agreements/_field.html" with field=form.counterparty_account_email %} + {% endif %} +
    + {% include "agreements/_field.html" with field=form.terms %} + {% include "agreements/_field.html" with field=form.body_markdown %} +

    + + Cancel +

    +
    +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/detail.html b/apps/agreements/templates/agreements/detail.html new file mode 100644 index 000000000..ab6fe0468 --- /dev/null +++ b/apps/agreements/templates/agreements/detail.html @@ -0,0 +1,33 @@ +{% extends "agreements/base.html" %} +{% load agreements %} +{% block title %}{{ agreement.title }}: {{ agreement.counterparty_name }}{% endblock %} + +{% block crumbs %} +{% if can_prepare %}
  • Agreements>>>
  • {% endif %} +
  • {{ agreement.counterparty_name }}
  • +{% endblock %} + +{% block content %} +
    +
    +

    {{ agreement.title }}

    +
    +

    {% status_badge agreement.status %} {{ agreement.counterparty_name }} · Reference {{ agreement.reference }} · Revision {{ agreement.revision }}{% if agreement.subject and agreement.kind != "custom" %} · Details{% endif %}

    + +
    +

    Document

    +

    Download: PDF · DOCX

    + {% if metadata_hidden %}

    For the complete document, download the PDF or DOCX.

    {% endif %} + {% if agreement.status == "offered" %} +
    {{ html|safe }}
    + {% else %} +
    + Read the agreement +
    {{ html|safe }}
    +
    + {% endif %} +
    + + {% include "agreements/_actions.html" %} +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/edit.html b/apps/agreements/templates/agreements/edit.html new file mode 100644 index 000000000..3c804aaab --- /dev/null +++ b/apps/agreements/templates/agreements/edit.html @@ -0,0 +1,68 @@ +{% extends "agreements/base.html" %} +{% block title %}Edit: {{ agreement.title }}{% endblock %} + +{% block crumbs %} +
  • Agreements>>>
  • +
  • {{ agreement.counterparty_name }}>>>
  • +
  • Edit
  • +{% endblock %} + +{% block content %} +
    +
    +

    Edit {{ agreement.title }}

    +
    +

    Changes apply to {{ agreement.counterparty_name }}'s document only, as revision {{ agreement.revision|add:1 }}. To change the terms for every future document, edit the terms instead.

    +
    +

    Anyone signing from now on sees the new text. If the counterparty already downloaded revision {{ agreement.revision }} to sign elsewhere, send them the new PDF: a signed copy must be the current revision.

    +
    + + {% if conflict %} + +

    Current revision {{ agreement.revision }}

    +
    {{ conflict.current_markdown }}
    +

    Changes your proposed replacement would make

    +

    Removed lines are marked with − and added lines with +.

    +
    {% for tag, line in conflict.diff %}{{ line }}
    +{% endfor %}
    + {% endif %} + +
    + {% csrf_token %} + {{ form.base_sha256 }} + {{ form.non_field_errors }} + {% include "agreements/_field.html" with field=form.markdown %} + {% include "agreements/_field.html" with field=form.note %} +

    + + + Cancel +

    +
    + + {% if preview %} +

    Preview

    +
    {{ preview|safe }}
    + {% endif %} + +

    Revisions

    +
      + {% for item in history %} +
    1. + Revision {{ item.revision.revision }}, {{ item.revision.created_at|date:"M j, Y, g:i A" }} by {{ item.revision.created_by.email }}: {{ item.revision.note }} + {% if item.diff %} +
      + Changes from revision {{ item.revision.revision|add:-1 }} +
      {% for tag, line in item.diff %}{{ line }}
      +{% endfor %}
      +
      + {% endif %} +
    2. + {% endfor %} +
    +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/email/executed.txt b/apps/agreements/templates/agreements/email/executed.txt new file mode 100644 index 000000000..33075b7f9 --- /dev/null +++ b/apps/agreements/templates/agreements/email/executed.txt @@ -0,0 +1,8 @@ +{% autoescape off %}Hello {{ agreement.signer_name }}, + +The Python Software Foundation has countersigned {{ agreement.title }} (reference {{ agreement.reference }}) with {{ agreement.counterparty_name }}. It took effect on {{ agreement.countersigned_at|date:"F j, Y" }}. + +The signed copy is attached.{% if terms_versions %} The cited terms versions are also attached, so you can retain them even if their online pages are private.{% endif %} Keep these documents for your records. + +Python Software Foundation +{% endautoescape %} diff --git a/apps/agreements/templates/agreements/email/executed_subject.txt b/apps/agreements/templates/agreements/email/executed_subject.txt new file mode 100644 index 000000000..ad22d5751 --- /dev/null +++ b/apps/agreements/templates/agreements/email/executed_subject.txt @@ -0,0 +1 @@ +Signed by both parties: {{ agreement.title }} diff --git a/apps/agreements/templates/agreements/email/signing_link.txt b/apps/agreements/templates/agreements/email/signing_link.txt new file mode 100644 index 000000000..a2ebc0c3b --- /dev/null +++ b/apps/agreements/templates/agreements/email/signing_link.txt @@ -0,0 +1,17 @@ +{% autoescape off %}Hello {{ link.name }}, + +The Python Software Foundation has prepared the following for {{ agreement.counterparty_name }} to sign: + + {{ agreement.title }} + Reference {{ agreement.reference }} + +Review and sign it here: + + {{ url }} + +The link works once and expires on {{ link.expires_at|date:"F j, Y" }}. Your name, title, email, the time, and your IP address are recorded with your signature. If you would rather sign through DocuSign or on paper, reply to this email. + +If you weren't expecting this, you can ignore it. + +Python Software Foundation +{% endautoescape %} diff --git a/apps/agreements/templates/agreements/email/signing_link_subject.txt b/apps/agreements/templates/agreements/email/signing_link_subject.txt new file mode 100644 index 000000000..f819797ab --- /dev/null +++ b/apps/agreements/templates/agreements/email/signing_link_subject.txt @@ -0,0 +1 @@ +Please sign: {{ agreement.title }} diff --git a/apps/agreements/templates/agreements/orders/_comparison.html b/apps/agreements/templates/agreements/orders/_comparison.html new file mode 100644 index 000000000..2806e574b --- /dev/null +++ b/apps/agreements/templates/agreements/orders/_comparison.html @@ -0,0 +1,94 @@ +{% load agreements %}{% with agreement=aform.agreement tier_options=aform.tier_options has_included_extras=aform.has_included_extras %} +
    + Compare service levels for {{ agreement.short_name }} +
    + + + + + {% for tier in agreement.tiers %}{% endfor %} + + + + + {% for tier in agreement.tiers %}{% endfor %} + + + + + + {% for tier in agreement.tiers %}{% endfor %} + + + + {% for tier in agreement.tiers %}{% endfor %} + + + + {% for tier in agreement.tiers %}{% endfor %} + + + + {% for tier in agreement.tiers %}{% endfor %} + + + + {% for tier in agreement.tiers %}{% endfor %} + + + + {% for tier in agreement.tiers %}{% endfor %} + + {% for service in agreement.services %} + + + {% for option in tier_options %} + {% if service in option.services %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} + {% if has_included_extras %} + + + {% for option in tier_options %}{% with tier=option.tier %} + + {% endwith %}{% endfor %} + + {% endif %} + + + + + {% for tier in agreement.tiers %} + + {% endfor %} + + +
    {{ agreement.short_name }} service levels
    Service tier{{ tier.name }}
    Annual fee{{ tier.annual_fee|money }}
    Critical (P1) response target{{ tier.response_targets.0 }}
    High (P2) response target{{ tier.response_targets.1 }}
    Medium (P3) response target{{ tier.response_targets.2 }}
    Low (P4) response target{{ tier.response_targets.3 }}
    Support requests a year Fair-use threshold{{ tier.fair_use }}
    {{ service.name }}IncludedNot included
    Also included + {% if tier.includes or option.included_addons %} +
      + {% if tier.includes %}
    • {{ tier.includes }}
    • {% endif %} + {% for addon in option.included_addons %}
    • {{ addon.name }}
    • {% endfor %} +
    + {% else %} + Nothing else + {% endif %} +
    Choose a service tier
    +
    + {% if agreement.services %} +
    +

    What the services cover

    +
    + {% for service, tiers in agreement.service_rows %} +
    {{ service.name }} {% if tiers %}{{ tiers }} only{% else %}Every tier{% endif %}
    + {% if service.description %}
    {{ service.description }}
    {% endif %} + {% endfor %} +
    +
    + {% endif %} +
    +{% endwith %} diff --git a/apps/agreements/templates/agreements/orders/_extras.html b/apps/agreements/templates/agreements/orders/_extras.html new file mode 100644 index 000000000..b7ef6bb5a --- /dev/null +++ b/apps/agreements/templates/agreements/orders/_extras.html @@ -0,0 +1,40 @@ +{% with agreement=aform.agreement %}{% if agreement.addons %} +
    +

    {{ agreement.short_name }} extras

    +
    + Extras for {{ agreement.short_name }} +

    Priced separately unless included in your tier.

    +
      + {% for row in aform.addon_fields %}{% with addon=row.addon %} +
    • +
      + {{ row.checkbox }} + + {% if addon.description %}

      {{ addon.description }}

      {% endif %} + {% if addon.included_in_tiers %} +

      Included with {% for tier in agreement.tiers %}{% if tier.key in addon.included_in_tiers %}{{ tier.name }}{% endif %}{% endfor %}

      + + {% endif %} +
      + {% if show_errors %}{{ row.checkbox.errors }}{% endif %} + {% if row.params %} +
      + {% for field in row.params %} +
      + + {{ field }} + {% if field.help_text %}

      {{ field.help_text }}

      {% endif %} + {% if show_errors %}{{ field.errors }}{% endif %} +
      + {% endfor %} +
      + {% endif %} +
    • + {% endwith %}{% endfor %} +
    +
    +
    +{% endif %}{% endwith %} diff --git a/apps/agreements/templates/agreements/orders/_family.html b/apps/agreements/templates/agreements/orders/_family.html new file mode 100644 index 000000000..54e2f4606 --- /dev/null +++ b/apps/agreements/templates/agreements/orders/_family.html @@ -0,0 +1,39 @@ +{% load agreements %}{% with agreement=aform.agreement tier_options=aform.tier_options %} +
    +
    +
    +

    {{ agreement.short_name }}

    + {% if agreement.tagline %}

    {{ agreement.tagline }}

    {% endif %} +
    + +
    + + {% if show_errors %}{{ aform.non_field_errors }}{% endif %} + +
    + {{ agreement.short_name }} service tier +
    + {% for option in tier_options %}{% with tier=option.tier %} +
    +
    + + +

    {{ tier.annual_fee|money }} a year

    +
    +
    +
    Critical (P1) response target
    {{ tier.response_targets.0 }}
    +
    Support requests a year
    {{ tier.fair_use }}
    +
    +
    + {% endwith %}{% endfor %} +
    +
    + {% if show_errors %}{{ aform.tier.errors }}{% endif %} + + {% include "agreements/orders/_comparison.html" %} + +
    +{% endwith %} diff --git a/apps/agreements/templates/agreements/orders/_order_table.html b/apps/agreements/templates/agreements/orders/_order_table.html new file mode 100644 index 000000000..e40e2ee71 --- /dev/null +++ b/apps/agreements/templates/agreements/orders/_order_table.html @@ -0,0 +1,31 @@ +{% load agreements %} +
    + + + + + + + + + + + + {% for row in orders %} + {% with order=row.order %} + + + {% if row.review_selections %} + + {% else %} + + + {% endif %} + + + + {% endwith %} + {% endfor %} + +
    CustomerAgreementsFeesStatus{% if show_owner %}Customer account{% else %}Updated{% endif %}
    {{ order.legal_name }}{{ order.reference }} · {{ order.program_title }}Review selections{% for name, tier in row.selections %}{{ name }} ({{ tier }}){% if not forloop.last %}
    {% endif %}{% endfor %}
    {{ row.fees.annual|money }} a year{% if row.fees.one_time %}
    {{ row.fees.one_time|money }} one-time{% endif %}
    {% status_badge order.status %}{% if show_owner %}{{ order.customer_account.email|default:"Signs outside python.org" }}{% else %}{{ order.modified|date:"M j, Y" }}{% endif %}
    +
    diff --git a/apps/agreements/templates/agreements/orders/base.html b/apps/agreements/templates/agreements/orders/base.html new file mode 100644 index 000000000..22b5187f8 --- /dev/null +++ b/apps/agreements/templates/agreements/orders/base.html @@ -0,0 +1,32 @@ +{% extends "agreements/base.html" %} +{% load static agreements %} + +{% block title %}Service orders{% endblock %} +{% block body_attributes %}class="psf agreements agreement-orders"{% endblock %} +{% block head %}{{ block.super }}{% endblock %} +{% block crumbs %}
  • Service orders
  • {% endblock %} + +{% block left_sidebar %} + +{% endblock %} diff --git a/apps/agreements/templates/agreements/orders/order_detail.html b/apps/agreements/templates/agreements/orders/order_detail.html new file mode 100644 index 000000000..b00509cda --- /dev/null +++ b/apps/agreements/templates/agreements/orders/order_detail.html @@ -0,0 +1,132 @@ +{% extends "agreements/orders/base.html" %} +{% load agreements %} +{% block title %}{{ order.legal_name }}: {{ order.program_title }} order{% endblock %} + +{% block crumbs %} +
  • Programs>>>
  • +{% if nav == "staff" %} +
  • Orders>>>
  • +{% else %} +
  • Your orders>>>
  • +{% endif %} +
  • {{ order.legal_name }}
  • +{% endblock %} + +{% block content %} +
    +
    +

    {{ order.legal_name }}

    +
    +

    {% status_badge order.status %} {{ order.program_title }} order · Reference {{ order.reference }}{% if order.agreement and order.agreement.revision > 1 %} · Order Form revision {{ order.agreement.revision }}{% endif %}{% if can_prepare and order.agreement %} · Agreement record{% endif %}

    + + {% if configuration_error %} +

    {{ configuration_error }}

    + {% if can_edit %}

    Edit selections

    {% endif %} + {% else %} +
    + + + + + + {% for line in lines %} + {% with terms=line.cited_terms %} + + + + + + + {% endwith %} + {% endfor %} + + + + + + + {% if order.fee_totals.one_time %} + + + + + {% endif %} + {% if order.term_months > 12 or order.fee_totals.one_time %} + + + + + {% endif %} + +
    AgreementService TierTermsFees
    {{ line.agreement_obj.short_name }}{{ line.tier_name }}Version {{ terms.version }}{{ line.fee_totals.annual|money }} a year{% if line.fee_totals.one_time %}
    {{ line.fee_totals.one_time|money }} one-time{% endif %}
    Total annual fees{{ order.fee_totals.annual|money }}
    Total one-time fees{{ order.fee_totals.one_time|money }}
    {{ order.term_months }}-month Initial Term{% if order.term_months > 12 %}, prepaid{% endif %}{{ order.term_total_display }}
    +
    +

    {{ order.catalog.covered_entities_label }}: {{ order.organization_list|join:", " }}

    + + {% if not order.agreement %} +

    Review the Order Form below. It cites the published terms of each agreement, which are part of the contract.{% if can_edit %} Edit selections{% endif %}

    + {% endif %} + +
    +

    Order Form

    +

    Download: PDF · DOCX{% if not order.agreement %} (preview){% endif %}

    + {% if metadata_hidden %}

    For the complete document, download the PDF or DOCX.

    {% endif %} + {% if order.status == "draft" or order.status == "offered" %} +
    {{ html|safe }}
    + {% else %} +
    + Read the Order Form +
    {{ html|safe }}
    +
    + {% endif %} +
    + + {% if order.agreement %} + {% include "agreements/_actions.html" with agreement=order.agreement %} + {% else %} + {% if draft_sign_form %} +
    +

    Sign on python.org

    +
    + {% csrf_token %} + {{ draft_sign_form.document_sha256 }} + {{ draft_sign_form.non_field_errors }} + + {{ draft_sign_form.accept.errors }} +
    + {% include "agreements/_field.html" with field=draft_sign_form.signer_name %} + {% include "agreements/_field.html" with field=draft_sign_form.signer_title %} +
    +
    +

    Signing as {{ user.get_full_name|default:user.get_username }} ({{ user.email }}) for {{ order.legal_name }}, {% now "F j, Y" %}.

    +

    Your identity comes from your python.org account. Your email, the time, and your IP address are recorded with your signature and the exact text of the Order Form.

    +
    + +
    +
    + {% endif %} + + {% if can_offer %} +
    +

    {% if draft_sign_form %}Or sign some other way{% else %}Prepare for signature{% endif %}

    +

    Making the Order Form ready to sign fixes its text. Then {% if is_administrator %}the PSF can edit it for this customer, email a one-time signing link to anyone, or record a copy signed through DocuSign or on paper{% else %}you can download it to sign through DocuSign, your procurement system, or on paper, and upload the signed copy{% endif %}. It can be withdrawn and changed until it is signed.

    +
    +
    + {% csrf_token %} + +
    +
    +
    + {% endif %} + {% if can_edit %} +
    + {% csrf_token %} + +
    + {% endif %} + {% endif %} + {% endif %} +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/orders/order_form.html b/apps/agreements/templates/agreements/orders/order_form.html new file mode 100644 index 000000000..6471a0748 --- /dev/null +++ b/apps/agreements/templates/agreements/orders/order_form.html @@ -0,0 +1,231 @@ +{% extends "agreements/orders/base.html" %} +{% load static agreements %} +{% block title %}{% if order %}Edit package{% else %}Build a package{% endif %}{% endblock %} + +{% block crumbs %} +
  • Programs>>>
  • +{% if can_browse_program %}
  • {{ program.title }}>>>
  • {% endif %} +{% if order %} +
  • {{ order.legal_name }}>>>
  • +
  • Edit package
  • +{% else %} +
  • Build a package
  • +{% endif %} +{% endblock %} + +{% block content_attributes %}{% endblock %} +{% block left_sidebar %}{% endblock %} + +{% block content %} +
    +
    +

    {% if order %}Edit your package{% else %}Build your {{ program.title }} package{% endif %}

    +

    Choose your services, extras and term, then add your organization and contact details. You'll review the complete Order Form before signing.

    +
    + +
    + {% csrf_token %} + + {% if builder.has_errors %} + + {% endif %} + + + + +
    +
    +
    +

    Choose your services

    +

    Choose a tier for each service you need. You can remove a service without losing your choices.

    + +
    + {{ form.agreements.errors }} + {% for slug, aform in builder.agreement_forms.items %} + {% if slug in builder.chosen %} + {% include "agreements/orders/_family.html" with show_errors=True %} + {% else %} + {% include "agreements/orders/_family.html" with show_errors=False %} + {% endif %} + {% endfor %} +
    + +
    +

    Choose your extras

    +

    Add extras for the services in your package, or continue without adding any.

    + + + {% for slug, aform in builder.agreement_forms.items %} + {% if slug in builder.chosen %} + {% include "agreements/orders/_extras.html" with show_errors=True %} + {% else %} + {% include "agreements/orders/_extras.html" with show_errors=False %} + {% endif %} + {% endfor %} +
    + +
    +

    Term and discount

    +

    Applies to every service in your package. Discounts apply to Service Tier fees only and don't combine. Multi-year terms are prepaid.

    +
    + Term and discount +
    + {% for discount in catalog.discounts %} + + {% endfor %} +
    +
    + {{ form.discount.errors }} +
    + + {{ form.discount_attestation.errors }} +
    +
    + + + +
    +

    Your organization

    +

    Details for the Order Form. Fields marked with an asterisk are required.

    + {% with compact_required=True %} +
    + Organization +
    + {% include "agreements/_field.html" with field=form.legal_name %} +
    + {% include "agreements/_field.html" with field=form.jurisdiction field_label="State or country of registration" %} + {% include "agreements/_field.html" with field=form.entity_type %} +
    +
    + {% include "agreements/_field.html" with field=form.address field_label="Principal business address" %} + {% include "agreements/_field.html" with field=form.covered_entities %} +
    +
    +
    + + {% endwith %} +
    + +
    +

    Contacts and billing

    +

    Add the people who can request services and receive billing notices. Fields marked with an asterisk are required.

    + {% with compact_required=True %} +
    + Authorized contacts +

    Who can request services? Add at least one person.

    + {% for name, email in form.contact_rows %} +
    + Contact {{ forloop.counter }} +
    + {% include "agreements/_field.html" with field=name %} + {% include "agreements/_field.html" with field=email %} +
    +
    + {% endfor %} + +
    + +
    + Billing and notices +
    +
    + {% include "agreements/_field.html" with field=form.billing_contact_name %} + {% include "agreements/_field.html" with field=form.billing_contact_email %} +
    + {% include "agreements/_field.html" with field=form.notices_contact %} +
    +
    + + {% if staff %} +
    + Staff contract adjustments + {% if form.customer_account_email %} + {% include "agreements/_field.html" with field=form.customer_account_email %} + {% endif %} + {% for slug, aform in builder.agreement_forms.items %} + {% if aform.special_terms %} +
    + Special terms for {{ aform.agreement.short_name }} + {% include "agreements/_field.html" with field=aform.special_terms %} +
    + {% endif %} + {% endfor %} +
    + {% endif %} + {% endwith %} + +
    +

    Agreements for this package

    +

    Each service is provided under its own published agreement with the Python Software Foundation. Your package goes on one Order Form that cites them, signed once.

    + +

    Choose a service to see its agreement here.

    +
    + + Cancel +
    +

    Next you'll see the complete Order Form with every fee. Nothing is binding until it's signed.

    +
    +
    + +
    + + +
    +
    +{% endblock %} + +{% block extra_js %}{{ builder.parameter_conditions|json_script:"order-parameter-conditions" }}{% endblock %} diff --git a/apps/agreements/templates/agreements/orders/order_list.html b/apps/agreements/templates/agreements/orders/order_list.html new file mode 100644 index 000000000..85afc30da --- /dev/null +++ b/apps/agreements/templates/agreements/orders/order_list.html @@ -0,0 +1,18 @@ +{% extends "agreements/orders/base.html" %} +{% block title %}Your orders{% endblock %} +{% block crumbs %} +
  • Programs>>>
  • +
  • Your orders
  • +{% endblock %} + +{% block content %} +
    +

    Your orders

    + {% if orders %} + {% include "agreements/orders/_order_table.html" %} + {% else %} +

    You haven't started an order yet. Nothing is binding until you sign.

    + {% endif %} +

    Browse programs

    +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/orders/overview.html b/apps/agreements/templates/agreements/orders/overview.html new file mode 100644 index 000000000..c78165e74 --- /dev/null +++ b/apps/agreements/templates/agreements/orders/overview.html @@ -0,0 +1,57 @@ +{% extends "agreements/orders/base.html" %} +{% load agreements %} +{% block title %}{{ program.title }}{% endblock %} +{% block crumbs %} +
  • Programs>>>
  • +
  • {{ program.title }}
  • +{% endblock %} + +{% block content %} +
    +

    {{ program.title }}

    + {% if program.description %}

    {{ program.description|linebreaksbr }}

    {% endif %} + {% if not program.is_public %}

    Private program — staff-prepared orders only.

    {% endif %} + {% if under_review %} +

    Draft terms. Some terms are under review and may change before they are offered.

    + {% endif %} + +
    +
    +

    {% if program.is_public %}Build your support package{% else %}Prepare a package for a customer{% endif %}

    +

    Choose a tier for {% if agreements|length > 1 %}one service or several{% else %}{{ agreements.0.short_name|default:"the service" }}{% endif %}, add any extras, and see your fees as you go. Everything goes on one Order Form, signed once.

    +
    + {% if program.is_public %}Build a package{% else %}Prepare a package{% endif %} +
    + + {% for agreement in agreements %} +
    +

    {{ agreement.short_name }}

    + {% if agreement.tagline %}

    {{ agreement.tagline }}

    {% endif %} +
    + + + + + {% for tier in agreement.tiers %} + + {% endfor %} + +
    {{ agreement.short_name }} service tiers
    Service tierAnnual feeCritical (P1) responseRequests a year
    {{ tier.name }}{{ tier.annual_fee|money }}{{ tier.response_targets.0 }}{{ tier.fair_use }}
    +
    + {% if agreement.services %} +

    Includes {% for service, tiers in agreement.service_rows %}{{ service.name }}{% if tiers %} ({{ tiers }} only){% endif %}{% if not forloop.last %}; {% endif %}{% endfor %}.

    + {% endif %} + {% if agreement.addons %} +

    Extras, priced separately: {% for addon in agreement.addons %}{{ addon.name }}{% if not forloop.last %}; {% endif %}{% endfor %}.

    + {% endif %} +

    Provided under the {{ agreement.title }}.

    +
    + {% endfor %} + + {% if orders %} +

    Your orders

    + {% include "agreements/orders/_order_table.html" %} +

    All your orders

    + {% endif %} +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/orders/program_list.html b/apps/agreements/templates/agreements/orders/program_list.html new file mode 100644 index 000000000..6b6a7c67b --- /dev/null +++ b/apps/agreements/templates/agreements/orders/program_list.html @@ -0,0 +1,20 @@ +{% extends "agreements/orders/base.html" %} +{% block title %}Programs{% endblock %} +{% block crumbs %}
  • Programs
  • {% endblock %} + +{% block content %} +
    +

    Programs

    + {% for program in programs %} +
    +

    {{ program.title }}

    + {% if program.description %}

    {{ program.description|linebreaksbr }}

    {% endif %} + {% if not program.is_public %}

    Private program — staff-prepared orders only.

    {% endif %} +

    Compare agreements

    +
    + {% empty %} +

    There are no programs available to browse.

    + {% if user.is_authenticated %}

    You can still view orders prepared for your account.

    {% endif %} + {% endfor %} +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/orders/staff_queue.html b/apps/agreements/templates/agreements/orders/staff_queue.html new file mode 100644 index 000000000..f3134ee6f --- /dev/null +++ b/apps/agreements/templates/agreements/orders/staff_queue.html @@ -0,0 +1,18 @@ +{% extends "agreements/orders/base.html" %} +{% block title %}All orders{% endblock %} +{% block crumbs %} +
  • Programs>>>
  • +
  • All orders
  • +{% endblock %} + +{% block content %} +
    +

    All orders

    +

    To prepare an order for a customer, choose a program. Signed Order Forms wait in the agreements queue for countersignature.

    + {% if orders %} + {% include "agreements/orders/_order_table.html" with show_owner=True %} + {% else %} +

    No orders have been prepared yet.

    + {% endif %} +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/queue.html b/apps/agreements/templates/agreements/queue.html new file mode 100644 index 000000000..1a047186a --- /dev/null +++ b/apps/agreements/templates/agreements/queue.html @@ -0,0 +1,38 @@ +{% extends "agreements/base.html" %} +{% load agreements %} +{% block title %}Agreements{% endblock %} + +{% block content %} +
    +
    +

    Agreements

    +
    +

    + New contract + Edit terms +

    + +

    To countersign ({{ to_countersign|length }})

    + {% include "agreements/_agreement_table.html" with agreements=to_countersign label="To countersign" empty="Nothing is waiting." %} + +

    Awaiting signature ({{ awaiting_signature|length }})

    + {% include "agreements/_agreement_table.html" with agreements=awaiting_signature label="Awaiting signature" empty="Nothing is out for signature." %} + + {% if drafts %} +

    Contract drafts

    +
    + + + + {% for contract in drafts %} + + {% endfor %} + +
    ContractCounterpartyUpdated
    {{ contract.title }}{{ contract.counterparty_name }}{{ contract.modified|date:"M j, Y" }}
    +
    + {% endif %} + +

    Recent

    + {% include "agreements/_agreement_table.html" with agreements=recent label="Recent agreements" empty="None yet." %} +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/sign_link.html b/apps/agreements/templates/agreements/sign_link.html new file mode 100644 index 000000000..7f5f334ce --- /dev/null +++ b/apps/agreements/templates/agreements/sign_link.html @@ -0,0 +1,51 @@ +{% extends "agreements/base.html" %} +{% block title %}{% if agreement %}Sign: {{ agreement.title }}{% else %}Signing link{% endif %}{% endblock %} +{% block crumbs %}
  • Sign
  • {% endblock %} +{% block content_attributes %}{% endblock %} +{% block left_sidebar %}{% endblock %} + +{% block content %} +
    + {% if unusable %} +

    This signing link no longer works

    +

    It has expired, has already been used, or the document was withdrawn. Ask your contact at the Python Software Foundation for a new link.

    + {% elif signed %} +

    Signed. Thank you.

    +

    {{ agreement.signer_name }} signed {{ agreement.title }} for {{ agreement.counterparty_name }} (reference {{ agreement.reference }}). The PSF will countersign and email the fully signed copy to {{ agreement.signer_email }}.

    + {% else %} +

    {{ agreement.title }}

    +

    For {{ agreement.counterparty_name }} · Reference {{ agreement.reference }} · Revision {{ agreement.revision }} · Download: PDF · DOCX

    + {% if metadata_hidden %}

    For the complete document, download the PDF or DOCX.

    {% endif %} +
    {{ html|safe }}
    + {% if agreement.terms_versions.exists %} +

    Terms included in this agreement

    + + {% endif %} + +
    +

    Sign

    +
    + {% csrf_token %} + {{ form.document_sha256 }} + {{ form.non_field_errors }} + + {{ form.accept.errors }} +
    + {% include "agreements/_field.html" with field=form.signer_name %} + {% include "agreements/_field.html" with field=form.signer_title %} +
    +
    +

    Signing as {{ link.email }}, the address this link was sent to.

    +

    Your name, title, email, the time, and your IP address are recorded with your signature and the exact text you sign.

    +
    + +
    +
    + {% endif %} +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/terms.html b/apps/agreements/templates/agreements/terms.html new file mode 100644 index 000000000..7a49f4f76 --- /dev/null +++ b/apps/agreements/templates/agreements/terms.html @@ -0,0 +1,46 @@ +{% extends "agreements/base.html" %} +{% block title %}{{ terms.title }}{% endblock %} +{% block crumbs %}
  • {{ terms.title }}
  • {% endblock %} + +{% block sidebar %} + +{% endblock %} + +{% block content %} +
    +
    +

    {{ terms.title }}

    +
    +

    + Version {{ shown.version }}, published {{ shown.published_at|date:"F j, Y" }} + Download: + {% if token %} + PDF · DOCX + {% else %} + PDF · DOCX + {% endif %} + {% if can_edit %} · Edit{% endif %} + +

    +

    + Permanent address: {{ shown.permanent_url }} +

    + + {% if not is_current %} +
    +

    This is an earlier version. It applies only to documents that cite it.{% if can_read_current %} Read the current version.{% endif %}

    +
    + {% endif %} + {% if terms.under_review %} +

    Draft for attorney review. This text may change before it is offered.

    + {% endif %} + +
    {{ html|safe }}
    + {% block after_terms %}{% endblock %} +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/terms_edit.html b/apps/agreements/templates/agreements/terms_edit.html new file mode 100644 index 000000000..6961afdf1 --- /dev/null +++ b/apps/agreements/templates/agreements/terms_edit.html @@ -0,0 +1,64 @@ +{% extends "agreements/base.html" %} +{% load agreements %} +{% block title %}Edit: {{ terms.title }}{% endblock %} +{% block crumbs %} +
  • Terms>>>
  • +
  • {{ terms.title }}
  • +{% endblock %} + +{% block content %} +
    +
    +

    Edit {{ terms.title }}

    +
    +

    Save a draft as often as you like; it stays private. Publishing makes it a new version at a permanent address, and every document offered from then on cites it. Documents already offered or signed keep citing their version.

    + +
    + {% csrf_token %} + {{ form.non_field_errors }} + {% include "agreements/_field.html" with field=form.markdown %} + {% if user|is_agreements_administrator %} + + + {{ form.is_public.errors }} + {% endif %} + +

    + + + Cancel +

    + + {% if user|is_agreements_administrator %} +
    + Publish as a new version +
    + {% include "agreements/_field.html" with field=form.version %} +
    + {% include "agreements/_field.html" with field=form.notes %} + +
    + {% endif %} +
    + + {% if preview %} +

    Preview

    +
    {{ preview|safe }}
    + {% endif %} + + {% if diff %} +

    Changes from version {{ current.version }}

    +
    {% for tag, line in diff %}{{ line }}
    +{% endfor %}
    + {% endif %} + +

    Published versions

    +
      + {% for version in versions %} +
    1. Version {{ version.version }}, {{ version.published_at|date:"M j, Y" }}{% if version.published_by %} by {{ version.published_by.email }}{% endif %}{% if version.notes %}: {{ version.notes }}{% endif %}
    2. + {% empty %} +
    3. None yet.
    4. + {% endfor %} +
    +
    +{% endblock %} diff --git a/apps/agreements/templates/agreements/terms_list.html b/apps/agreements/templates/agreements/terms_list.html new file mode 100644 index 000000000..d535df5cd --- /dev/null +++ b/apps/agreements/templates/agreements/terms_list.html @@ -0,0 +1,33 @@ +{% extends "agreements/base.html" %} +{% load agreements %} +{% block title %}Terms{% endblock %} +{% block crumbs %}
  • Agreements>>>
  • Terms
  • {% endblock %} + +{% block content %} +
    +
    +

    Terms

    +
    +

    Standing terms that documents cite. Publishing a new version changes them for every document offered afterwards; documents already offered or signed keep the version they cite. To change one counterparty's document only, edit that agreement instead.

    +
    + + + + {% for terms in all_terms %} + + + + + + + {% endfor %} + +
    TermsCurrent versionUnpublished draft
    {{ terms.title }}{% if terms.under_review %}Draft for attorney review{% endif %}{% with current=terms.current_version %}{% if current %}{{ current.version }}, {{ current.published_at|date:"M j, Y" }}{% else %}None{% endif %}{% endwith %}{% if terms.draft_markdown %}Saved {{ terms.draft_updated_at|date:"M j, Y" }}{% else %}None{% endif %}Edit
    +
    + {% if user.is_staff and user|is_agreements_administrator %} +

    Add a new set of terms in the admin, then write and publish it here.

    + {% else %} +

    Ask an administrator to create new terms or publish your draft.

    + {% endif %} +
    +{% endblock %} diff --git a/apps/agreements/templatetags/__init__.py b/apps/agreements/templatetags/__init__.py new file mode 100644 index 000000000..2dadf4a6f --- /dev/null +++ b/apps/agreements/templatetags/__init__.py @@ -0,0 +1 @@ +"""Template tags for agreement pages.""" diff --git a/apps/agreements/templatetags/agreements.py b/apps/agreements/templatetags/agreements.py new file mode 100644 index 000000000..b47853f57 --- /dev/null +++ b/apps/agreements/templatetags/agreements.py @@ -0,0 +1,27 @@ +"""Template helpers for agreement pages.""" + +from django import template + +from apps.agreements.auth import can_prepare, is_administrator +from apps.agreements.orders.pricing import money + +register = template.Library() +register.filter("money", money) +register.filter("can_prepare_agreements", can_prepare) +register.filter("is_agreements_administrator", is_administrator) + +STATUSES: dict[str, tuple[str, str]] = { + "draft": ("neutral", "Draft"), + "offered": ("notice", "Awaiting signature"), + "signed": ("notice", "Awaiting countersignature"), + "executed": ("success", "Active"), + "declined": ("error", "Declined"), + "withdrawn": ("neutral", "Withdrawn"), +} + + +@register.inclusion_tag("agreements/_status.html") +def status_badge(status: str) -> dict[str, str]: + """Status pill for an agreement or a draft; an executed agreement reads as Active.""" + tone, label = STATUSES[status] + return {"tone": tone, "label": label} diff --git a/apps/agreements/tests/__init__.py b/apps/agreements/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/agreements/tests/catalog_data.py b/apps/agreements/tests/catalog_data.py new file mode 100644 index 000000000..83c3a7dcf --- /dev/null +++ b/apps/agreements/tests/catalog_data.py @@ -0,0 +1,217 @@ +"""Fictional catalog data shared by agreement and order behavior tests.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from apps.agreements.models import Program + + +def _tier(key: str, name: str, fee: str, fair_use: int) -> dict[str, Any]: + return { + "key": key, + "name": name, + "annual_fee": fee, + "response_targets": ["1 day", "2 days", "3 days", "4 days"], + "fair_use": fair_use, + } + + +def _fixed(amount: str, recurring: bool = True) -> dict[str, Any]: + return {"type": "fixed", "amount": amount, "recurring": recurring} + + +def _quantity(parameter: str, amount: str, **kwargs: Any) -> dict[str, Any]: + return {"type": "quantity", "parameter": parameter, "unit_amount": amount, "recurring": False, **kwargs} + + +def _addon( + key: str, name: str, pricing: dict[str, Any], params: list[dict[str, Any]] | None = None, **kwargs: Any +) -> dict[str, Any]: + return { + "key": key, + "name": name, + "description": f"Example {name.lower()} service.", + "price_summary": "See the example fee schedule.", + "pricing": pricing, + "params": params or [], + **kwargs, + } + + +CATALOG_DATA: dict[str, Any] = { + "order_title": "Example service order", + "order_intro": "Select the example services for your organization.", + "covered_entities_label": "Covered workspaces", + "covered_entities_help": "Enter one workspace per line.", + "attestation_label": "I confirm eligibility for the community discount.", + "attestation_help": "Available to eligible community groups.", + "payment": { + "annual": "Example annual fees are payable in advance.", + "multi_year": "Example fees for the initial term are payable in advance.", + "renewal": "Renewal follows the example agreement terms.", + "other_charges": "Other example charges are invoiced separately.", + }, + "discounts": [ + {"key": "none", "name": "No discount", "percent": 0, "term_months": 12}, + {"key": "prepaid", "name": "Prepaid term", "percent": 12, "term_months": 36}, + { + "key": "eligible", + "name": "Community discount", + "percent": 25, + "term_months": 12, + "requires_attestation": True, + }, + ], + "agreements": [ + { + "slug": "studio", + "title": "Example Studio Services", + "short_name": "Studio", + "tagline": "Example services for a shared studio.", + "terms_slug": "studio-terms", + "default_tier": "basic", + "tiers": [ + _tier("basic", "Basic", "1200", 8), + _tier("plus", "Plus", "2400", 16), + _tier("max", "Max", "4800", 32), + ], + "services": [ + {"key": "access", "name": "Studio access", "description": "Access to shared studio resources."}, + { + "key": "planning", + "name": "Studio planning", + "description": "Scheduled planning assistance.", + "tiers": ["plus", "max"], + }, + ], + "addons": [], + }, + { + "slug": "workshop", + "title": "Example Workshop Services", + "short_name": "Workshop", + "tagline": "Example services for collaborative workshops.", + "terms_slug": "workshop-terms", + "default_tier": "basic", + "tiers": [ + _tier("basic", "Basic", "1800", 8), + _tier("plus", "Plus", "3600", 16), + _tier("max", "Max", "7200", 32), + ], + "services": [ + {"key": "materials", "name": "Workshop materials", "description": "Shared example materials."}, + { + "key": "planning", + "name": "Workshop planning", + "description": "Dedicated planning assistance.", + "tiers": ["max"], + }, + ], + "addons": [ + _addon("kit", "Resource kit", _fixed("135")), + _addon("advisor", "Workshop advisor", _fixed("210"), included_in_tiers=["max"]), + _addon( + "labels", + "Workspace labels", + _quantity("names", "7", recurring=True, base_amount="42", included=3), + [{"key": "names", "label": "Workspace names", "kind": "lines"}], + ), + _addon( + "batch", + "Batch preparation", + { + "type": "brackets", + "parameter": "units", + "recurring": False, + "bands": [{"up_to": 8, "amount": "95"}, {"up_to": 30, "amount": "185"}], + "overflow_basis": "Quoted separately above 30 units", + }, + [{"key": "units", "label": "Units", "kind": "count"}], + ), + _addon( + "reports", + "Activity summaries", + { + "type": "choice", + "parameter": "mode", + "options": { + "periodic": _fixed("84"), + "single": _quantity("copies", "19"), + }, + }, + [ + { + "key": "mode", + "label": "Delivery", + "kind": "choice", + "choices": [["periodic", "Periodic"], ["single", "Individual"]], + }, + {"key": "copies", "label": "Copies", "kind": "count", "required_when": {"mode": "single"}}, + ], + ), + _addon( + "sessions", + "Practice sessions", + _quantity("hours", "37.50"), + [{"key": "hours", "label": "Hours", "kind": "count"}], + ), + _addon( + "bundles", + "Material bundles", + { + "type": "sum", + "recurring": False, + "components": [_quantity("small", "23"), _quantity("large", "61")], + }, + [ + {"key": "small", "label": "Small bundles", "kind": "count", "minimum": 0}, + {"key": "large", "label": "Large bundles", "kind": "count", "minimum": 0}, + ], + ), + _addon( + "schedule", + "Custom schedule", + _fixed("315"), + [{"key": "window", "label": "Schedule", "kind": "text"}], + ), + _addon( + "custom", + "Custom project", + { + "type": "unpriced", + "recurring": False, + "basis": "Quoted separately", + "detail": "Scope agreed before work begins", + }, + ), + ], + }, + ], +} + + +def make_program(*, is_public: bool = True) -> Program: + """Create a program and published terms; each caller gets independent JSON data.""" + from apps.agreements.models import Program, Terms, TermsVersion + + definition = deepcopy(CATALOG_DATA) + for agreement in definition["agreements"]: + terms, _ = Terms.objects.get_or_create( + slug=agreement["terms_slug"], + defaults={"title": agreement["title"], "is_public": is_public}, + ) + if not terms.versions.exists(): + TermsVersion.objects.create( + terms=terms, + version="example-1", + markdown="## Example terms\n\nThese are fictional test terms.\n", + ) + return Program.objects.create( + slug="sample-services", + title="Example Services", + is_public=is_public, + definition=definition, + ) diff --git a/apps/agreements/tests/test_agreements.py b/apps/agreements/tests/test_agreements.py new file mode 100644 index 000000000..196a4adb6 --- /dev/null +++ b/apps/agreements/tests/test_agreements.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import datetime +import re +from typing import TYPE_CHECKING, Any, cast + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.core import mail +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client, TestCase +from django.urls import reverse +from django.utils import timezone + +from apps.agreements import documents, workflow +from apps.agreements.auth import ADMINISTRATORS +from apps.agreements.documents import SIGNATURES +from apps.agreements.kinds import CustomContractKind +from apps.agreements.models import Agreement, AgreementRevision, CustomContract, SigningLink, Terms, TermsVersion + +if TYPE_CHECKING: + from django.test.client import _MonkeyPatchedWSGIResponse + + from apps.users.models import User as UserModel + +User = get_user_model() +PDF = b"%PDF-1.4 signed copy" + + +def make_officer(username: str = "pat") -> UserModel: + officer = User.objects.create_user(username, f"{username}@example.org", "password") + officer.groups.add(Group.objects.get_or_create(name=ADMINISTRATORS)[0]) + return officer + + +def make_terms(slug: str = "pycon-venue", markdown: str = "## 1. Scope\n\nThe venue hosts PyCon US.\n") -> Terms: + terms = Terms.objects.create(slug=slug, title="PyCon Venue Terms") + TermsVersion.objects.create(terms=terms, version="2026-01-01", markdown=markdown) + return terms + + +def offer_contract(officer: UserModel, **overrides: Any) -> Agreement: + fields = { + "title": "PyCon US 2027 Venue Agreement", + "counterparty_name": "Example Convention Center, LLC", + "body_markdown": "## 1. Dates\n\nMay 12 to May 20, 2027.", + "created_by": officer, + **overrides, + } + contract = CustomContract.objects.create(**fields) + return workflow.offer(CustomContractKind(), contract, user=officer) + + +def refresh(agreement: Agreement) -> Agreement: + return Agreement.objects.get(pk=agreement.pk) + + +class OfferAndEditTests(TestCase): + def setUp(self) -> None: + self.officer = make_officer() + self.client.force_login(self.officer) + + def test_offer_freezes_the_text_as_revision_one_and_cites_current_terms(self) -> None: + terms = make_terms() + agreement = offer_contract(self.officer, terms=terms) + self.assertEqual(agreement.status, Agreement.Status.OFFERED) + self.assertEqual(agreement.document_sha256, documents.sha256(agreement.document_markdown)) + self.assertEqual(list(agreement.terms_versions.all()), [terms.current_version]) + self.assertIn(cast("TermsVersion", terms.current_version).permanent_url, agreement.document_markdown) + self.assertEqual(agreement.revisions.get().revision, 1) + + CustomContract.objects.filter(agreement=agreement).update(body_markdown="Changed after offer") + self.assertNotIn("Changed after offer", refresh(agreement).document_markdown) + + def test_staff_edit_is_a_new_revision_and_keeps_the_old_text(self) -> None: + agreement = offer_contract(self.officer) + edited = agreement.document_markdown.replace("May 20", "May 21") + response = self.client.post( + reverse("agreements:edit", args=[agreement.pk]), + {"markdown": edited, "note": "One more day", "base_sha256": agreement.document_sha256}, + ) + self.assertRedirects(response, agreement.get_absolute_url()) + agreement = refresh(agreement) + self.assertEqual(agreement.revision, 2) + self.assertIn("May 21", agreement.document_markdown) + first = AgreementRevision.objects.get(agreement=agreement, revision=1) + self.assertIn("May 20", first.markdown) + self.assertEqual(agreement.revisions.get(revision=2).note, "One more day") + + def test_edit_from_a_stale_copy_is_refused(self) -> None: + agreement = offer_contract(self.officer) + stale = agreement.document_sha256 + workflow.edit( + agreement, + markdown=agreement.document_markdown + "\nFirst edit.", + note="a", + user=self.officer, + base_sha256=stale, + ) + with self.assertRaises(workflow.DocumentChangedError): + workflow.edit( + agreement, + markdown=agreement.document_markdown + "\nLost edit.", + note="b", + user=self.officer, + base_sha256=stale, + ) + + def test_edit_must_keep_the_signature_block(self) -> None: + agreement = offer_contract(self.officer) + response = self.client.post( + reverse("agreements:edit", args=[agreement.pk]), + { + "markdown": agreement.document_markdown.replace(SIGNATURES, ""), + "note": "oops", + "base_sha256": agreement.document_sha256, + }, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(refresh(agreement).revision, 1) + + def test_signed_agreements_cannot_be_edited(self) -> None: + agreement = offer_contract(self.officer) + workflow.record_signed_copy( + agreement, + workflow.Signature("Grace", "CTO", "grace@example.com", signed_on=timezone.localdate()), + upload=workflow.Upload("s.pdf", PDF), + user=self.officer, + seen_sha256=agreement.document_sha256, + ) + response = self.client.get(reverse("agreements:edit", args=[agreement.pk])) + self.assertRedirects(response, agreement.get_absolute_url()) + + def test_staff_written_text_cannot_embed_images(self) -> None: + contract = CustomContract.objects.create( + title="T", counterparty_name="C", body_markdown="![x](/etc/passwd)", created_by=self.officer + ) + markdown = documents.preview_markdown(CustomContractKind().compose(contract), "C") + for render in (documents.render_pdf, documents.render_docx): + with self.subTest(render=render.__name__), self.assertRaises(RuntimeError): + render(markdown) + + def test_withdrawing_returns_the_draft_for_editing(self) -> None: + agreement = offer_contract(self.officer) + contract = cast("CustomContract", agreement.subject) + self.client.post(reverse("agreements:withdraw", args=[agreement.pk])) + self.assertEqual(refresh(agreement).status, Agreement.Status.WITHDRAWN) + contract.refresh_from_db() + self.assertIsNone(contract.agreement) + again = workflow.offer(CustomContractKind(), contract, user=self.officer) + self.assertNotEqual(again.pk, agreement.pk) + + +class SigningLinkTests(TestCase): + def setUp(self) -> None: + self.officer = make_officer() + self.agreement = offer_contract(self.officer) + + def send_link(self) -> str: + self.client.force_login(self.officer) + self.client.post( + reverse("agreements:send_link", args=[self.agreement.pk]), + {"name": "Grace Hopper", "email": "grace@example.com"}, + ) + self.client.logout() + return cast( + "re.Match[str]", re.search(r"/agreements/sign/([^/\s]+)/", cast("str", mail.outbox[-1].body)) + ).group(1) + + def sign(self, token: str, **overrides: Any) -> _MonkeyPatchedWSGIResponse: + data = { + "signer_name": "Grace Hopper", + "signer_title": "General Manager", + "accept": "on", + "document_sha256": refresh(self.agreement).document_sha256, + **overrides, + } + return self.client.post(reverse("agreements:sign_link", args=[token]), data) + + def test_emailed_link_signs_as_the_address_it_was_sent_to(self) -> None: + token = self.send_link() + self.assertEqual(mail.outbox[-1].to, ["grace@example.com"]) + self.assertFalse(SigningLink.objects.filter(token_sha256=token).exists()) + + page = self.client.get(reverse("agreements:sign_link", args=[token])) + self.assertContains(page, "Example Convention Center") + + self.sign(token, signer_name="Grace Hopper") + agreement = refresh(self.agreement) + self.assertEqual(agreement.status, Agreement.Status.SIGNED) + self.assertEqual(agreement.signature_method, Agreement.SignatureMethod.LINK) + self.assertEqual(agreement.signer_email, "grace@example.com") + + def test_link_form_passes_csrf_checks_as_a_browser_sends_it(self) -> None: + token = self.send_link() + browser = Client(enforce_csrf_checks=True) + url = reverse("agreements:sign_link", args=[token]) + page = browser.get(url) + # Browsers send "Origin: null" on a form POST from a page with "no-referrer". + origin = "null" if page["Referrer-Policy"] == "no-referrer" else "http://testserver" + browser.post( + url, + { + "csrfmiddlewaretoken": page.context["csrf_token"], + "signer_name": "Grace Hopper", + "signer_title": "General Manager", + "accept": "on", + "document_sha256": self.agreement.document_sha256, + }, + HTTP_ORIGIN=origin, + ) + self.assertEqual(refresh(self.agreement).status, Agreement.Status.SIGNED) + + def test_link_works_once(self) -> None: + token = self.send_link() + self.sign(token) + self.assertEqual(self.client.get(reverse("agreements:sign_link", args=[token])).status_code, 410) + + def test_expired_link_does_not_work(self) -> None: + token = self.send_link() + SigningLink.objects.update(expires_at=timezone.now() - datetime.timedelta(minutes=1)) + self.assertEqual(self.sign(token).status_code, 410) + self.assertEqual(refresh(self.agreement).status, Agreement.Status.OFFERED) + + def test_signature_is_refused_if_the_text_changed_after_the_signer_opened_it(self) -> None: + token = self.send_link() + seen = self.agreement.document_sha256 + workflow.edit( + self.agreement, + markdown=self.agreement.document_markdown + "\nNew clause.", + note="edit", + user=self.officer, + base_sha256=seen, + ) + self.sign(token, document_sha256=seen) + self.assertEqual(refresh(self.agreement).status, Agreement.Status.OFFERED) + self.sign(token) + self.assertEqual(refresh(self.agreement).status, Agreement.Status.SIGNED) + + def test_withdrawn_offer_disables_its_links(self) -> None: + token = self.send_link() + workflow.withdraw(self.agreement, user=self.officer) + self.assertEqual(self.client.get(reverse("agreements:sign_link", args=[token])).status_code, 410) + + +class SignedCopyAndCountersignTests(TestCase): + def setUp(self) -> None: + self.officer = make_officer() + self.other = User.objects.create_user("eve", "eve@example.com", "password") + self.agreement = offer_contract(self.officer) + self.client.force_login(self.officer) + + def record(self, content: bytes = PDF, **overrides: Any) -> _MonkeyPatchedWSGIResponse: + data = { + "signed_copy": SimpleUploadedFile("signed.pdf", content, content_type="application/pdf"), + "signer_name": "Grace Hopper", + "signer_title": "CTO", + "signer_email": "grace@example.com", + "signed_on": "2026-09-01", + "matches": "on", + "document_sha256": self.agreement.document_sha256, + **overrides, + } + return self.client.post(reverse("agreements:record_copy", args=[self.agreement.pk]), data) + + def test_signed_copy_is_kept_and_cited_in_the_record(self) -> None: + self.record() + agreement = refresh(self.agreement) + self.assertEqual(agreement.signature_method, Agreement.SignatureMethod.OFFLINE) + self.assertEqual(cast("datetime.datetime", agreement.signed_at).date(), datetime.date(2026, 9, 1)) + text = documents.final_markdown(agreement) + self.assertIn("Signed copy on file", text) + + download = reverse("agreements:copy_download", args=[agreement.pk, "customer"]) + self.assertEqual(self.client.get(download).content, PDF) + self.client.force_login(self.other) + self.assertEqual(self.client.get(download).status_code, 404) + + def test_only_pdfs_are_accepted(self) -> None: + self.assertEqual(self.record(content=b"").status_code, 400) + self.assertEqual(refresh(self.agreement).status, Agreement.Status.OFFERED) + + def test_only_the_psf_countersigns_and_the_signatory_gets_the_executed_copy(self) -> None: + self.record() + url = reverse("agreements:countersign", args=[self.agreement.pk]) + payload = {"name": "Pat Officer", "title": "Executive Director", "accept": "on"} + self.client.force_login(self.other) + self.client.post(url, payload) + self.assertEqual(refresh(self.agreement).status, Agreement.Status.SIGNED) + + self.client.force_login(self.officer) + self.client.post(url, payload) + self.assertEqual(refresh(self.agreement).status, Agreement.Status.EXECUTED) + email = mail.outbox[-1] + self.assertEqual(email.to, ["grace@example.com"]) + self.assertTrue(email.attachments[0][1].startswith(b"%PDF")) + + def test_agreements_are_private(self) -> None: + self.client.force_login(self.other) + self.assertEqual(self.client.get(self.agreement.get_absolute_url()).status_code, 404) + + +class TermsPublishingTests(TestCase): + def setUp(self) -> None: + self.officer = make_officer() + self.client.force_login(self.officer) + self.terms = make_terms() + self.url = reverse("agreements:terms_edit", args=[self.terms.slug]) + + def publish( + self, markdown: str, version: str = "2026-06-01", notes: str = "Longer load-in window" + ) -> _MonkeyPatchedWSGIResponse: + return self.client.post( + self.url, {"markdown": markdown, "version": version, "notes": notes, "action": "publish"} + ) + + def test_new_version_applies_only_to_documents_offered_afterwards(self) -> None: + before = offer_contract(self.officer, terms=self.terms) + self.publish("## 1. Scope\n\nThe venue hosts PyCon US and its sprints.\n") + self.terms = Terms.objects.get(pk=self.terms.pk) + after = offer_contract(self.officer, terms=self.terms) + + self.assertEqual(before.terms_versions.get().version, "2026-01-01") + self.assertEqual(after.terms_versions.get().version, "2026-06-01") + old = self.client.get(reverse("agreements:terms_version", args=[self.terms.slug, "2026-01-01"])) + self.assertContains(old, "earlier version") + self.assertNotContains(old, "sprints") + + def test_drafts_stay_private_until_published(self) -> None: + self.client.post(self.url, {"markdown": "## 1. Scope\n\nSecret draft.\n", "action": "save"}) + self.assertEqual(Terms.objects.get(pk=self.terms.pk).draft_markdown, "## 1. Scope\n\nSecret draft.\n") + self.client.logout() + response = self.client.get(self.terms.get_absolute_url()) + self.assertEqual(response.status_code, 404) + self.assertNotIn(b"Secret draft", response.content) + + def test_version_labels_are_never_reused_and_versions_never_change(self) -> None: + response = self.publish("Different text\n", version="2026-01-01") + self.assertEqual(response.status_code, 400) + version = cast("TermsVersion", self.terms.current_version) + version.markdown = "Rewritten" + with self.assertRaises(ValueError): + version.save() + + +class TermsAccessTests(TestCase): + def setUp(self) -> None: + self.officer = make_officer() + self.customer = User.objects.create_user("customer", "customer@example.org", "password") + self.stranger = User.objects.create_user("stranger", "stranger@example.org", "password") + self.terms = make_terms() + self.version = cast("TermsVersion", self.terms.current_version) + self.agreement = offer_contract(self.officer, terms=self.terms, counterparty_account=self.customer) + + def test_private_terms_only_expose_versions_cited_to_the_counterparty(self) -> None: + newer = TermsVersion.objects.create( + terms=self.terms, version="2026-08-01", markdown="## New confidential terms\n" + ) + url = self.version.get_absolute_url() + self.assertEqual(self.client.get(url).status_code, 404) + self.client.force_login(self.stranger) + self.assertEqual(self.client.get(url).status_code, 404) + self.client.force_login(self.customer) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn("no-store", response["Cache-Control"]) + self.assertEqual(self.client.get(newer.get_absolute_url()).status_code, 404) + download = reverse("agreements:terms_version_download", args=[self.terms.slug, self.version.version, "docx"]) + self.assertEqual(self.client.get(download).status_code, 200) + self.client.force_login(self.stranger) + self.assertEqual(self.client.get(download).status_code, 404) + + def test_publication_is_independent_of_version_creation(self) -> None: + url = self.version.get_absolute_url() + self.assertEqual(self.client.get(url).status_code, 404) + self.terms.is_public = True + self.terms.save(update_fields=["is_public"]) + self.assertContains(self.client.get(url), self.terms.title) + + def test_signing_link_grants_only_its_cited_terms_until_consumed(self) -> None: + _, token = workflow.create_signing_link( + self.agreement, name="Customer", email=self.customer.email, user=self.officer + ) + url = reverse("agreements:sign_link_terms", args=[token, self.version.pk]) + self.assertContains(self.client.get(reverse("agreements:sign_link", args=[token])), url) + self.assertContains(self.client.get(url), self.terms.title) + unrelated = cast("TermsVersion", make_terms(slug="another-contract").current_version) + self.assertEqual( + self.client.get(reverse("agreements:sign_link_terms", args=[token, unrelated.pk])).status_code, 404 + ) + download = reverse("agreements:sign_link_terms_download", args=[token, self.version.pk, "docx"]) + self.assertEqual(self.client.get(download).status_code, 200) + workflow.sign_with_link( + cast("SigningLink", workflow.find_link(token)), + workflow.Signature("Customer", "Director", self.customer.email), + seen_sha256=self.agreement.document_sha256, + ) + self.assertEqual(self.client.get(url).status_code, 404) + self.assertEqual(self.client.get(download).status_code, 404) + + def test_preview_does_not_publish_terms(self) -> None: + self.client.force_login(self.officer) + self.client.post( + reverse("agreements:terms_edit", args=[self.terms.slug]), + {"markdown": "## Preview\n", "preview": "1", "is_public": "on"}, + ) + self.terms.refresh_from_db() + self.assertFalse(self.terms.is_public) diff --git a/apps/agreements/tests/test_authoring_validation.py b/apps/agreements/tests/test_authoring_validation.py new file mode 100644 index 000000000..6aa599304 --- /dev/null +++ b/apps/agreements/tests/test_authoring_validation.py @@ -0,0 +1,117 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.test import TestCase +from django.urls import reverse + +from apps.agreements.auth import ADMINISTRATORS +from apps.agreements.models import Agreement, CustomContract, Terms +from apps.agreements.tests.catalog_data import make_program +from apps.agreements.tests.test_agreements import make_officer, offer_contract +from apps.agreements.tests.test_orders import make_order + + +class AuthoringValidationTests(TestCase): + def setUp(self) -> None: + self.officer = make_officer() + self.client.force_login(self.officer) + + def test_terms_images_are_rejected_before_save_preview_or_publish(self) -> None: + terms = Terms.objects.create(slug="example", title="Example", draft_markdown="Original draft") + url = reverse("agreements:terms_edit", args=[terms.slug]) + for action in ({"action": "save"}, {"preview": "1"}, {"action": "publish", "version": "v1", "notes": "First"}): + with self.subTest(action=action): + response = self.client.post(url, {"markdown": "![image](https://example.com/image.png)", **action}) + self.assertEqual(response.status_code, 400) + self.assertIn("markdown", response.context["form"].errors) + terms.refresh_from_db() + self.assertEqual(terms.draft_markdown, "Original draft") + self.assertFalse(terms.versions.exists()) + + def test_custom_contract_images_are_rejected_before_create_or_edit(self) -> None: + data = {"title": "Example", "counterparty_name": "Example company", "body_markdown": "![image](/etc/passwd)"} + response = self.client.post(reverse("agreements:custom_create"), data) + self.assertIn("body_markdown", response.context["form"].errors) + self.assertFalse(CustomContract.objects.exists()) + contract = CustomContract.objects.create( + title="Example", + counterparty_name="Example company", + body_markdown="Original draft", + created_by=self.officer, + ) + response = self.client.post(reverse("agreements:custom_edit", args=[contract.pk]), data) + self.assertIn("body_markdown", response.context["form"].errors) + contract.refresh_from_db() + self.assertEqual(contract.body_markdown, "Original draft") + + def test_document_images_are_rejected_without_a_revision(self) -> None: + agreement = offer_contract(self.officer) + response = self.client.post( + reverse("agreements:edit", args=[agreement.pk]), + { + "markdown": agreement.document_markdown + "\n![image](/etc/passwd)", + "note": "Unsupported image", + "base_sha256": agreement.document_sha256, + }, + ) + self.assertEqual(response.status_code, 400) + self.assertIn("markdown", response.context["form"].errors) + agreement.refresh_from_db() + self.assertEqual(agreement.revision, 1) + + def test_unpublished_terms_link_to_editor_and_non_admin_guidance(self) -> None: + terms = Terms.objects.create(slug="unpublished", title="Unpublished") + response = self.client.get(reverse("agreements:terms_list")) + self.assertContains(response, f'href="{reverse("agreements:terms_edit", args=[terms.slug])}">Unpublished') + self.assertNotContains(response, reverse("admin:agreements_terms_add")) + + def test_staff_administrator_gets_terms_creation_link(self) -> None: + admin = get_user_model().objects.create_superuser("admin", "admin@example.com", "password") + admin.groups.add(Group.objects.get_or_create(name=ADMINISTRATORS)[0]) + self.client.force_login(admin) + response = self.client.get(reverse("agreements:terms_list")) + self.assertContains(response, reverse("admin:agreements_terms_add")) + + def test_staff_authoring_routes_deny_customers_without_a_login_loop(self) -> None: + terms = Terms.objects.create(slug="private", title="Private") + contract = CustomContract.objects.create( + title="Example", counterparty_name="Example company", body_markdown="Draft", created_by=self.officer + ) + routes = [ + reverse("agreements:terms_list"), + reverse("agreements:terms_edit", args=[terms.slug]), + reverse("agreements:custom_create"), + *( + reverse(f"agreements:custom_{action}", args=[contract.pk]) + for action in ("detail", "edit", "offer", "delete") + ), + ] + customer = get_user_model().objects.create_user("customer", "customer@example.com", "password") + self.client.force_login(customer) + for url in routes: + with self.subTest(url=url, authenticated=True): + self.assertEqual(self.client.get(url).status_code, 403) + self.assertEqual(self.client.post(url).status_code, 403) + self.client.logout() + for url in routes: + with self.subTest(url=url, authenticated=False): + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertIn("/accounts/login/?next=", response["Location"]) + + def test_record_admin_rejects_changes_even_for_superusers(self) -> None: + agreement = offer_contract(self.officer) + order = make_order(self.officer, program=make_program()) + user = get_user_model().objects.create_superuser("admin", "admin@example.com", "password") + user.groups.add(Group.objects.get_or_create(name=ADMINISTRATORS)[0]) + self.client.force_login(user) + for instance in (agreement, order): + with self.subTest(model=type(instance).__name__): + model_name = type(instance).__name__.lower() + url = reverse(f"admin:agreements_{model_name}_change", args=[instance.pk]) + self.assertNotContains(self.client.get(url), 'name="_save"') + response = self.client.post(url, {"status": "declined", "legal_name": "Changed"}) + self.assertEqual(response.status_code, 403) + agreement.refresh_from_db() + order.refresh_from_db() + self.assertEqual(agreement.status, Agreement.Status.OFFERED) + self.assertEqual(order.legal_name, "Example Workshops, Inc.") diff --git a/apps/agreements/tests/test_customer_link_security.py b/apps/agreements/tests/test_customer_link_security.py new file mode 100644 index 000000000..6302861e5 --- /dev/null +++ b/apps/agreements/tests/test_customer_link_security.py @@ -0,0 +1,242 @@ +"""Account linkage is administered separately from draft preparation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.test import TestCase +from django.urls import reverse + +from apps.agreements import documents as agreement_documents +from apps.agreements.auth import ADMINISTRATORS, EDITORS +from apps.agreements.models import Agreement, CustomContract, Order +from apps.agreements.orders import documents +from apps.agreements.tests.catalog_data import make_program +from apps.agreements.tests.test_orders import make_order, payload, reload + +if TYPE_CHECKING: + from django.test.client import _MonkeyPatchedWSGIResponse + + from apps.agreements.models import Program + from apps.users.models import User + + +class CustomerLinkSecurityTests(TestCase): + administrator: User + editor: User + customer: User + accomplice: User + program: Program + order: Order + contract: CustomContract + + @classmethod + def setUpTestData(cls) -> None: + users = get_user_model().objects + cls.administrator = users.create_user("administrator", "administrator@example.org") + cls.administrator.groups.add(Group.objects.get_or_create(name=ADMINISTRATORS)[0]) + cls.editor = users.create_user("editor", "editor@example.org") + cls.editor.groups.add(Group.objects.get_or_create(name=EDITORS)[0]) + cls.customer = users.create_user("customer", "customer@example.org") + cls.accomplice = users.create_user("accomplice", "accomplice@example.org") + cls.program = make_program(is_public=False) + cls.order = make_order(cls.administrator, program=cls.program, customer_account=cls.customer) + cls.contract = CustomContract.objects.create( + title="Example contract", + counterparty_name="Example Company", + body_markdown="Original draft.", + counterparty_account=cls.customer, + created_by=cls.administrator, + ) + + def assert_link_field(self, url: str, name: str, *, shown: bool) -> _MonkeyPatchedWSGIResponse: + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIs(name in response.context["form"].fields, shown) + (self.assertContains if shown else self.assertNotContains)(response, f'name="{name}"') + return response + + def assert_order_actions_denied(self, order: Order) -> None: + for user in (self.editor, self.accomplice): + with self.subTest(actor=user.username): + self.client.force_login(user) + self.assertFalse(order.is_customer(user)) + self.assertFalse(order.can_offer(user)) + for action in ("order_offer", "order_sign"): + response = self.client.post(reverse(f"agreements:{action}", args=[order.pk])) + self.assertEqual(response.status_code, 404) + self.assertIsNone(reload(order).agreement) + + def contract_data(self, **overrides: Any) -> dict[str, Any]: + return { + "title": "Updated example contract", + "counterparty_name": "Updated Example Company", + "body_markdown": "Updated draft.", + **overrides, + } + + def test_editor_created_orders_stay_unlinked_with_or_without_forged_accounts(self) -> None: + url = reverse("agreements:order_create", args=[self.program.slug]) + for public in (False, True): + self.program.is_public = public + self.program.save(update_fields=["is_public"]) + for email in (None, "", self.editor.email, self.accomplice.email): + with self.subTest(public=public, email=email): + self.client.force_login(self.editor) + self.assert_link_field(url, "customer_account_email", shown=False) + data = payload(["studio"], **{"studio-special_terms": "Example payment terms."}) + if email is not None: + data["customer_account_email"] = email + existing = set(Order.objects.values_list("pk", flat=True)) + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302) + created = Order.objects.exclude(pk__in=existing).get() + self.assertEqual(created.created_by, self.editor) + self.assertIsNone(created.customer_account) + self.assertEqual(created.agreements.get().special_terms, "Example payment terms.") + self.assert_order_actions_denied(created) + + def test_editor_cannot_assign_reassign_or_remove_customer_on_edit(self) -> None: + for original in (None, self.customer): + order = make_order(self.administrator, program=self.program, customer_account=original) + url = reverse("agreements:order_edit", args=[order.pk]) + for email in (None, "", self.editor.email, self.accomplice.email): + with self.subTest(original=original, email=email): + self.client.force_login(self.editor) + self.assert_link_field(url, "customer_account_email", shown=False) + data = payload( + ["studio"], + legal_name="Revised company", + **{"studio-special_terms": "Revised payment terms."}, + ) + if email is not None: + data["customer_account_email"] = email + self.assertEqual(self.client.post(url, data).status_code, 302) + order = reload(order) + self.assertEqual(order.customer_account, original) + self.assertEqual(order.created_by, self.administrator) + self.assertEqual(order.legal_name, "Revised company") + self.assertEqual(order.agreements.get().special_terms, "Revised payment terms.") + self.assert_order_actions_denied(order) + if original: + self.assertTrue(order.is_customer(original)) + self.assertTrue(order.can_offer(original)) + + def test_administrator_can_assign_reassign_and_remove_order_linkage(self) -> None: + self.client.force_login(self.administrator) + create_url = reverse("agreements:order_create", args=[self.program.slug]) + self.assert_link_field(create_url, "customer_account_email", shown=True) + response = self.client.post(create_url, payload(["studio"], customer_account_email=self.customer.email)) + self.assertEqual(response.status_code, 302) + order = Order.objects.exclude(pk=self.order.pk).get() + self.assertEqual(order.customer_account, self.customer) + edit_url = reverse("agreements:order_edit", args=[order.pk]) + response = self.assert_link_field(edit_url, "customer_account_email", shown=True) + self.assertContains(response, f'value="{self.customer.email}"') + for customer in (self.accomplice, None): + with self.subTest(customer=customer): + email = customer.email if customer else "" + self.assertEqual( + self.client.post(edit_url, payload(["studio"], customer_account_email=email)).status_code, 302 + ) + order = reload(order) + self.assertEqual(order.customer_account, customer) + + def test_public_customer_self_service_ignores_forged_account_assignment(self) -> None: + self.program.is_public = True + self.program.save(update_fields=["is_public"]) + self.client.force_login(self.customer) + url = reverse("agreements:order_create", args=[self.program.slug]) + self.assert_link_field(url, "customer_account_email", shown=False) + data = payload(["studio"], customer_account_email=self.accomplice.email) + self.assertEqual(self.client.post(url, data).status_code, 302) + order = Order.objects.exclude(pk=self.order.pk).get() + self.assertEqual(order.customer_account, self.customer) + self.assertEqual(order.created_by, self.customer) + self.assertEqual(self.client.post(reverse("agreements:order_edit", args=[order.pk]), data).status_code, 302) + order = reload(order) + self.assertEqual(order.customer_account, self.customer) + self.assertTrue(order.can_offer(self.customer)) + self.assertEqual(self.client.post(reverse("agreements:order_offer", args=[order.pk])).status_code, 302) + self.assertEqual(cast("Agreement", reload(order).agreement).counterparty_account, self.customer) + + def test_administrator_linked_editor_retains_customer_signing_after_edit(self) -> None: + url = reverse("agreements:order_edit", args=[self.order.pk]) + self.client.force_login(self.administrator) + self.assertEqual( + self.client.post(url, payload(["studio"], customer_account_email=self.editor.email)).status_code, + 302, + ) + self.client.force_login(self.editor) + self.assertEqual(self.client.post(url, payload(["studio"])).status_code, 302) + order = reload(self.order) + self.assertEqual(order.customer_account, self.editor) + self.assertTrue(order.can_offer(self.editor)) + response = self.client.post( + reverse("agreements:order_sign", args=[order.pk]), + { + "signer_name": "Example Customer", + "signer_title": "Director", + "accept": "on", + "document_sha256": agreement_documents.sha256(documents.compose_order_form_markdown(order)), + }, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(reload(order).status, Agreement.Status.SIGNED) + + def test_editor_created_custom_contracts_stay_unlinked(self) -> None: + self.client.force_login(self.editor) + url = reverse("agreements:custom_create") + self.assert_link_field(url, "counterparty_account_email", shown=False) + for email in (None, "", self.editor.email, self.accomplice.email): + with self.subTest(email=email): + data = self.contract_data() + if email is not None: + data["counterparty_account_email"] = email + existing = set(CustomContract.objects.values_list("pk", flat=True)) + self.assertEqual(self.client.post(url, data).status_code, 302) + contract = CustomContract.objects.exclude(pk__in=existing).get() + self.assertIsNone(contract.counterparty_account) + self.assertEqual(contract.created_by, self.editor) + self.assertEqual(contract.body_markdown, "Updated draft.") + + def test_editor_cannot_assign_reassign_or_remove_custom_contract_linkage(self) -> None: + self.client.force_login(self.editor) + url = reverse("agreements:custom_edit", args=[self.contract.pk]) + for original in (None, self.customer): + self.contract.counterparty_account = original + self.contract.save(update_fields=["counterparty_account"]) + for email in (None, "", self.editor.email, self.accomplice.email): + with self.subTest(original=original, email=email): + self.assert_link_field(url, "counterparty_account_email", shown=False) + data = self.contract_data() + if email is not None: + data["counterparty_account_email"] = email + self.assertEqual(self.client.post(url, data).status_code, 302) + self.contract.refresh_from_db() + self.assertEqual(self.contract.counterparty_account, original) + self.assertEqual(self.contract.created_by, self.administrator) + self.assertEqual(self.contract.title, data["title"]) + self.assertEqual(self.contract.counterparty_name, data["counterparty_name"]) + self.assertEqual(self.contract.body_markdown, data["body_markdown"]) + + def test_administrator_can_assign_reassign_and_remove_custom_contract_linkage(self) -> None: + self.client.force_login(self.administrator) + url = reverse("agreements:custom_create") + self.assert_link_field(url, "counterparty_account_email", shown=True) + response = self.client.post(url, self.contract_data(counterparty_account_email=self.customer.email)) + self.assertEqual(response.status_code, 302) + contract = CustomContract.objects.exclude(pk=self.contract.pk).get() + self.assertEqual(contract.counterparty_account, self.customer) + url = reverse("agreements:custom_edit", args=[contract.pk]) + response = self.assert_link_field(url, "counterparty_account_email", shown=True) + self.assertContains(response, f'value="{self.customer.email}"') + for customer in (self.accomplice, None): + with self.subTest(customer=customer): + email = customer.email if customer else "" + response = self.client.post(url, self.contract_data(counterparty_account_email=email)) + self.assertEqual(response.status_code, 302) + contract.refresh_from_db() + self.assertEqual(contract.counterparty_account, customer) diff --git a/apps/agreements/tests/test_draft_concurrency.py b/apps/agreements/tests/test_draft_concurrency.py new file mode 100644 index 000000000..7368b3331 --- /dev/null +++ b/apps/agreements/tests/test_draft_concurrency.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from queue import Queue +from time import monotonic, sleep +from typing import TYPE_CHECKING, Any, cast +from unittest import skipUnless + +from django.contrib.auth import get_user_model +from django.contrib.messages.middleware import MessageMiddleware +from django.db import connection, connections, transaction +from django.http import Http404 +from django.test import RequestFactory, TransactionTestCase +from django.urls import resolve, reverse + +from apps.agreements import workflow +from apps.agreements.documents import sha256 +from apps.agreements.models import Agreement, CustomContract, Order +from apps.agreements.orders.documents import compose_order_form_markdown +from apps.agreements.registry import get_kind +from apps.agreements.tests.catalog_data import make_program +from apps.agreements.tests.test_agreements import make_officer +from apps.agreements.tests.test_orders import make_order, payload + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.contrib.sessions.backends.base import SessionBase + from django.http import HttpRequest, HttpResponse + + from apps.users.models import User + + +@skipUnless(connection.vendor == "postgresql", "Exercises PostgreSQL row-lock contention") +class DraftConcurrencyTests(TransactionTestCase): + def setUp(self) -> None: + self.officer = make_officer() + self.officer.get_all_permissions() + + def post_during_change[ChangeResult]( + self, + subject: CustomContract | Order, + route: str, + data: dict[str, Any], + change: Callable[[], ChangeResult], + *, + user: User | None = None, + ) -> tuple[int, ChangeResult]: + """Make a request contend with a transaction already holding the draft lock.""" + backend: Queue[int] = Queue() + url = reverse(route, args=[subject.pk]) + + def submit() -> int: + try: + with connection.cursor() as cursor: + cursor.execute("SELECT pg_backend_pid()") + backend.put(cursor.fetchone()[0]) + request = RequestFactory().post(url, data) + request.user = user or self.officer + request.session = cast("SessionBase", {}) + MessageMiddleware(cast("Callable[[HttpRequest], HttpResponse]", lambda _request: None)).process_request( + request + ) + match = resolve(url) + try: + return match.func(request, **match.kwargs).status_code + except Http404: + return 404 + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=1) as pool: + with transaction.atomic(): + type(subject).objects.select_for_update().get(pk=subject.pk) + pending = pool.submit(submit) + pid = backend.get(timeout=10) + deadline = monotonic() + 10 + while monotonic() < deadline: + if pending.done(): + self.fail(f"Draft mutation did not wait for the offer: {pending.result()}") + with connection.cursor() as cursor: + cursor.execute("SELECT wait_event_type FROM pg_stat_activity WHERE pid = %s", [pid]) + row = cursor.fetchone() + if row and row[0] == "Lock": + break + sleep(0.01) + else: + self.fail("Draft mutation never reached the held row lock") + result = change() + status = pending.result(timeout=10) + return status, result + + def mutate_while_offering( + self, subject: CustomContract | Order, kind: str, route: str, data: dict[str, Any] + ) -> tuple[int, Agreement]: + def offer_and_sign() -> Agreement: + agreement = workflow.offer(get_kind(kind), subject, user=self.officer) + return workflow.sign( + agreement, + workflow.Signature("Customer", "Director", "customer@example.com"), + seen_sha256=agreement.document_sha256, + ) + + status, agreement = self.post_during_change(subject, route, data, offer_and_sign) + subject.refresh_from_db() + self.assertEqual(subject.agreement_id, agreement.pk) + self.assertEqual(cast("Agreement", subject.agreement).status, Agreement.Status.SIGNED) + return status, agreement + + def contract(self) -> CustomContract: + return CustomContract.objects.create( + title="Original contract", + counterparty_name="Example Company", + body_markdown="Original terms.", + created_by=self.officer, + ) + + def test_contract_edit_cannot_detach_a_concurrent_signed_offer(self) -> None: + contract = self.contract() + status, _ = self.mutate_while_offering( + contract, + "custom", + "agreements:custom_edit", + {"title": "Stale edit", "counterparty_name": "Changed", "body_markdown": "Changed terms."}, + ) + self.assertEqual(status, 302) + self.assertEqual(contract.title, "Original contract") + self.assertEqual(contract.body_markdown, "Original terms.") + + def test_contract_delete_cannot_remove_a_concurrent_signed_offer(self) -> None: + status, _ = self.mutate_while_offering(self.contract(), "custom", "agreements:custom_delete", {}) + self.assertEqual(status, 404) + + def test_order_edit_preserves_a_concurrent_signed_offer_and_frozen_selections(self) -> None: + program = make_program() + order = make_order(self.officer, program=program) + status, _ = self.mutate_while_offering( + order, "order", "agreements:order_edit", payload(legal_name="Stale edit") + ) + self.assertEqual(status, 302) + self.assertEqual(order.legal_name, "Example Workshops, Inc.") + self.assertEqual(order.catalog_snapshot, program.definition) + self.assertEqual(list(order.agreements.values_list("agreement", "tier")), [("studio", "plus")]) + + def test_order_delete_preserves_a_concurrent_signed_offer_and_its_lines(self) -> None: + order = make_order(self.officer, program=make_program()) + status, _ = self.mutate_while_offering(order, "order", "agreements:order_delete", {}) + self.assertEqual(status, 404) + self.assertEqual(list(order.agreements.values_list("agreement", "tier")), [("studio", "plus")]) + + def test_reassigned_customer_cannot_sign_the_order(self) -> None: + customer = get_user_model().objects.create_user("customer", "customer@example.com") + replacement = get_user_model().objects.create_user("replacement", "replacement@example.com") + order = make_order(self.officer, program=make_program(), customer_account=customer) + digest = sha256(compose_order_form_markdown(order)) + + def reassign() -> None: + order.customer_account = replacement + order.save(update_fields=["customer_account"]) + + status, _ = self.post_during_change( + order, + "agreements:order_sign", + {"signer_name": "Customer", "signer_title": "Director", "accept": "on", "document_sha256": digest}, + reassign, + user=customer, + ) + self.assertEqual(status, 404) + order.refresh_from_db() + self.assertEqual(order.customer_account_id, replacement.pk) + self.assertIsNone(order.agreement_id) + self.assertFalse(Agreement.objects.exists()) + + def test_removed_customer_cannot_offer_the_order(self) -> None: + customer = get_user_model().objects.create_user("customer", "customer@example.com") + order = make_order(self.officer, program=make_program(), customer_account=customer) + + def unlink() -> None: + order.customer_account = None + order.save(update_fields=["customer_account"]) + + status, _ = self.post_during_change(order, "agreements:order_offer", {}, unlink, user=customer) + self.assertEqual(status, 404) + order.refresh_from_db() + self.assertIsNone(order.customer_account_id) + self.assertIsNone(order.agreement_id) + self.assertFalse(Agreement.objects.exists()) diff --git a/apps/agreements/tests/test_draft_group_roles.py b/apps/agreements/tests/test_draft_group_roles.py new file mode 100644 index 000000000..8138b7ff5 --- /dev/null +++ b/apps/agreements/tests/test_draft_group_roles.py @@ -0,0 +1,371 @@ +"""Group-only authorization for draft authoring and read-only record administration.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, cast + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.test import TestCase +from django.urls import reverse + +from apps.agreements.auth import ADMINISTRATORS, EDITORS +from apps.agreements.models import Agreement, CustomContract, Order, Program, Terms, TermsVersion +from apps.agreements.tests.catalog_data import make_program +from apps.agreements.tests.test_agreements import offer_contract +from apps.agreements.tests.test_orders import make_order + +if TYPE_CHECKING: + from apps.users.models import User + + +class DraftGroupRoleTests(TestCase): + administrator: User + editor: User + staff: User + superuser: User + permission_only: User + customer: User + terms: Terms + version: TermsVersion + contract: CustomContract + program: Program + order: Order + agreement: Agreement + + @classmethod + def setUpTestData(cls) -> None: + users = get_user_model().objects + cls.administrator = users.create_user("administrator", "administrator@example.org", is_staff=True) + cls.administrator.groups.add(Group.objects.get_or_create(name=ADMINISTRATORS)[0]) + cls.editor = users.create_user("editor", "editor@example.org", is_staff=True) + cls.editor.groups.add(Group.objects.get_or_create(name=EDITORS)[0]) + cls.staff = users.create_user("staff", "staff@example.org", is_staff=True) + cls.superuser = users.create_superuser("superuser", "superuser@example.org", "password") + cls.permission_only = users.create_user("permission-only", "permissions@example.org", is_staff=True) + cls.permission_only.user_permissions.set(Permission.objects.filter(content_type__app_label="agreements")) + cls.customer = users.create_user("customer", "customer@example.org") + cls.terms = Terms.objects.create( + slug="example-private", title="Example private terms", draft_markdown="Original draft", under_review=True + ) + cls.version = TermsVersion.objects.create(terms=cls.terms, version="v1", markdown="Original published text.") + cls.contract = CustomContract.objects.create( + title="Example draft", + counterparty_name="Example Company", + body_markdown="Original draft", + created_by=cls.editor, + ) + cls.program = make_program(is_public=False) + cls.order = make_order(cls.administrator, program=cls.program, customer_account=cls.customer) + cls.agreement = offer_contract( + cls.administrator, title="Example offered contract", terms=cls.terms, counterparty_account=cls.customer + ) + + def terms_url(self) -> str: + return reverse("agreements:terms_edit", args=[self.terms.slug]) + + def contract_data(self) -> dict[str, str]: + return {"title": "Updated example", "counterparty_name": "Example Company", "body_markdown": "Updated draft"} + + def assert_terms_unchanged(self) -> None: + self.terms.refresh_from_db() + self.assertEqual(self.terms.draft_markdown, "Original draft") + self.assertFalse(self.terms.is_public) + self.assertTrue(self.terms.under_review) + self.assertEqual(self.terms.versions.count(), 1) + + def test_editor_saves_and_previews_only_draft_text(self) -> None: + self.client.force_login(self.editor) + response = self.client.get(self.terms_url()) + self.assertContains(response, 'value="save"') + self.assertContains(response, 'name="preview"') + for field in ("is_public", "under_review", "version", "notes"): + self.assertNotIn(field, response.context["form"].fields) + self.assertNotContains(response, f'name="{field}"') + self.assertNotContains(response, 'value="publish"') + self.assertNotContains(self.client.get(reverse("agreements:terms_list")), reverse("admin:agreements_terms_add")) + response = self.client.post(self.terms_url(), {"markdown": "Preview example.", "preview": "1"}) + self.assertContains(response, "Preview example.") + self.assert_terms_unchanged() + response = self.client.post(self.terms_url(), {"markdown": "Saved example.", "action": "save"}) + self.assertRedirects(response, self.terms_url()) + self.terms.refresh_from_db() + self.assertEqual(self.terms.draft_markdown, "Saved example.") + self.assertEqual(self.terms.draft_updated_by, self.editor) + self.assertFalse(self.terms.is_public) + self.assertTrue(self.terms.under_review) + self.assertEqual(self.terms.versions.count(), 1) + + def test_editor_forged_publication_and_configuration_posts_are_forbidden(self) -> None: + self.client.force_login(self.editor) + for payload in ( + {"action": "publish", "version": "v2", "notes": "Changed"}, + {"action": "publish", "preview": "1"}, + {"action": "save", "is_public": "on"}, + {"action": "save", "under_review": ""}, + {"preview": "1", "is_public": ""}, + {"action": "save", "version": "v2"}, + {"action": "save", "notes": "Changed"}, + ): + with self.subTest(payload=payload): + response = self.client.post(self.terms_url(), {"markdown": "Unauthorized change", **payload}) + self.assertEqual(response.status_code, 403) + self.assert_terms_unchanged() + + def test_administrator_can_publish_and_configure_terms(self) -> None: + self.client.force_login(self.administrator) + response = self.client.get(self.terms_url()) + self.assertContains(response, 'value="publish"') + self.assertContains(response, 'name="is_public"') + response = self.client.post( + self.terms_url(), + { + "action": "publish", + "markdown": "New example publication.", + "version": "v2", + "notes": "Example revision", + "is_public": "on", + }, + ) + published = self.terms.versions.get(version="v2") + self.assertRedirects(response, published.get_absolute_url()) + self.terms.refresh_from_db() + self.assertTrue(self.terms.is_public) + self.assertFalse(self.terms.under_review) + self.assertEqual(published.published_by, self.administrator) + + def test_editor_can_create_edit_and_discard_custom_drafts_without_staff_status(self) -> None: + self.editor.is_staff = False + self.editor.save(update_fields=["is_staff"]) + self.client.force_login(self.editor) + response = self.client.get(reverse("admin:agreements_customcontract_changelist")) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("admin:login"), response["Location"]) + response = self.client.post(reverse("agreements:custom_create"), self.contract_data()) + contract = CustomContract.objects.get(title="Updated example") + self.assertRedirects(response, contract.get_absolute_url()) + self.assertEqual(contract.created_by, self.editor) + response = self.client.post(reverse("agreements:custom_edit", args=[self.contract.pk]), self.contract_data()) + self.assertRedirects(response, self.contract.get_absolute_url()) + self.contract.refresh_from_db() + self.assertEqual(self.contract.body_markdown, "Updated draft") + response = self.client.get(self.contract.get_absolute_url()) + self.assertContains(response, reverse("agreements:custom_edit", args=[self.contract.pk])) + self.assertContains(response, reverse("agreements:custom_delete", args=[self.contract.pk])) + self.assertNotContains(response, reverse("agreements:custom_offer", args=[self.contract.pk])) + response = self.client.post(reverse("agreements:custom_delete", args=[self.contract.pk])) + self.assertEqual(response.status_code, 302) + self.assertFalse(CustomContract.objects.filter(pk=self.contract.pk).exists()) + + def test_only_administrator_can_offer_custom_draft(self) -> None: + url = reverse("agreements:custom_offer", args=[self.contract.pk]) + for user in (self.editor, self.staff, self.superuser, self.permission_only): + with self.subTest(user=user.username): + self.client.force_login(user) + self.assertEqual(self.client.post(url).status_code, 403) + self.contract.refresh_from_db() + self.assertIsNone(self.contract.agreement_id) + self.assertEqual(Agreement.objects.count(), 1) + self.client.force_login(self.administrator) + self.assertContains(self.client.get(self.contract.get_absolute_url()), url) + response = self.client.post(url) + self.contract.refresh_from_db() + self.assertIsNotNone(self.contract.agreement_id) + self.assertRedirects(response, cast("Agreement", self.contract.agreement).get_absolute_url()) + + def test_offered_custom_edit_redirects_editor_to_readable_record_without_mutation(self) -> None: + self.client.force_login(self.editor) + contract = cast("CustomContract", self.agreement.subject) + url = reverse("agreements:custom_edit", args=[contract.pk]) + for method in (self.client.get, self.client.post): + response = method(url, self.contract_data()) + self.assertRedirects(response, self.agreement.get_absolute_url()) + contract.refresh_from_db() + self.agreement.refresh_from_db() + self.assertEqual(contract.title, "Example offered contract") + self.assertEqual(self.agreement.revision, 1) + + def test_ungrouped_staff_superuser_and_permission_holder_cannot_author(self) -> None: + for user in (self.staff, self.superuser, self.permission_only): + self.client.force_login(user) + urls = ( + reverse("agreements:terms_list"), + self.terms_url(), + reverse("agreements:custom_create"), + self.contract.get_absolute_url(), + reverse("agreements:custom_edit", args=[self.contract.pk]), + reverse("agreements:custom_delete", args=[self.contract.pk]), + ) + for url in urls: + with self.subTest(user=user.username, url=url): + self.assertEqual(self.client.get(url).status_code, 403) + response = self.client.post( + url, {**self.contract_data(), "action": "save", "markdown": "Unauthorized change"} + ) + self.assertEqual(response.status_code, 403) + response = self.client.post( + self.terms_url(), + { + "action": "publish", + "markdown": "Unauthorized publication", + "version": "v2", + "notes": "Unauthorized", + "is_public": "on", + }, + ) + self.assertEqual(response.status_code, 403) + self.assert_terms_unchanged() + self.contract.refresh_from_db() + self.assertEqual(self.contract.body_markdown, "Original draft") + self.assertEqual(CustomContract.objects.count(), 2) + + def test_revoked_authors_and_offerers_cannot_read_private_terms(self) -> None: + self.administrator.groups.clear() + self.client.force_login(self.administrator) + for terms in (self.terms, Terms.objects.get(slug="studio-terms")): + self.assertEqual( + self.client.get(cast("TermsVersion", terms.current_version).get_absolute_url()).status_code, 404 + ) + self.assertEqual(self.client.get(self.terms_url()).status_code, 403) + self.editor.groups.clear() + self.client.force_login(self.editor) + self.assertEqual(self.client.get(self.contract.get_absolute_url()).status_code, 403) + response = self.client.post(reverse("agreements:custom_edit", args=[self.contract.pk]), self.contract_data()) + self.assertEqual(response.status_code, 403) + self.contract.refresh_from_db() + self.assertEqual(self.contract.body_markdown, "Original draft") + self.client.force_login(self.customer) + self.assertContains(self.client.get(self.version.get_absolute_url()), "Original published text.") + self.assertEqual(self.client.get(Terms.objects.get(slug="studio-terms").get_absolute_url()).status_code, 200) + + def test_admin_module_and_view_permissions_use_only_groups(self) -> None: + instances = (self.terms, self.program, self.contract, self.agreement, self.order) + for user in (self.editor, self.administrator, self.staff, self.superuser, self.permission_only): + allowed = user in (self.editor, self.administrator) + self.client.force_login(user) + index = self.client.get(reverse("admin:index")) + for instance in instances: + name = type(instance).__name__.lower() + with self.subTest(user=user.username, model=name): + url = reverse(f"admin:agreements_{name}_changelist") + if allowed: + self.assertContains(index, url) + else: + self.assertNotContains(index, url) + response = self.client.get(url) + self.assertEqual(response.status_code, 200 if allowed else 403) + response = self.client.get(reverse(f"admin:agreements_{name}_change", args=[instance.pk])) + self.assertEqual(response.status_code, 200 if allowed else 403) + + def test_group_members_can_view_record_inlines_without_model_permissions(self) -> None: + for user in (self.editor, self.administrator): + self.client.force_login(user) + self.assertFalse(user.user_permissions.exists()) + for instance, expected in ( + (self.terms, self.version), + (self.agreement, self.agreement.revisions.get()), + (self.order, self.order.agreements.get()), + ): + name = type(instance).__name__.lower() + with self.subTest(user=user.username, model=name): + response = self.client.get(reverse(f"admin:agreements_{name}_change", args=[instance.pk])) + shown = [ + row for inline in response.context["inline_admin_formsets"] for row in inline.formset.queryset + ] + self.assertIn(expected, shown) + + def test_admin_configuration_rejects_forged_writes_without_administrator_group(self) -> None: + original_definition = self.program.definition + forged_definition = {**original_definition, "order_title": "Unauthorized"} + for user in (self.editor, self.staff, self.superuser, self.permission_only): + self.client.force_login(user) + for instance, data in ( + (self.terms, {"slug": self.terms.slug, "title": "Unauthorized", "is_public": "on"}), + ( + self.program, + { + "slug": self.program.slug, + "title": "Unauthorized", + "is_public": "on", + "definition": json.dumps(forged_definition), + }, + ), + ): + name = type(instance).__name__.lower() + with self.subTest(user=user.username, model=name): + response = self.client.post(reverse(f"admin:agreements_{name}_change", args=[instance.pk]), data) + self.assertEqual(response.status_code, 403) + response = self.client.post( + reverse(f"admin:agreements_{name}_add"), {**data, "slug": "new-example"} + ) + self.assertEqual(response.status_code, 403) + response = self.client.post( + reverse(f"admin:agreements_{name}_delete", args=[instance.pk]), {"post": "yes"} + ) + self.assertEqual(response.status_code, 403) + instance.refresh_from_db() + self.assertNotEqual(instance.title, "Unauthorized") + self.assertFalse(instance.is_public) + self.assertFalse(type(instance).objects.filter(slug="new-example").exists()) + self.assert_terms_unchanged() + self.assertEqual(self.program.definition, original_definition) + + def test_administrator_can_create_terms_and_change_program_configuration(self) -> None: + self.client.force_login(self.administrator) + response = self.client.post( + reverse("admin:agreements_terms_add"), + { + "slug": "new-example", + "title": "New example terms", + "is_public": "on", + "versions-TOTAL_FORMS": "0", + "versions-INITIAL_FORMS": "0", + "versions-MIN_NUM_FORMS": "0", + "versions-MAX_NUM_FORMS": "1000", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(Terms.objects.get(slug="new-example").is_public) + Terms.objects.filter(slug__in=[item.terms_slug for item in self.program.catalog.agreements.values()]).update( + is_public=True + ) + response = self.client.post( + reverse("admin:agreements_program_change", args=[self.program.pk]), + { + "slug": self.program.slug, + "title": "Updated example program", + "is_public": "on", + "definition": json.dumps(self.program.definition), + }, + ) + self.assertEqual(response.status_code, 302) + self.program.refresh_from_db() + self.assertEqual(self.program.title, "Updated example program") + self.assertTrue(self.program.is_public) + + def test_all_groups_and_superusers_cannot_mutate_admin_records(self) -> None: + for user in (self.editor, self.administrator, self.staff, self.superuser, self.permission_only): + self.client.force_login(user) + for instance in (self.agreement, self.contract, self.order): + name = type(instance).__name__.lower() + with self.subTest(user=user.username, model=name): + response = self.client.post(reverse(f"admin:agreements_{name}_add"), self.contract_data()) + self.assertEqual(response.status_code, 403) + response = self.client.post( + reverse(f"admin:agreements_{name}_change", args=[instance.pk]), + {**self.contract_data(), "status": "declined", "legal_name": "Unauthorized"}, + ) + self.assertEqual(response.status_code, 403) + response = self.client.post( + reverse(f"admin:agreements_{name}_delete", args=[instance.pk]), {"post": "yes"} + ) + self.assertEqual(response.status_code, 403) + self.agreement.refresh_from_db() + self.contract.refresh_from_db() + self.order.refresh_from_db() + self.assertEqual(self.agreement.status, Agreement.Status.OFFERED) + self.assertEqual(self.contract.body_markdown, "Original draft") + self.assertEqual(self.order.legal_name, "Example Workshops, Inc.") + self.assertEqual(Order.objects.count(), 1) + self.assertEqual(Program.objects.count(), 1) diff --git a/apps/agreements/tests/test_edit_conflict_recovery.py b/apps/agreements/tests/test_edit_conflict_recovery.py new file mode 100644 index 000000000..a147b5240 --- /dev/null +++ b/apps/agreements/tests/test_edit_conflict_recovery.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +from django.contrib.messages import ERROR, get_messages +from django.test import TestCase +from django.urls import reverse +from django.utils.html import escape + +from apps.agreements import documents, workflow +from apps.agreements.models import Agreement +from apps.agreements.tests.test_agreements import make_officer, offer_contract +from apps.agreements.views.helpers import _agreement_or_404 + +if TYPE_CHECKING: + from collections.abc import Mapping + from uuid import UUID + + from django.http import HttpRequest + from django.test.client import _MonkeyPatchedWSGIResponse + + +class EditConflictRecoveryTests(TestCase): + def setUp(self) -> None: + self.officer = make_officer() + self.client.force_login(self.officer) + self.agreement = offer_contract(self.officer) + self.url = reverse("agreements:edit", args=[self.agreement.pk]) + opened = self.client.get(self.url) + self.base_sha256 = opened.context["form"]["base_sha256"].value() + self.current = self.agreement.document_markdown.replace("May 20", "May 21") + self.proposed = self.agreement.document_markdown + "\nProposed & additions.\n" + self.note = "Preserve my & reason" + + def save_concurrent_edit(self, markdown: str) -> None: + self.agreement = workflow.edit( + self.agreement, + markdown=markdown, + note="Concurrent edit", + user=self.officer, + base_sha256=self.agreement.document_sha256, + ) + + def post_edit(self, markdown: str, base_sha256: str) -> _MonkeyPatchedWSGIResponse: + return self.client.post( + self.url, + {"markdown": markdown, "note": self.note, "base_sha256": base_sha256, "save": ""}, + ) + + def history(self) -> list[Mapping[str, object]]: + return list(self.agreement.revisions.values()) + + def conflict_response(self) -> _MonkeyPatchedWSGIResponse: + self.save_concurrent_edit(self.current) + history = self.history() + response = self.post_edit(self.proposed, self.base_sha256) + self.assertEqual(response.status_code, 409) + self.agreement.refresh_from_db() + self.assertEqual(self.agreement.document_markdown, self.current) + self.assertEqual(self.agreement.document_sha256, documents.sha256(self.current)) + self.assertEqual(self.agreement.revision, 2) + self.assertEqual(self.history(), history) + return response + + def test_reviewed_replacement_can_be_saved_without_losing_submitted_work(self) -> None: + response = self.conflict_response() + form = response.context["form"] + self.assertEqual(form["markdown"].value(), self.proposed) + self.assertEqual(form["note"].value(), self.note) + self.assertEqual(form["base_sha256"].value(), self.agreement.document_sha256) + self.assertContains(response, escape(self.current), status_code=409) + self.assertContains(response, escape(self.proposed), status_code=409) + self.assertContains(response, escape(self.note), status_code=409) + self.assertContains(response, "-May 12 to May 21, 2027.", status_code=409) + self.assertContains(response, "+May 12 to May 20, 2027.", status_code=409) + + merged = self.proposed.replace("May 20", "May 21") + response = self.post_edit(merged, form["base_sha256"].value()) + self.assertRedirects(response, self.agreement.get_absolute_url()) + self.agreement.refresh_from_db() + self.assertEqual(self.agreement.revision, 3) + self.assertEqual(self.agreement.document_markdown, merged) + self.assertEqual(self.agreement.document_sha256, documents.sha256(merged)) + revision = self.agreement.revisions.get(revision=3) + self.assertEqual(revision.markdown, merged) + self.assertEqual(revision.note, self.note) + self.assertEqual(self.agreement.revisions.get(revision=2).markdown, self.current) + self.assertEqual(self.agreement.revisions.count(), 3) + + def test_another_edit_after_review_conflicts_again(self) -> None: + response = self.conflict_response() + reviewed_sha256 = response.context["form"]["base_sha256"].value() + newest = self.current + "\nAnother concurrent change.\n" + self.save_concurrent_edit(newest) + history = self.history() + merged = self.proposed.replace("May 20", "May 21") + + response = self.post_edit(merged, reviewed_sha256) + + self.assertEqual(response.status_code, 409) + self.agreement.refresh_from_db() + self.assertEqual(self.agreement.document_markdown, newest) + self.assertEqual(self.agreement.revision, 3) + self.assertEqual(self.history(), history) + self.assertEqual(response.context["form"]["markdown"].value(), merged) + self.assertEqual(response.context["form"]["note"].value(), self.note) + self.assertEqual(response.context["form"]["base_sha256"].value(), self.agreement.document_sha256) + self.assertContains(response, escape(newest), status_code=409) + + def test_conflict_reloads_changes_committed_after_view_loaded_agreement(self) -> None: + def load_then_edit(request: HttpRequest, pk: UUID) -> Agreement: + loaded = _agreement_or_404(request, pk) + self.save_concurrent_edit(self.current) + return loaded + + with patch("apps.agreements.views.agreements._agreement_or_404", side_effect=load_then_edit): + response = self.post_edit(self.proposed, self.base_sha256) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.context["agreement"].revision, 2) + self.assertEqual(response.context["form"]["base_sha256"].value(), self.agreement.document_sha256) + self.assertContains(response, escape(self.current), status_code=409) + self.assertEqual(self.agreement.revisions.count(), 2) + + def sign(self) -> None: + workflow.sign( + self.agreement, + workflow.Signature("Grace Hopper", "CTO", "grace@example.com"), + seen_sha256=self.agreement.document_sha256, + ) + + def assert_signed_document_unchanged(self, history: list[Mapping[str, object]]) -> None: + self.agreement.refresh_from_db() + self.assertEqual(self.agreement.status, Agreement.Status.SIGNED) + self.assertEqual(self.agreement.document_markdown, self.current) + self.assertEqual(self.agreement.document_sha256, documents.sha256(self.current)) + self.assertEqual(self.agreement.revision, 2) + self.assertEqual(self.history(), history) + + def test_signing_after_review_prevents_replacement(self) -> None: + response = self.conflict_response() + reviewed_sha256 = response.context["form"]["base_sha256"].value() + history = self.history() + self.sign() + + response = self.post_edit(self.proposed, reviewed_sha256) + + self.assertRedirects(response, self.agreement.get_absolute_url()) + self.assert_signed_document_unchanged(history) + + def test_signing_after_view_loaded_agreement_prevents_replacement(self) -> None: + response = self.conflict_response() + reviewed_sha256 = response.context["form"]["base_sha256"].value() + history = self.history() + + def load_then_sign(request: HttpRequest, pk: UUID) -> Agreement: + loaded = _agreement_or_404(request, pk) + self.sign() + return loaded + + with patch("apps.agreements.views.agreements._agreement_or_404", side_effect=load_then_sign): + response = self.post_edit(self.proposed, reviewed_sha256) + + self.assertIn(ERROR, [message.level for message in get_messages(response.wsgi_request)]) + self.assert_signed_document_unchanged(history) diff --git a/apps/agreements/tests/test_group_access.py b/apps/agreements/tests/test_group_access.py new file mode 100644 index 000000000..a99af50ad --- /dev/null +++ b/apps/agreements/tests/test_group_access.py @@ -0,0 +1,198 @@ +"""Group boundaries must hold independently of staff flags and Django permissions.""" + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.core import mail +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from django.urls import reverse + +from apps.agreements import workflow +from apps.agreements.auth import EDITORS +from apps.agreements.models import Agreement, SignedCopy +from apps.agreements.tests.test_agreements import PDF, make_officer, offer_contract + + +class SigningGroupAccessTests(TestCase): + def setUp(self) -> None: + self.administrator = make_officer() + users = get_user_model().objects + self.customer = users.create_user("customer", "customer@example.com", "password") + self.editor = users.create_user("editor", "editor@example.com", "password", is_staff=True) + self.editor.groups.add(Group.objects.get_or_create(name=EDITORS)[0]) + self.staff = users.create_user("staff", "staff@example.com", "password", is_staff=True) + self.superuser = users.create_superuser("root", "root@example.com", "password") + self.permissions_only = users.create_user("permissions", "permissions@example.com", "password", is_staff=True) + Permission.objects.get_or_create( + content_type=ContentType.objects.get_for_model(Agreement), + codename="manage_agreement", + defaults={"name": "Legacy agreement permission"}, + ) + self.permissions_only.user_permissions.set(Permission.objects.filter(content_type__app_label="agreements")) + self.agreement = offer_contract(self.administrator, counterparty_account=self.customer) + + def test_staff_superuser_and_direct_permissions_do_not_grant_record_access(self) -> None: + copy = SignedCopy.objects.create( + agreement=self.agreement, + kind=SignedCopy.Kind.CUSTOMER, + filename="signed.pdf", + content=PDF, + sha256="0" * 64, + uploaded_by=self.administrator, + ) + for user in (self.staff, self.superuser, self.permissions_only): + self.client.force_login(user) + with self.subTest(user=user.username): + self.assertEqual(self.client.get(reverse("agreements:queue")).status_code, 403) + self.assertEqual(self.client.get(self.agreement.get_absolute_url()).status_code, 404) + self.assertEqual( + self.client.get(reverse("agreements:document", args=[self.agreement.pk, "pdf"])).status_code, 404 + ) + self.assertEqual( + self.client.get( + reverse("agreements:copy_download", args=[self.agreement.pk, copy.kind]) + ).status_code, + 404, + ) + + def test_editor_can_read_but_cannot_modify_or_send_an_offer(self) -> None: + self.client.force_login(self.editor) + page = self.client.get(self.agreement.get_absolute_url()) + self.assertContains(page, self.agreement.counterparty_name) + edit_url = reverse("agreements:edit", args=[self.agreement.pk]) + send_url = reverse("agreements:send_link", args=[self.agreement.pk]) + self.assertNotContains(page, edit_url) + self.assertNotContains(page, send_url) + self.assertEqual(self.client.get(edit_url).status_code, 403) + self.assertEqual( + self.client.post( + edit_url, + { + "markdown": self.agreement.document_markdown + "\nChanged by editor.", + "note": "Not allowed", + "base_sha256": self.agreement.document_sha256, + }, + ).status_code, + 403, + ) + self.assertEqual(self.client.post(send_url, {"name": "Signer", "email": "signer@example.com"}).status_code, 403) + self.assertEqual(self.client.post(reverse("agreements:withdraw", args=[self.agreement.pk])).status_code, 404) + self.assertEqual( + self.client.post( + reverse("agreements:record_copy", args=[self.agreement.pk]), + { + "signed_copy": SimpleUploadedFile("signed.pdf", PDF), + "signer_name": "Signer", + "signer_title": "Director", + "signer_email": "signer@example.com", + "signed_on": "2026-01-01", + "matches": "on", + "document_sha256": self.agreement.document_sha256, + }, + ).status_code, + 404, + ) + self.agreement.refresh_from_db() + self.assertEqual(self.agreement.status, Agreement.Status.OFFERED) + self.assertEqual(self.agreement.revision, 1) + self.assertFalse(self.agreement.signing_links.exists()) + self.assertFalse(self.agreement.signed_copies.exists()) + self.assertEqual(mail.outbox, []) + + def test_only_administrators_can_countersign_decline_or_resend(self) -> None: + signed = workflow.sign( + self.agreement, + workflow.Signature("Customer", "Director", self.customer.email), + seen_sha256=self.agreement.document_sha256, + ) + for user in (self.editor, self.staff, self.superuser, self.permissions_only): + self.client.force_login(user) + for name, data in ( + ("countersign", {"name": "Officer", "title": "Director", "accept": "on"}), + ("decline", {"reason": "Not allowed"}), + ): + with self.subTest(user=user.username, action=name): + self.assertEqual( + self.client.post(reverse(f"agreements:{name}", args=[signed.pk]), data).status_code, 403 + ) + signed.refresh_from_db() + self.assertEqual(signed.status, Agreement.Status.SIGNED) + self.client.force_login(self.administrator) + response = self.client.post( + reverse("agreements:countersign", args=[signed.pk]), + { + "name": "Officer", + "title": "Director", + "accept": "on", + "executed_copy": SimpleUploadedFile("executed.pdf", PDF), + }, + ) + self.assertEqual(response.status_code, 302) + signed.refresh_from_db() + self.assertEqual(signed.status, Agreement.Status.EXECUTED) + self.assertEqual(signed.countersigned_by, self.administrator) + self.assertEqual(len(mail.outbox), 1) + for user in (self.editor, self.staff, self.superuser, self.permissions_only): + self.client.force_login(user) + with self.subTest(user=user.username, action="resend"): + self.assertEqual( + self.client.post(reverse("agreements:resend_executed_copy", args=[signed.pk])).status_code, 403 + ) + self.assertEqual(len(mail.outbox), 1) + + def test_role_removal_revokes_access_even_for_the_offer_creator(self) -> None: + self.client.force_login(self.administrator) + self.assertContains(self.client.get(self.agreement.get_absolute_url()), self.agreement.counterparty_name) + self.administrator.groups.clear() + self.assertEqual(self.client.get(reverse("agreements:queue")).status_code, 403) + self.assertEqual(self.client.get(self.agreement.get_absolute_url()).status_code, 404) + self.assertEqual( + self.client.post( + reverse("agreements:send_link", args=[self.agreement.pk]), + { + "name": "Signer", + "email": "signer@example.com", + }, + ).status_code, + 403, + ) + self.assertFalse(self.agreement.signing_links.exists()) + + def test_customer_and_anonymous_link_rights_do_not_require_group_membership(self) -> None: + self.client.force_login(self.customer) + self.assertContains(self.client.get(self.agreement.get_absolute_url()), self.agreement.counterparty_name) + self.assertEqual(self.client.get(reverse("agreements:queue")).status_code, 403) + self.client.logout() + self.assertEqual(self.client.get(self.agreement.get_absolute_url()).status_code, 302) + self.assertEqual(self.client.get(reverse("agreements:queue")).status_code, 302) + link, token = workflow.create_signing_link( + self.agreement, name="Invited", email="invited@example.com", user=self.administrator + ) + url = reverse("agreements:sign_link", args=[token]) + self.assertContains(self.client.get(url), self.agreement.counterparty_name) + response = self.client.post( + url, + { + "signer_name": "Invited", + "signer_title": "Director", + "accept": "on", + "document_sha256": self.agreement.document_sha256, + }, + ) + self.assertEqual(response.status_code, 200) + self.agreement.refresh_from_db() + link.refresh_from_db() + self.assertEqual(self.agreement.status, Agreement.Status.SIGNED) + self.assertEqual(self.agreement.signer_email, "invited@example.com") + self.assertIsNotNone(link.used_at) + self.assertEqual(self.client.get(url).status_code, 410) + + def test_inactive_group_member_cannot_use_management_views(self) -> None: + self.administrator.is_active = False + self.administrator.save(update_fields=["is_active"]) + self.client.force_login(self.administrator) + response = self.client.get(reverse("agreements:queue")) + self.assertEqual(response.status_code, 302) + self.agreement.refresh_from_db() + self.assertEqual(self.agreement.status, Agreement.Status.OFFERED) diff --git a/apps/agreements/tests/test_group_admin_security.py b/apps/agreements/tests/test_group_admin_security.py new file mode 100644 index 000000000..4adde7784 --- /dev/null +++ b/apps/agreements/tests/test_group_admin_security.py @@ -0,0 +1,89 @@ +"""Group administration cannot be used to acquire agreement roles.""" + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.test import TestCase +from django.urls import reverse + +from apps.agreements.auth import ADMINISTRATORS, EDITORS, is_administrator + + +class GroupAdministrationSecurityTests(TestCase): + def setUp(self) -> None: + users = get_user_model().objects + self.staff = users.create_user("group-editor", is_staff=True) + self.staff.user_permissions.set( + Permission.objects.filter(content_type__app_label="auth", codename__in=("add_group", "change_group")) + ) + self.own_group = Group.objects.create(name="Delegated group") + self.staff.groups.add(self.own_group) + self.administrators = Group.objects.get_or_create(name=ADMINISTRATORS)[0] + self.editors = Group.objects.get_or_create(name=EDITORS)[0] + self.administrator = users.create_user("agreement-administrator") + self.administrator.groups.add(self.administrators) + self.client.force_login(self.staff) + + def test_group_editor_cannot_rename_roles_or_take_their_names(self) -> None: + for group in (self.administrators, self.editors): + with self.subTest(role=group.name): + original_name = group.name + response = self.client.post( + reverse("admin:auth_group_change", args=[group.pk]), + {"name": "Renamed role", "permissions": [], "_save": "Save"}, + ) + self.assertEqual(response.status_code, 403) + group.refresh_from_db() + self.assertEqual(group.name, original_name) + response = self.client.post( + reverse("admin:auth_group_change", args=[self.own_group.pk]), + {"name": ADMINISTRATORS, "permissions": [], "_save": "Save"}, + ) + self.assertEqual(response.status_code, 403) + self.own_group.refresh_from_db() + self.assertEqual(self.own_group.name, "Delegated group") + self.assertFalse(is_administrator(self.staff)) + self.assertTrue(is_administrator(self.administrator)) + self.assertEqual(self.client.get(reverse("agreements:queue")).status_code, 403) + + def test_group_editor_cannot_grant_user_administration_to_own_group(self) -> None: + permission = Permission.objects.get(content_type__app_label="users", codename="change_user") + response = self.client.post( + reverse("admin:auth_group_change", args=[self.own_group.pk]), + {"name": self.own_group.name, "permissions": [permission.pk], "_save": "Save"}, + ) + self.assertEqual(response.status_code, 403) + self.assertFalse(self.own_group.permissions.exists()) + + def test_group_creator_cannot_create_a_privileged_group(self) -> None: + permission = Permission.objects.get(content_type__app_label="users", codename="change_user") + response = self.client.post( + reverse("admin:auth_group_add"), + {"name": "New privileged group", "permissions": [permission.pk], "_save": "Save"}, + ) + self.assertEqual(response.status_code, 403) + self.assertFalse(Group.objects.filter(name="New privileged group").exists()) + + def test_delegated_group_editor_can_still_inspect_groups(self) -> None: + response = self.client.get(reverse("admin:auth_group_change", args=[self.own_group.pk])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.own_group.name) + + def test_superuser_can_create_and_manage_group_permissions(self) -> None: + root = get_user_model().objects.create_superuser("identity-admin", "root@example.com", "password") + self.client.force_login(root) + permission = Permission.objects.get(content_type__app_label="users", codename="change_user") + response = self.client.post( + reverse("admin:auth_group_add"), + {"name": "Trusted group", "permissions": [permission.pk], "_save": "Save"}, + ) + self.assertEqual(response.status_code, 302) + group = Group.objects.get(name="Trusted group") + self.assertEqual(list(group.permissions.all()), [permission]) + response = self.client.post( + reverse("admin:auth_group_change", args=[group.pk]), + {"name": "Renamed trusted group", "permissions": [], "_save": "Save"}, + ) + self.assertEqual(response.status_code, 302) + group.refresh_from_db() + self.assertEqual(group.name, "Renamed trusted group") + self.assertFalse(group.permissions.exists()) diff --git a/apps/agreements/tests/test_group_delete_security.py b/apps/agreements/tests/test_group_delete_security.py new file mode 100644 index 000000000..16ee6f9f5 --- /dev/null +++ b/apps/agreements/tests/test_group_delete_security.py @@ -0,0 +1,91 @@ +"""Group deletion cannot remove agreement roles or the memberships they confer.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.test import TestCase +from django.urls import reverse + +from apps.agreements.auth import ADMINISTRATORS, EDITORS, can_prepare, is_administrator + +if TYPE_CHECKING: + from django.db.models import QuerySet + + +class GroupDeletionSecurityTests(TestCase): + def setUp(self) -> None: + users = get_user_model().objects + self.staff = users.create_user("group-deleter", is_staff=True) + self.administrators = Group.objects.get_or_create(name=ADMINISTRATORS)[0] + self.editors = Group.objects.get_or_create(name=EDITORS)[0] + self.ordinary_group = Group.objects.create(name="Delegated group") + self.administrator = users.create_user("agreement-administrator") + self.administrator.groups.add(self.administrators) + self.editor = users.create_user("agreement-editor") + self.editor.groups.add(self.editors) + self.client.force_login(self.staff) + + def delegated_permissions(self) -> dict[str, QuerySet[Permission]]: + auth_permissions = Permission.objects.filter(content_type__app_label="auth") + return { + "view and delete groups": auth_permissions.filter(codename__in=("view_group", "delete_group")), + "all auth permissions": auth_permissions, + } + + def groups(self) -> tuple[Group, Group, Group]: + return (self.administrators, self.editors, self.ordinary_group) + + def assert_groups_and_roles_intact(self) -> None: + for group in self.groups(): + self.assertTrue(Group.objects.filter(pk=group.pk, name=group.name).exists()) + self.assertTrue(is_administrator(self.administrator)) + self.assertTrue(can_prepare(self.editor)) + + def test_delegated_staff_cannot_delete_groups_directly(self) -> None: + for label, permissions in self.delegated_permissions().items(): + self.staff.user_permissions.set(permissions) + for group in self.groups(): + with self.subTest(permissions=label, group=group.name): + url = reverse("admin:auth_group_delete", args=[group.pk]) + self.assertEqual(self.client.get(url).status_code, 403) + self.assertEqual(self.client.post(url, {"post": "yes"}).status_code, 403) + self.assert_groups_and_roles_intact() + + def test_delegated_staff_cannot_bulk_delete_groups(self) -> None: + for label, permissions in self.delegated_permissions().items(): + self.staff.user_permissions.set(permissions) + with self.subTest(permissions=label): + response = self.client.post( + reverse("admin:auth_group_changelist"), + { + "action": "delete_selected", + "_selected_action": [group.pk for group in self.groups()], + "post": "yes", + }, + ) + # Without any permitted action the forged POST only renders the changelist. + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.context["action_form"]) + self.assert_groups_and_roles_intact() + + def test_superuser_can_delete_ordinary_groups(self) -> None: + root = get_user_model().objects.create_superuser("identity-admin", "root@example.com", "password") + self.client.force_login(root) + retired = Group.objects.create(name="Retired group") + root.groups.add(retired) + response = self.client.post(reverse("admin:auth_group_delete", args=[retired.pk]), {"post": "yes"}) + self.assertEqual(response.status_code, 302) + self.assertFalse(Group.objects.filter(pk=retired.pk).exists()) + response = self.client.post( + reverse("admin:auth_group_changelist"), + {"action": "delete_selected", "_selected_action": [self.ordinary_group.pk], "post": "yes"}, + ) + self.assertEqual(response.status_code, 302) + self.assertFalse(Group.objects.filter(pk=self.ordinary_group.pk).exists()) + for group in (self.administrators, self.editors): + self.assertTrue(Group.objects.filter(pk=group.pk, name=group.name).exists()) + self.assertTrue(is_administrator(self.administrator)) + self.assertTrue(can_prepare(self.editor)) diff --git a/apps/agreements/tests/test_link_privacy.py b/apps/agreements/tests/test_link_privacy.py new file mode 100644 index 000000000..1c348f5ef --- /dev/null +++ b/apps/agreements/tests/test_link_privacy.py @@ -0,0 +1,81 @@ +from html.parser import HTMLParser +from typing import TYPE_CHECKING, cast +from urllib.parse import urlsplit + +from django.test import TestCase +from django.urls import reverse + +from apps.agreements import workflow +from apps.agreements.tests.test_agreements import make_officer, make_terms, offer_contract + +if TYPE_CHECKING: + from apps.agreements.models import TermsVersion + + +class ScriptSources(HTMLParser): + def __init__(self, html: str) -> None: + super().__init__() + self.hosts: set[str | None] = set() + self.feed(html) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "script": + source = dict(attrs).get("src") + if source: + self.hosts.add(urlsplit(source).hostname) + + +class SigningLinkPrivacyTests(TestCase): + def test_token_pages_do_not_load_tracking_scripts_in_any_signing_state(self) -> None: + officer = make_officer() + terms = make_terms() + agreement = offer_contract(officer, terms=terms) + _, token = workflow.create_signing_link(agreement, name="Customer", email="customer@example.com", user=officer) + url = reverse("agreements:sign_link", args=[token]) + terms_url = reverse("agreements:sign_link_terms", args=[token, cast("TermsVersion", terms.current_version).pk]) + responses = [ + self.client.get(url), + self.client.post(url, {"accept": "on"}), + self.client.get(terms_url), + self.client.post( + url, + { + "signer_name": "Customer", + "signer_title": "Director", + "accept": "on", + "document_sha256": agreement.document_sha256, + }, + ), + self.client.get(url), + ] + for state, response in zip(("offered", "invalid", "terms", "signed", "used"), responses, strict=True): + with self.subTest(state=state): + self.assertEqual(response.status_code, 410 if state == "used" else 400 if state == "invalid" else 200) + scripts = ScriptSources(response.content.decode()) + self.assertNotIn("analytics.python.org", scripts.hosts) + self.assertNotIn("media.ethicalads.io", scripts.hosts) + + def test_token_download_and_terms_errors_do_not_load_trackers(self) -> None: + officer = make_officer() + agreement = offer_contract(officer) + _, token = workflow.create_signing_link(agreement, name="Customer", email="customer@example.com", user=officer) + other_terms = make_terms() + urls = ( + reverse("agreements:sign_link_document", args=[token, "unsupported"]), + reverse("agreements:sign_link_terms", args=[token, cast("TermsVersion", other_terms.current_version).pk]), + ) + for url in urls: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + scripts = ScriptSources(response.content.decode()) + self.assertNotIn("analytics.python.org", scripts.hosts) + self.assertNotIn("media.ethicalads.io", scripts.hosts) + + def test_public_pages_retain_analytics(self) -> None: + terms = make_terms() + terms.is_public = True + terms.save(update_fields=["is_public"]) + response = self.client.get(terms.get_absolute_url()) + self.assertEqual(response.status_code, 200) + self.assertIn("analytics.python.org", ScriptSources(response.content.decode()).hosts) diff --git a/apps/agreements/tests/test_order_group_roles.py b/apps/agreements/tests/test_order_group_roles.py new file mode 100644 index 000000000..6ab36b27d --- /dev/null +++ b/apps/agreements/tests/test_order_group_roles.py @@ -0,0 +1,221 @@ +"""Order management is granted by named groups, independently of customer rights.""" + +from typing import cast + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from django.urls import reverse + +from apps.agreements import documents as agreement_documents +from apps.agreements import workflow +from apps.agreements.auth import ADMINISTRATORS, EDITORS +from apps.agreements.models import Agreement, Order, Program +from apps.agreements.orders import documents +from apps.agreements.registry import get_kind +from apps.agreements.tests.catalog_data import make_program +from apps.agreements.tests.test_orders import make_officer, make_order, payload, reload + +User = get_user_model() + + +class OrderGroupRoleTests(TestCase): + def setUp(self) -> None: + self.editor = User.objects.create_user("editor", "editor@example.org", "password") + self.editors = Group.objects.get_or_create(name=EDITORS)[0] + self.editor.groups.add(self.editors) + self.administrator = make_officer() + self.customer = User.objects.create_user("customer", "customer@example.com", "password") + self.program = make_program(is_public=False) + self.order = make_order(self.editor, program=self.program, customer_account=self.customer) + self.offer_url = reverse("agreements:order_offer", args=[self.order.pk]) + self.quote_url = reverse("agreements:quote", args=[self.program.slug]) + self.create_url = reverse("agreements:order_create", args=[self.program.slug]) + + def test_both_groups_can_read_private_catalogs_and_all_order_queues(self) -> None: + for user in (self.editor, self.administrator): + with self.subTest(user=user.username): + self.client.force_login(user) + self.assertTrue(Program.visible_to(user).filter(pk=self.program.pk).exists()) + self.assertContains(self.client.get(self.program.get_absolute_url()), "Example Workshop Services") + self.assertContains(self.client.get(reverse("agreements:staff_orders")), self.order.legal_name) + self.assertContains(self.client.get(reverse("agreements:queue")), "Agreements") + self.assertEqual(self.client.get(reverse("agreements:terms_list")).status_code, 200) + self.assertEqual(self.client.get(self.quote_url, payload(["studio"])).status_code, 200) + self.assertContains(self.client.get(self.order.get_absolute_url()), "All orders") + + def test_editor_can_create_edit_and_discard_unoffered_orders(self) -> None: + self.client.force_login(self.editor) + response = self.client.post( + self.create_url, + payload(["studio"], customer_account_email=self.customer.email), + ) + self.assertEqual(response.status_code, 302) + created = Order.objects.exclude(pk=self.order.pk).get() + self.assertEqual(created.created_by, self.editor) + self.assertIsNone(created.customer_account) + response = self.client.post( + reverse("agreements:order_edit", args=[created.pk]), + payload( + ["studio"], + legal_name="Example Revised Workshops, Inc.", + customer_account_email=self.customer.email, + **{"studio-special_terms": "Example payment terms."}, + ), + ) + self.assertEqual(response.status_code, 302) + created.refresh_from_db() + self.assertEqual(created.legal_name, "Example Revised Workshops, Inc.") + self.assertEqual(created.agreements.get().special_terms, "Example payment terms.") + self.assertIsNone(created.customer_account) + self.assertEqual(self.client.post(reverse("agreements:order_delete", args=[created.pk])).status_code, 302) + self.assertFalse(Order.objects.filter(pk=created.pk).exists()) + + def test_editor_cannot_offer_or_sign_for_the_linked_customer(self) -> None: + self.client.force_login(self.editor) + self.assertTrue(self.order.can_edit(self.editor)) + self.assertFalse(self.order.can_offer(self.editor)) + page = self.client.get(self.order.get_absolute_url()) + self.assertContains(page, "Discard draft") + self.assertNotContains(page, "Make ready to sign") + self.assertNotContains(page, "Sign Order Form") + self.assertEqual(self.client.post(self.offer_url).status_code, 404) + self.assertEqual(self.client.post(reverse("agreements:order_sign", args=[self.order.pk])).status_code, 404) + self.assertIsNone(reload(self.order).agreement) + + def test_administrator_can_offer_for_another_customer(self) -> None: + self.client.force_login(self.administrator) + self.assertTrue(self.order.can_offer(self.administrator)) + self.assertContains(self.client.get(self.order.get_absolute_url()), "Make ready to sign") + self.assertEqual(self.client.post(self.offer_url).status_code, 302) + order = reload(self.order) + self.assertEqual(order.status, Agreement.Status.OFFERED) + self.assertEqual(cast("Agreement", order.agreement).counterparty_account, self.customer) + + def test_editor_cannot_revise_offered_documents_or_use_staff_signing_controls(self) -> None: + agreement = workflow.offer(get_kind("order"), self.order, user=self.administrator) + self.client.force_login(self.editor) + page = self.client.get(self.order.get_absolute_url()) + self.assertContains(page, "Agreement record") + for text in ( + "Make ready to sign", + "Discard draft", + "Edit this document", + "Send signing link", + "Record signed copy", + ): + self.assertNotContains(page, text) + for name in ("edit", "send_link", "countersign"): + with self.subTest(action=name): + response = self.client.post(reverse(f"agreements:{name}", args=[agreement.pk])) + self.assertEqual(response.status_code, 403) + self.assertEqual(self.client.post(self.offer_url).status_code, 404) + self.assertEqual(self.client.post(reverse("agreements:order_delete", args=[self.order.pk])).status_code, 404) + self.assertEqual(cast("Agreement", reload(self.order).agreement).document_sha256, agreement.document_sha256) + + def test_staff_superuser_and_direct_permissions_do_not_grant_management_access(self) -> None: + staff = User.objects.create_user("staff", "staff@example.org", "password", is_staff=True) + superuser = User.objects.create_superuser("root", "root@example.org", "password") + permitted = User.objects.create_user("permitted", "permitted@example.org", "password") + permission, _ = Permission.objects.get_or_create( + content_type=ContentType.objects.get_for_model(Agreement), + codename="manage_agreement", + defaults={"name": "Former agreement management permission"}, + ) + permitted.user_permissions.add(permission) + for user in (staff, superuser, permitted): + with self.subTest(user=user.username): + self.client.force_login(user) + self.assertFalse(self.order.can_view(user)) + self.assertFalse(self.order.can_edit(user)) + self.assertFalse(self.order.can_offer(user)) + self.assertFalse(Program.visible_to(user).filter(pk=self.program.pk).exists()) + self.assertEqual(self.client.get(reverse("agreements:staff_orders")).status_code, 403) + self.assertEqual(self.client.get(self.program.get_absolute_url()).status_code, 404) + self.assertEqual(self.client.get(self.quote_url, payload(["studio"])).status_code, 404) + self.assertEqual(self.client.post(self.create_url, payload(["studio"])).status_code, 404) + self.assertEqual(self.client.get(self.order.get_absolute_url()).status_code, 404) + self.assertEqual(self.client.post(self.offer_url).status_code, 404) + self.assertNotContains(self.client.get(reverse("agreements:program_list")), "All orders") + + def test_revoked_creator_loses_draft_and_offered_order_access(self) -> None: + self.editor.groups.add(Group.objects.get(name=ADMINISTRATORS)) + offered = make_order(self.editor, program=self.program, customer_account=self.customer) + agreement = workflow.offer(get_kind("order"), offered, user=self.editor) + self.client.force_login(self.editor) + self.assertContains(self.client.get(reverse("agreements:order_list")), self.order.legal_name) + self.editor.groups.clear() + self.assertNotContains(self.client.get(reverse("agreements:order_list")), self.order.legal_name) + self.assertFalse(agreement.can_view(self.editor)) + self.assertEqual(self.client.get(agreement.get_absolute_url()).status_code, 404) + for order in (self.order, offered): + with self.subTest(order=order.pk): + self.assertFalse(order.can_view(self.editor)) + self.assertEqual(self.client.get(order.get_absolute_url()).status_code, 404) + self.assertEqual( + self.client.get(reverse("agreements:order_document", args=[order.pk, "pdf"])).status_code, 404 + ) + for name in ("order_edit", "order_offer", "order_delete", "order_sign"): + response = self.client.post(reverse(f"agreements:{name}", args=[order.pk])) + self.assertEqual(response.status_code, 404) + self.assertEqual( + self.client.get(self.quote_url, {"order": order.pk, **payload(["studio"])}).status_code, 404 + ) + self.assertEqual(self.client.get(self.program.get_absolute_url()).status_code, 404) + self.assertEqual(self.client.get(self.quote_url, payload(["studio"])).status_code, 404) + + def test_editor_linked_to_the_order_keeps_customer_offer_rights(self) -> None: + own = make_order(self.editor, program=self.program) + self.client.force_login(self.editor) + self.assertTrue(own.can_offer(self.editor)) + self.assertContains(self.client.get(own.get_absolute_url()), "Make ready to sign") + self.assertEqual(self.client.post(reverse("agreements:order_offer", args=[own.pk])).status_code, 302) + self.assertEqual(reload(own).status, Agreement.Status.OFFERED) + + def test_linked_customer_can_offer_and_sign_without_any_group(self) -> None: + self.client.force_login(self.customer) + self.assertTrue(self.order.can_offer(self.customer)) + self.assertContains(self.client.get(self.order.get_absolute_url()), "Make ready to sign") + self.assertEqual(self.client.post(self.offer_url).status_code, 302) + agreement = cast("Agreement", reload(self.order).agreement) + page = self.client.get(self.order.get_absolute_url()) + self.assertContains(page, "Sign on python.org") + self.assertNotContains(page, "Send signing link") + self.assertEqual( + self.client.post( + reverse("agreements:sign", args=[agreement.pk]), + { + "signer_name": "Example Customer", + "signer_title": "Director", + "accept": "on", + "document_sha256": agreement.document_sha256, + }, + ).status_code, + 302, + ) + self.assertEqual(reload(self.order).status, Agreement.Status.SIGNED) + + def test_revoked_group_member_keeps_linked_customer_draft_rights(self) -> None: + own = make_order(self.editor, program=self.program) + self.editor.groups.remove(self.editors) + self.client.force_login(self.editor) + self.assertTrue(own.can_view(self.editor)) + self.assertTrue(own.can_edit(self.editor)) + self.assertTrue(own.can_offer(self.editor)) + page = self.client.get(reverse("agreements:order_list")) + self.assertContains(page, own.get_absolute_url()) + self.assertNotContains(page, self.order.get_absolute_url()) + self.assertEqual(self.client.get(self.quote_url, {"order": own.pk, **payload(["studio"])}).status_code, 200) + self.assertContains(self.client.get(own.get_absolute_url()), "Sign Order Form") + response = self.client.post( + reverse("agreements:order_sign", args=[own.pk]), + { + "signer_name": "Example Customer", + "signer_title": "Director", + "accept": "on", + "document_sha256": agreement_documents.sha256(documents.compose_order_form_markdown(own)), + }, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(reload(own).status, Agreement.Status.SIGNED) diff --git a/apps/agreements/tests/test_orders.py b/apps/agreements/tests/test_orders.py new file mode 100644 index 000000000..258dbe831 --- /dev/null +++ b/apps/agreements/tests/test_orders.py @@ -0,0 +1,731 @@ +"""Order behavior against an entirely fictional, database-configured program.""" + +from __future__ import annotations + +from copy import deepcopy +from io import BytesIO +from typing import TYPE_CHECKING, Any, cast +from urllib.parse import urlsplit +from zipfile import ZipFile + +from defusedxml.ElementTree import fromstring +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from apps.agreements import documents as agreement_documents +from apps.agreements import workflow +from apps.agreements.auth import ADMINISTRATORS +from apps.agreements.models import Agreement, Order, OrderLine, Program, Terms, TermsVersion +from apps.agreements.orders import documents +from apps.agreements.registry import get_kind +from apps.agreements.tests.catalog_data import make_program + +if TYPE_CHECKING: + from collections.abc import Iterable + + from django.http import HttpResponseRedirect + from django.test.client import _MonkeyPatchedWSGIResponse + + from apps.users.models import User as UserModel + +User = get_user_model() +STUDIO: dict[str, Any] = {"tier": "plus"} +WORKSHOP: dict[str, Any] = {"tier": "basic", "addons": {"kit": {}}} + + +def make_order( + user: UserModel, + agreements: dict[str, dict[str, Any]] | None = None, + *, + program: Program, + **overrides: Any, +) -> Order: + order = Order.objects.create( + **{ + "program": program, + "created_by": user, + "customer_account": user, + "legal_name": "Example Workshops, Inc.", + "jurisdiction": "Delaware", + "entity_type": "corporation", + "address": "100 Example Street\nPortland, OR", + "covered_entities": "example-workspace", + "authorized_contacts": [{"name": "Ada Byron", "email": "ada@example.com"}], + "billing_contact_name": "Accounts Payable", + "billing_contact_email": "ap@example.com", + **overrides, + } + ) + for slug, selections in (agreements or {"studio": STUDIO}).items(): + OrderLine.objects.create(order=order, agreement=slug, **selections) + return order + + +def make_officer(username: str = "pat") -> UserModel: + officer = User.objects.create_user(username, f"{username}@example.org", "password") + officer.groups.add(Group.objects.get_or_create(name=ADMINISTRATORS)[0]) + return officer + + +def reload(order: Order) -> Order: + return Order.objects.select_related("agreement", "program").get(pk=order.pk) + + +def payload(agreements: Iterable[str] = ("studio", "workshop"), **overrides: Any) -> dict[str, Any]: + return { + "agreements": list(agreements), + "legal_name": "Example Workshops, Inc.", + "jurisdiction": "Delaware", + "entity_type": "corporation", + "address": "100 Example Street", + "covered_entities": "example-workspace", + "discount": "none", + "contact_1_name": "Ada Byron", + "contact_1_email": "ada@example.com", + "billing_contact_name": "AP", + "billing_contact_email": "ap@example.com", + "studio-tier": "basic", + "workshop-tier": "max", + **overrides, + } + + +class OrderFormDocumentTests(TestCase): + def setUp(self) -> None: + self.user = User.objects.create_user("ada", "ada@example.com", "password") + self.program = make_program() + + def test_recurring_and_one_time_fees_are_separate_without_repricing_snapshots(self) -> None: + order = make_order( + self.user, + { + "studio": {"tier": "basic"}, + "workshop": {"tier": "max", "addons": {"kit": {}, "sessions": {"hours": 2}}}, + }, + program=self.program, + discount="prepaid", + ) + self.client.force_login(self.user) + self.assertEqual(order.term_total_display, "$22,656") + markdown = documents.compose_order_form_markdown(order) + self.assertIn("**Total annual fees** | **$7,527**", markdown) + self.assertIn("Total one-time fees | $75", markdown) + self.assertNotIn("**Total annual fees** | **$7,602**", markdown) + detail = self.client.get(order.get_absolute_url()) + self.assertContains(detail, "$6,471 a year") + self.assertContains(detail, "$75 one-time") + listing = self.client.get(reverse("agreements:order_list")) + self.assertContains(listing, "$7,527 a year") + self.assertContains(listing, "$75 one-time") + + agreement = workflow.offer(get_kind("order"), order, user=self.user) + frozen = reload(order) + snapshots = list(frozen.agreements.values_list("pricing", flat=True)) + original = documents.order_form_markdown(frozen) + definition = deepcopy(self.program.definition) + definition["agreements"][0]["tiers"][0]["annual_fee"] = "9999" + Program.objects.filter(pk=self.program.pk).update(definition=definition) + frozen = reload(order) + self.assertEqual(frozen.fee_totals, {"annual": 7527, "one_time": 75}) + self.assertEqual(documents.order_form_markdown(reload(order)), original) + self.assertEqual(list(frozen.agreements.values_list("pricing", flat=True)), snapshots) + self.assertEqual(cast("Agreement", reload(order).agreement).document_markdown, agreement.document_markdown) + + def test_order_reference_stays_consistent_across_offers_and_withdrawal(self) -> None: + order = make_order(self.user, program=self.program) + agreement = workflow.offer(get_kind("order"), order, user=self.user) + self.assertEqual(agreement.reference, order.reference) + self.client.force_login(self.user) + self.assertContains(self.client.get(agreement.get_absolute_url()), f"{order.reference}") + workflow.withdraw(agreement, user=self.user) + agreement.refresh_from_db() + self.assertEqual(agreement.reference, order.reference) + replacement = workflow.offer(get_kind("order"), reload(order), user=self.user) + self.assertNotEqual(agreement.pk, replacement.pk) + self.assertEqual(replacement.reference, order.reference) + + def test_legacy_fingerprints_are_hidden_without_changing_the_signed_document(self) -> None: + order = make_order(self.user, {"studio": STUDIO, "workshop": WORKSHOP}, program=self.program) + agreement = workflow.offer(get_kind("order"), order, user=self.user) + versions = list(agreement.terms_versions.all()) + legacy = agreement.document_markdown + fingerprints = [] + for index, version in enumerate(versions): + digest = version.sha256 + fingerprint = ( + f"`{digest}`" if index else " ".join(digest[offset : offset + 16] for offset in range(0, 64, 16)) + ) + fingerprints.append(fingerprint.strip("`")) + citation = f"published at <{version.permanent_url}>" + legacy = legacy.replace(f"{citation}.", f"{citation}, SHA-256 {fingerprint}.") + # This is substantive contract text, not generated terms-identification metadata. + legacy += "\nThe service must use SHA-256 to verify uploaded files.\n" + workflow.edit( + agreement, markdown=legacy, note="Legacy document", user=self.user, base_sha256=agreement.document_sha256 + ) + agreement.refresh_from_db() + stored_hash = agreement.document_sha256 + _, token = workflow.create_signing_link(agreement, name="Ada", email=self.user.email, user=self.user) + link_page = self.client.get(reverse("agreements:sign_link", args=[token])) + self.assertNotIn(fingerprints[0], link_page.context["html"]) + workflow.sign(agreement, workflow.Signature("Ada", "Director", self.user.email), seen_sha256=stored_hash) + self.client.force_login(self.user) + for url in (agreement.get_absolute_url(), order.get_absolute_url()): + with self.subTest(url=url): + page = self.client.get(url) + for fingerprint in fingerprints: + self.assertNotIn(fingerprint, page.context["html"]) + self.assertContains(page, "use SHA-256 to verify uploaded files") + for version in versions: + self.assertContains(page, version.permanent_url) + download = self.client.get(reverse("agreements:document", args=[agreement.pk, "docx"])) + with ZipFile(BytesIO(download.content)) as archive: + xml = fromstring(archive.read("word/document.xml")) + downloaded_text = " ".join("".join(xml.itertext()).split()) + for fingerprint in fingerprints: + self.assertIn(fingerprint, downloaded_text) + agreement.refresh_from_db() + self.assertEqual(agreement.document_markdown, legacy) + self.assertEqual(agreement.document_sha256, stored_hash) + self.assertEqual(agreement.revisions.get(revision=agreement.revision).markdown, legacy) + + def test_customer_input_renders_as_literal_text(self) -> None: + order = make_order( + self.user, + program=self.program, + legal_name=" [x](javascript:alert(1)) *bold* | pipe", + covered_entities="\\input{/etc/passwd}\n$x$", + ) + html, _ = agreement_documents.render_html(documents.order_form_markdown(order)) + self.assertNotIn(" + {% endif %} @@ -21,11 +23,13 @@ + {% if not request.disable_tracking %} + {% endif %} {% stylesheet 'style' %} diff --git a/uv.lock b/uv.lock index 78e5d4760..21df0cbdc 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,12 @@ version = 1 revision = 3 requires-python = ">=3.14" resolution-markers = [ - "sys_platform == 'win32'", - "sys_platform == 'emscripten'", - "sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version < '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version < '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -61,6 +64,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" }, + { url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" }, + { url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, + { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, + { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -682,6 +749,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" }, ] +[[package]] +name = "django-stubs" +version = "5.2.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-stubs-ext" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/01/86c921e0e19c9fa7e705bf795998dbf55eb183e7be0342a3027dc1bcbc9f/django_stubs-5.2.9.tar.gz", hash = "sha256:c192257120b08785cfe6f2f1c91f1797aceae8e9daa689c336e52c91e8f6a493", size = 257970, upload-time = "2026-01-20T23:59:27.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/05/4c9c419b7051eb4b350100b086be6df487f968ab672d3d370f8ccf7c3746/django_stubs-5.2.9-py3-none-any.whl", hash = "sha256:2317a7130afdaa76f6ff7f623650d7f3bf1b6c86a60f95840e14e6ec6de1a7cd", size = 508656, upload-time = "2026-01-20T23:59:25.12Z" }, +] + +[[package]] +name = "django-stubs-ext" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/62/de532d58736d671b9ac0c0467909a9429758ae579d636e868ce74519566b/django_stubs_ext-6.1.2.tar.gz", hash = "sha256:2142da7fffbbe897ccecf9b4c97e09ea1a2e9291760b0a824b4c402b375ce6f9", size = 7221, upload-time = "2026-10-05T06:46:43.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/bb/393c3c1e24aaf37d62918ea50f3ea22e30d5f3162fc62f4c3bf3275e5ed3/django_stubs_ext-6.1.2-py3-none-any.whl", hash = "sha256:7334e687ab6dc78a6c3da90ab1ccb0e412efd827ea11de8d3f85adbad948abb1", size = 10850, upload-time = "2026-10-05T06:46:42.647Z" }, +] + [[package]] name = "django-tastypie" version = "0.15.1" @@ -940,6 +1035,76 @@ redis = [ { name = "redis" }, ] +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + [[package]] name = "lxml" version = "6.1.3" @@ -1098,6 +1263,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/8e/9108507501846c9ee2a1b277d35d1f92ae251852bbd1527f6dbf2665d94a/model_bakery-1.24.0-py3-none-any.whl", hash = "sha256:d1f739cfc43e8d403d04734a18c73c8e677f655dd08719e35342c2f5ada1d7e9", size = 30819, upload-time = "2026-06-23T21:55:21.911Z" }, ] +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "myst-parser" version = "5.0.0" @@ -1204,6 +1420,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/dd/49f5d0aa985c14218da8759ec4a93390b683c92fd9081d5b96383d48db6e/panflute-2.3.1-py3-none-any.whl", hash = "sha256:e44afd875b7b17ffebbbe58282849df06d9f1b20a45a2f933cd51bdcf4e89130", size = 36714, upload-time = "2024-03-20T05:47:38.375Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pilkit" version = "3.0" @@ -1508,9 +1733,11 @@ dev = [ { name = "coverage" }, { name = "ddt" }, { name = "django-debug-toolbar" }, + { name = "django-stubs" }, { name = "factory-boy" }, { name = "faker" }, { name = "model-bakery" }, + { name = "mypy" }, { name = "pyproject-fmt" }, { name = "responses" }, { name = "ruff" }, @@ -1587,9 +1814,11 @@ dev = [ { name = "coverage" }, { name = "ddt" }, { name = "django-debug-toolbar", specifier = "==8.0.0" }, + { name = "django-stubs", specifier = "==5.2.9" }, { name = "factory-boy", specifier = "==3.3.3" }, { name = "faker", specifier = "==40.38.0" }, { name = "model-bakery", specifier = "==1.24.0" }, + { name = "mypy", specifier = "==2.4.0" }, { name = "pyproject-fmt", specifier = "==2.29.4" }, { name = "responses", specifier = "==0.26.3" }, { name = "ruff", specifier = "==0.16.7" }, @@ -1952,6 +2181,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"