From e763fe672f12b1a2c4409ffc195e50622837175a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 12 Oct 2026 15:04:16 -0500 Subject: [PATCH 1/6] pre-select base services for the chosen tier --- apps/agreements/orders/forms.py | 7 ++++++- apps/agreements/static/agreements/package-builder.js | 5 +++++ .../templates/agreements/orders/_family.html | 4 ++-- .../templates/agreements/orders/order_form.html | 2 +- apps/agreements/tests/test_orders.py | 12 ++++++++++++ 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/agreements/orders/forms.py b/apps/agreements/orders/forms.py index a384fcc42..41dbf8e06 100644 --- a/apps/agreements/orders/forms.py +++ b/apps/agreements/orders/forms.py @@ -196,7 +196,12 @@ def __init__( cast("forms.MultipleChoiceField", self.fields["services"]).choices = [ (service.key, service.name) for service in agreement.services ] - self.initial["services"] = line.services if line else [] + # New packages start with every base service the tier offers; the builder keeps unedited + # services in step with tier changes. + self.services_follow_tier = line is None and not self.is_bound + self.initial["services"] = ( + line.services if line else [service.key for service in agreement.services_at(self.initial["tier"])] + ) if staff: self.fields["special_terms"] = forms.CharField( required=False, diff --git a/apps/agreements/static/agreements/package-builder.js b/apps/agreements/static/agreements/package-builder.js index c29613820..302949a49 100644 --- a/apps/agreements/static/agreements/package-builder.js +++ b/apps/agreements/static/agreements/package-builder.js @@ -31,6 +31,7 @@ include: el.querySelector(".pb-include-input"), radios: $$(".pb-tier-input", el), services: $$(".pb-base-service", el), + servicesFollowTier: "servicesFollowTier" in el.dataset, extraGroup, extras: extraGroup ? $$(".pb-extra", extraGroup) : [], }; @@ -134,6 +135,8 @@ const allowedTiers = row.dataset.serviceTiers.split(" ").filter(Boolean); const available = !allowedTiers.length || allowedTiers.includes(serviceTierKey); const box = serviceBox(row); + // Base services the customer hasn't edited track what the chosen tier offers. + if (tier && family.servicesFollowTier && !box.dataset.edited) box.checked = available; row.hidden = !available && !box.checked; box.disabled = !tier || (!available && !box.checked); const conflict = Boolean(tier) && !available && box.checked; @@ -815,6 +818,8 @@ } 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.closest(".pb-base-service")) { + target.dataset.edited = "true"; } } else if (target.name !== "discount") { return; diff --git a/apps/agreements/templates/agreements/orders/_family.html b/apps/agreements/templates/agreements/orders/_family.html index abadf1748..73c484853 100644 --- a/apps/agreements/templates/agreements/orders/_family.html +++ b/apps/agreements/templates/agreements/orders/_family.html @@ -1,5 +1,5 @@ {% load agreements %}{% with agreement=aform.agreement tier_options=aform.tier_options %} -
+

{{ agreement.short_name }}

@@ -35,7 +35,7 @@

{{ agreement.short_name
Base services for {{ agreement.short_name }} -

Select only the base services you want. Selecting none is allowed. The Service Tier fee stays the same regardless of your base-service selection.

+

Every base service your tier offers starts selected. Deselect any you don't need; the Service Tier fee stays the same either way.

{% if show_errors and aform.services.errors %}
{{ aform.services.errors }}
{% endif %}
{% for service, tiers in agreement.service_rows %} diff --git a/apps/agreements/templates/agreements/orders/order_form.html b/apps/agreements/templates/agreements/orders/order_form.html index ec0d95405..ef28b3ce0 100644 --- a/apps/agreements/templates/agreements/orders/order_form.html +++ b/apps/agreements/templates/agreements/orders/order_form.html @@ -228,4 +228,4 @@

Agreements for this package

{% endblock %} -{% block extra_js %}{{ builder.parameter_conditions|json_script:"order-parameter-conditions" }}{% endblock %} +{% block extra_js %}{{ builder.parameter_conditions|json_script:"order-parameter-conditions" }}{% endblock %} diff --git a/apps/agreements/tests/test_orders.py b/apps/agreements/tests/test_orders.py index e33640a7c..e52d1608e 100644 --- a/apps/agreements/tests/test_orders.py +++ b/apps/agreements/tests/test_orders.py @@ -501,6 +501,18 @@ def test_one_order_combines_agreements_in_catalog_order(self) -> None: self.assertEqual(order.agreements.get(agreement="workshop").tier, "max") self.assertEqual(order.customer_account, self.user) + def test_new_packages_preselect_only_services_the_default_tier_offers(self) -> None: + page = self.client.get(self.url).content.decode() + self.assertIn('id="studio-service-access" name="studio-services" value="access" checked', page) + self.assertIn('id="studio-service-planning" name="studio-services" value="planning">', page) + self.assertIn("data-services-follow-tier", page) + + order = make_order(self.user, {"studio": {"tier": "plus", "services": []}}, program=self.program) + page = self.client.get(reverse("agreements:order_edit", args=[order.pk])).content.decode() + self.assertIn('id="studio-service-access" name="studio-services" value="access">', page) + studio_opening_tag = page.split('id="family-studio"', 1)[1].split(">", 1)[0] + self.assertNotIn("data-services-follow-tier", studio_opening_tag) + def test_selected_services_persist_and_render_without_unselected_scope(self) -> None: response = self.client.post( self.url, From eed29afaca29e75ac5f6c59c7ec147b1d0736068 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 12 Oct 2026 15:05:19 -0500 Subject: [PATCH 2/6] show all response targets and extras on overview --- apps/agreements/orders/catalog.py | 11 +++ apps/agreements/orders/views.py | 27 +++++++ .../static/agreements/package-builder.css | 78 +++++++++++++++++-- .../templates/agreements/orders/base.html | 2 +- .../templates/agreements/orders/overview.html | 54 +++++++++++-- apps/agreements/tests/test_orders.py | 16 ++++ 6 files changed, 172 insertions(+), 16 deletions(-) diff --git a/apps/agreements/orders/catalog.py b/apps/agreements/orders/catalog.py index 22f8b2f84..c7e504d54 100644 --- a/apps/agreements/orders/catalog.py +++ b/apps/agreements/orders/catalog.py @@ -22,6 +22,8 @@ CURRENCY_WHOLE_DIGITS = 16 CURRENCY_DECIMAL_PLACES = 2 CHOICE_PAIR_SIZE = 2 +HOURS_PER_BUSINESS_DAY = 8 +BUSINESS_TIME = re.compile(r"^\s*(\d+)\s+business\s+(hour|day)s?\s*$", re.IGNORECASE) RULE_FIELDS = { "fixed": {"amount"}, @@ -45,6 +47,15 @@ class Tier: includes: str = "" +def business_hours(target: str) -> int | None: + """Convert a response target such as "2 Business Days" to Coverage Window hours, if it has that form.""" + match = BUSINESS_TIME.match(target) + if not match: + return None + count = int(match[1]) + return count * HOURS_PER_BUSINESS_DAY if match[2].lower() == "day" else count + + @dataclass(frozen=True) class Service: """A service available at every tier or at an explicit subset.""" diff --git a/apps/agreements/orders/views.py b/apps/agreements/orders/views.py index 4ef07de50..27bb2f2ef 100644 --- a/apps/agreements/orders/views.py +++ b/apps/agreements/orders/views.py @@ -24,6 +24,7 @@ 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.catalog import business_hours from apps.agreements.orders.forms import OrderBuilder from apps.agreements.orders.pricing import build_quote, money from apps.agreements.registry import get_kind @@ -36,9 +37,11 @@ from django.db.models import QuerySet from django.http import HttpRequest, HttpResponse + from apps.agreements.orders.catalog import Agreement from apps.users.models import User KIND = "order" +SEVERITIES = (("Critical", "P1"), ("High", "P2"), ("Medium", "P3"), ("Low", "P4")) def _orders() -> QuerySet[Order]: @@ -86,6 +89,29 @@ def _program_or_404(request: HttpRequest, slug: str) -> Program: return get_object_or_404(Program.visible_to(request.user), slug=slug) +def _response_ladders(agreements: list[Agreement]) -> list[list[dict[str, Any]]]: + """Scale every response target against the slowest one in the program, so agreements compare too.""" + hours = [[[business_hours(target) for target in tier.response_targets] for tier in a.tiers] for a in agreements] + slowest = max((h for agreement in hours for tier in agreement for h in tier if h), default=0) + return [ + [ + { + "name": name, + "code": code, + "cells": [ + { + "target": tier.response_targets[index], + "bar": round(100 * tier_hours[index] / slowest, 1) if slowest and tier_hours[index] else None, + } + for tier, tier_hours in zip(agreement.tiers, agreement_hours, strict=True) + ], + } + for index, (name, code) in enumerate(SEVERITIES) + ] + for agreement, agreement_hours in zip(agreements, hours, strict=True) + ] + + @never_cache @require_GET def program_list(request: HttpRequest) -> HttpResponse: @@ -113,6 +139,7 @@ def program_detail(request: HttpRequest, slug: str) -> HttpResponse: "program": program, "catalog": program.catalog, "agreements": agreements, + "families": list(zip(agreements, _response_ladders(agreements), strict=True)), "under_review": any(agreement.terms.under_review for agreement in agreements), "orders": _order_rows(_own_orders(request.user).filter(program=program)[:5]), "nav": "programs", diff --git a/apps/agreements/static/agreements/package-builder.css b/apps/agreements/static/agreements/package-builder.css index c3c65a67c..525f46bbf 100644 --- a/apps/agreements/static/agreements/package-builder.css +++ b/apps/agreements/static/agreements/package-builder.css @@ -2,10 +2,6 @@ /* ---------- 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; } @@ -26,9 +22,79 @@ .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; } +.program-overview .po-list-title { margin: 1em 0 0.15em; font-size: 1.0625em; } +.program-overview .agreement-services { margin-bottom: 0.4em; } +.program-overview .po-list { margin: 0 0 1em; font-size: 0.9375em; line-height: 1.5; } +.program-overview .po-list dt { margin-top: 0.5em; border: 0; font-family: SourceSansProBold, Arial, sans-serif; color: #222; } +.program-overview .po-list dd { margin: 0; padding: 0; border: 0; color: #555; } +.program-overview .po-list-note { margin-left: 0.35em; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.9em; color: #2b5b84; } + +/* Tier comparison: plan columns, with response targets drawn to one program-wide scale. */ +.program-overview .po-plans { + --po-blue: #3776ab; + --po-blue-dark: #2b5b84; + --po-blue-quiet: #a9c4dc; + --po-yellow: #ffd343; + --po-line: #e6e8ea; + --po-col: #f3f8fc; + width: 100%; + margin: 0.75em 0 1.75em; + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; + font-variant-numeric: tabular-nums; +} +.program-overview .po-plans tr { background: none; } +.program-overview .po-plans :is(th, td) { padding: 0.55em 0.9em; background: none; border: 0; text-align: left; vertical-align: middle; transition: background-color 180ms cubic-bezier(0.22, 1, 0.36, 1); } +.program-overview .po-plans tbody th { width: 30%; padding-left: 0; font-family: SourceSansProRegular, Arial, sans-serif; color: #444; } +.program-overview .po-plans thead :is(th, td) { border-bottom: 1px solid #caccce; } +.program-overview .po-plan { padding-top: 0.85em; padding-bottom: 0.9em; border-top: 3px solid var(--po-line); vertical-align: bottom; transition: background-color 180ms cubic-bezier(0.22, 1, 0.36, 1), border-color 180ms cubic-bezier(0.22, 1, 0.36, 1); } +.program-overview .po-plan-name { display: block; font-family: SourceSansProBold, Arial, sans-serif; font-size: 0.9375em; color: var(--po-blue-dark); } +.program-overview .po-plan-fee { display: block; margin-top: 0.1em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.75em; line-height: 1.1; color: #222; } +.program-overview .po-plan-unit { display: block; margin-top: 0.15em; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.875em; line-height: 1.3; color: #666; } +.program-overview .po-requests :is(th, td) { padding-block: 0.75em; border-bottom: 1px solid var(--po-line); } +.program-overview .po-requests td { font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.125em; color: #222; } +.program-overview .po-group th { padding: 1.5em 0 0.4em; font-family: SourceSansProBold, Arial, sans-serif; color: #222; } +.program-overview .po-group-note { margin-left: 0.4em; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.875em; color: #666; } +.program-overview .po-severity :is(th, td) { padding-block: 0.6em; border-top: 1px solid var(--po-line); } +.program-overview .po-sev { margin-left: 0.3em; padding: 0.1em 0.4em; border-radius: 3px; background-color: #eef1f4; color: #555; font-family: SourceSansProBold, Arial, sans-serif; font-size: 0.75em; vertical-align: 0.1em; } +.program-overview .po-target { display: block; color: #333; font-size: 0.9375em; white-space: nowrap; } +.program-overview .po-bar { display: block; width: 100%; height: 6px; margin-top: 0.4em; border-radius: 3px; overflow: hidden; } +.program-overview .po-bar-track { fill: #eef1f4; } +.program-overview .po-bar-fill { fill: var(--po-blue); transition: fill 180ms cubic-bezier(0.22, 1, 0.36, 1); } + +/* Pointing at any cell in a tier lights that tier's whole column and quiets the others. */ +.program-overview .po-plans:has(:is(thead th, td):hover) .po-bar-fill { fill: var(--po-blue-quiet); } +.program-overview .po-plans:has(tr > :nth-child(2):is(th, td):hover) tr > :nth-child(2), +.program-overview .po-plans:has(tr > :nth-child(3):is(th, td):hover) tr > :nth-child(3), +.program-overview .po-plans:has(tr > :nth-child(4):is(th, td):hover) tr > :nth-child(4), +.program-overview .po-plans:has(tr > :nth-child(5):is(th, td):hover) tr > :nth-child(5), +.program-overview .po-plans:has(tr > :nth-child(6):is(th, td):hover) tr > :nth-child(6) { background-color: var(--po-col); } +.program-overview .po-plans:has(tr > :nth-child(2):is(th, td):hover) tr > :nth-child(2) .po-bar-fill, +.program-overview .po-plans:has(tr > :nth-child(3):is(th, td):hover) tr > :nth-child(3) .po-bar-fill, +.program-overview .po-plans:has(tr > :nth-child(4):is(th, td):hover) tr > :nth-child(4) .po-bar-fill, +.program-overview .po-plans:has(tr > :nth-child(5):is(th, td):hover) tr > :nth-child(5) .po-bar-fill, +.program-overview .po-plans:has(tr > :nth-child(6):is(th, td):hover) tr > :nth-child(6) .po-bar-fill { fill: var(--po-blue-dark); } +.program-overview .po-plans:has(tr > :nth-child(2):is(th, td):hover) thead tr > :nth-child(2), +.program-overview .po-plans:has(tr > :nth-child(3):is(th, td):hover) thead tr > :nth-child(3), +.program-overview .po-plans:has(tr > :nth-child(4):is(th, td):hover) thead tr > :nth-child(4), +.program-overview .po-plans:has(tr > :nth-child(5):is(th, td):hover) thead tr > :nth-child(5), +.program-overview .po-plans:has(tr > :nth-child(6):is(th, td):hover) thead tr > :nth-child(6) { border-top-color: var(--po-yellow); } + +@media (prefers-reduced-motion: no-preference) { + .program-overview .po-bar-fill { transform-box: fill-box; transform-origin: left center; animation: po-bar-grow 720ms cubic-bezier(0.22, 1, 0.36, 1) both; } + .program-overview .po-severity:nth-child(4) .po-bar-fill { animation-delay: 70ms; } + .program-overview .po-severity:nth-child(5) .po-bar-fill { animation-delay: 140ms; } + .program-overview .po-severity:nth-child(6) .po-bar-fill { animation-delay: 210ms; } +} +@keyframes po-bar-grow { from { transform: scaleX(0); } } @media (max-width: 40em) { .program-overview .po-invite { grid-template-columns: minmax(0, 1fr); } + .program-overview .po-plans :is(th, td) { padding-inline: 0.45em; } + .program-overview .po-plans tbody th { width: 34%; padding-left: 0; line-height: 1.25; } + .program-overview .po-plan-fee { font-size: 1.25em; } + .program-overview .po-target { font-size: 0.875em; white-space: normal; line-height: 1.25; } .program-overview .po-invite-cta { justify-self: start; } } @@ -38,10 +104,6 @@ .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) ================================================================ */ diff --git a/apps/agreements/templates/agreements/orders/base.html b/apps/agreements/templates/agreements/orders/base.html index 510ca8305..3b60f80ea 100644 --- a/apps/agreements/templates/agreements/orders/base.html +++ b/apps/agreements/templates/agreements/orders/base.html @@ -3,7 +3,7 @@ {% block title %}Service orders{% endblock %} {% block body_attributes %}class="psf agreements agreement-orders default-page"{% endblock %} -{% block head %}{{ block.super }}{% endblock %} +{% block head %}{{ block.super }}{% endblock %} {% block crumbs %}
  • Service orders
  • {% endblock %} {% block sidebar %} diff --git a/apps/agreements/templates/agreements/orders/overview.html b/apps/agreements/templates/agreements/orders/overview.html index 3c5a5e813..325f1db65 100644 --- a/apps/agreements/templates/agreements/orders/overview.html +++ b/apps/agreements/templates/agreements/orders/overview.html @@ -23,26 +23,66 @@

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

    - {% for agreement in agreements %} + {% for agreement, ladder in families %}

    {{ agreement.short_name }}

    {% if agreement.tagline %}

    {{ agreement.tagline }}

    {% endif %}
    - +
    - + + + + {% for tier in agreement.tiers %} + + {% endfor %} + + - {% for tier in agreement.tiers %} - + + + {% for tier in agreement.tiers %}{% endfor %} + + + + + {% for row in ladder %} + + + {% for cell in row.cells %} + + {% endfor %} + {% endfor %}
    {{ agreement.short_name }} service tiers
    Service tierAnnual feeCritical (P1) responseRequests a year
    + {{ tier.name }} + {{ tier.annual_fee|money }} + a year +
    {{ tier.name }}{{ tier.annual_fee|money }}{{ tier.response_targets.0 }}{{ tier.fair_use }}
    Requests a year{{ tier.fair_use }}
    First response in business time; a shorter bar is a faster reply
    {{ row.name }} {{ row.code }} + {{ cell.target }} + {% if cell.bar is not None %}{% endif %} +
    {% if agreement.services %} -

    Base services available to select: {% for service, tiers in agreement.service_rows %}{{ service.name }}{% if tiers %} ({{ tiers }} only){% endif %}{% if not forloop.last %}; {% endif %}{% endfor %}. Your selection does not change the Service Tier fee.

    +

    Base services

    +

    Included in the Service Tier fee. Every base service your tier offers is selected when you build a package.

    +
    + {% 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 %} {% if agreement.addons %} -

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

    +

    Extras

    +

    Priced separately and added to the same Order Form.

    +
    + {% for addon in agreement.addons %} +
    {{ addon.name }}{% if addon.price_summary %} {{ addon.price_summary }}{% endif %}
    + {% if addon.description %}
    {{ addon.description }}
    {% endif %} + {% endfor %} +
    {% endif %}

    Provided under the {{ agreement.title }}.

    diff --git a/apps/agreements/tests/test_orders.py b/apps/agreements/tests/test_orders.py index e52d1608e..f2b2a6216 100644 --- a/apps/agreements/tests/test_orders.py +++ b/apps/agreements/tests/test_orders.py @@ -513,6 +513,22 @@ def test_new_packages_preselect_only_services_the_default_tier_offers(self) -> N studio_opening_tag = page.split('id="family-studio"', 1)[1].split(">", 1)[0] self.assertNotIn("data-services-follow-tier", studio_opening_tag) + def test_overview_scales_response_targets_across_the_whole_program(self) -> None: + definition = deepcopy(self.program.definition) + definition["agreements"][0]["tiers"][0]["response_targets"] = [ + "4 Business Hours", + "1 Business Day", + "2 Business Days", + "Next quarter", + ] + definition["agreements"][1]["tiers"][0]["response_targets"][0] = "4 Business Days" + Program.objects.filter(pk=self.program.pk).update(definition=definition) + response = self.client.get(reverse("agreements:program_detail", args=[self.program.slug])) + (_, studio), (_, workshop) = response.context["families"] + self.assertEqual([row["cells"][0]["bar"] for row in studio], [12.5, 25.0, 50.0, None]) + self.assertEqual(workshop[0]["cells"][0]["bar"], 100.0) + self.assertContains(response, "Next quarter") + def test_selected_services_persist_and_render_without_unselected_scope(self) -> None: response = self.client.post( self.url, From a37a47bb98190f85ef308f0b4f6a3d3161628585 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 12 Oct 2026 15:06:47 -0500 Subject: [PATCH 3/6] show staff follow-ups for add-ons with a staff_note --- apps/agreements/models/orders.py | 12 ++++++++++++ apps/agreements/orders/catalog.py | 4 +++- apps/agreements/orders/views.py | 2 ++ .../agreements/orders/order_detail.html | 10 ++++++++++ apps/agreements/tests/test_orders.py | 16 ++++++++++++++++ 5 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/agreements/models/orders.py b/apps/agreements/models/orders.py index 51ec0fa3e..49c670217 100644 --- a/apps/agreements/models/orders.py +++ b/apps/agreements/models/orders.py @@ -254,6 +254,18 @@ def tier_name(self) -> str: """Return the selected tier's name.""" return self.agreement_obj.tier(self.tier).name + @property + def staff_followups(self) -> list[dict[str, Any]]: + """Pair each selected add-on that has a staff note with its chosen parameter values.""" + followups = [] + for addon in self.agreement_obj.addons: + chosen = self.addons.get(addon.key) + if chosen is None or not addon.staff_note: + continue + values = [(param.label, chosen[param.key]) for param in addon.params if param.key in chosen] + followups.append({"name": addon.name, "note": addon.staff_note, "values": values}) + return followups + @cached_property def cited_terms(self) -> TermsVersion | None: """Return the fixed version after offering or the current draft candidate.""" diff --git a/apps/agreements/orders/catalog.py b/apps/agreements/orders/catalog.py index c7e504d54..d74af66d6 100644 --- a/apps/agreements/orders/catalog.py +++ b/apps/agreements/orders/catalog.py @@ -94,6 +94,7 @@ class AddOn: pricing: dict[str, Any] params: tuple[Param, ...] = () included_in_tiers: tuple[str, ...] = () + staff_note: str = "" @dataclass(frozen=True) @@ -474,7 +475,7 @@ 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", "params", "included_in_tiers", "pricing", "staff_note"}, {"key", "name", "description", "price_summary", "pricing"}, ) params = _params(row.get("params", []), f"{path}.params") @@ -487,6 +488,7 @@ def _addon(row: dict[str, Any], path: str, tier_keys: set[str]) -> AddOn: params=params, pricing=row["pricing"], included_in_tiers=_tier_refs(row.get("included_in_tiers", []), f"{path}.included_in_tiers", tier_keys), + staff_note=_text(row.get("staff_note", ""), f"{path}.staff_note", blank=True), ) diff --git a/apps/agreements/orders/views.py b/apps/agreements/orders/views.py index 27bb2f2ef..9d427a017 100644 --- a/apps/agreements/orders/views.py +++ b/apps/agreements/orders/views.py @@ -305,6 +305,8 @@ def order_detail(request: HttpRequest, pk: UUID, sign_form: SignForm | None = No else: context["html"], context["metadata_hidden"] = agreement_documents.render_agreement_preview(markdown) context["lines"] = order.agreement_list + if context["can_prepare"]: + context["staff_followups"] = [item for line in context["lines"] for item in line.staff_followups] if order.agreement: context.update(action_forms(request, order.agreement)) elif order.is_customer(user): diff --git a/apps/agreements/templates/agreements/orders/order_detail.html b/apps/agreements/templates/agreements/orders/order_detail.html index a85c9a862..503ad63ba 100644 --- a/apps/agreements/templates/agreements/orders/order_detail.html +++ b/apps/agreements/templates/agreements/orders/order_detail.html @@ -61,6 +61,16 @@

    {{ order.legal_name }}

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

    + {% if staff_followups %} +
    +

    Staff follow-up{% if order.status != "executed" %} once this order is executed{% endif %}:

    +
      + {% for item in staff_followups %} +
    • {{ item.name }}{% for label, value in item.values %} · {{ label }}: {{ value }}{% endfor %}. {{ item.note }}
    • + {% endfor %} +
    +
    + {% endif %} {% 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 %}

    diff --git a/apps/agreements/tests/test_orders.py b/apps/agreements/tests/test_orders.py index f2b2a6216..99bb5d775 100644 --- a/apps/agreements/tests/test_orders.py +++ b/apps/agreements/tests/test_orders.py @@ -375,6 +375,22 @@ def test_staff_queue_requires_login_and_agreement_group(self) -> None: self.assertEqual(response.status_code, 302) self.assertIn("next=" + url, cast("HttpResponseRedirect", response).url) + def test_staff_see_add_on_follow_ups_that_customers_do_not(self) -> None: + definition = deepcopy(self.program.definition) + definition["agreements"][1]["addons"][3]["staff_note"] = "Record the units in the scheduling system." + Program.objects.filter(pk=self.program.pk).update(definition=definition) + order = make_order( + self.customer, + {"workshop": {"tier": "basic", "addons": {"batch": {"units": 12}}}}, + program=self.program, + ) + url = order.get_absolute_url() + self.assertContains( + self.client.get(url), "Batch preparation · Units: 12. Record the units in the scheduling system." + ) + self.client.force_login(self.customer) + self.assertNotContains(self.client.get(url), "Record the units") + def post(self, **overrides: Any) -> _MonkeyPatchedWSGIResponse: return self.client.post( reverse("agreements:order_create", args=[self.program.slug]), payload(["studio"], **overrides) From 1df5572b53385144de6b76c39c253254714beef0 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 12 Oct 2026 15:30:09 -0500 Subject: [PATCH 4/6] narrow response hours for mypy in _response_ladders --- apps/agreements/orders/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/agreements/orders/views.py b/apps/agreements/orders/views.py index 9d427a017..3f5c2fbda 100644 --- a/apps/agreements/orders/views.py +++ b/apps/agreements/orders/views.py @@ -101,7 +101,7 @@ def _response_ladders(agreements: list[Agreement]) -> list[list[dict[str, Any]]] "cells": [ { "target": tier.response_targets[index], - "bar": round(100 * tier_hours[index] / slowest, 1) if slowest and tier_hours[index] else None, + "bar": round(100 * h / slowest, 1) if slowest and (h := tier_hours[index]) else None, } for tier, tier_hours in zip(agreement.tiers, agreement_hours, strict=True) ], From b668368581c6229488484a892afb62ac4ed28846 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 12 Oct 2026 15:31:49 -0500 Subject: [PATCH 5/6] fix tier hover border and compare table overflow --- apps/agreements/static/agreements/package-builder.css | 4 ++-- apps/agreements/templates/agreements/orders/base.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/agreements/static/agreements/package-builder.css b/apps/agreements/static/agreements/package-builder.css index 525f46bbf..4b764ba05 100644 --- a/apps/agreements/static/agreements/package-builder.css +++ b/apps/agreements/static/agreements/package-builder.css @@ -48,7 +48,7 @@ .program-overview .po-plans :is(th, td) { padding: 0.55em 0.9em; background: none; border: 0; text-align: left; vertical-align: middle; transition: background-color 180ms cubic-bezier(0.22, 1, 0.36, 1); } .program-overview .po-plans tbody th { width: 30%; padding-left: 0; font-family: SourceSansProRegular, Arial, sans-serif; color: #444; } .program-overview .po-plans thead :is(th, td) { border-bottom: 1px solid #caccce; } -.program-overview .po-plan { padding-top: 0.85em; padding-bottom: 0.9em; border-top: 3px solid var(--po-line); vertical-align: bottom; transition: background-color 180ms cubic-bezier(0.22, 1, 0.36, 1), border-color 180ms cubic-bezier(0.22, 1, 0.36, 1); } +.program-overview .po-plans .po-plan { padding-top: 0.85em; padding-bottom: 0.9em; border-top: 3px solid var(--po-line); vertical-align: bottom; transition: background-color 180ms cubic-bezier(0.22, 1, 0.36, 1), border-color 180ms cubic-bezier(0.22, 1, 0.36, 1); } .program-overview .po-plan-name { display: block; font-family: SourceSansProBold, Arial, sans-serif; font-size: 0.9375em; color: var(--po-blue-dark); } .program-overview .po-plan-fee { display: block; margin-top: 0.1em; font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.75em; line-height: 1.1; color: #222; } .program-overview .po-plan-unit { display: block; margin-top: 0.15em; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.875em; line-height: 1.3; color: #666; } @@ -296,7 +296,7 @@ .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-scroll { position: relative; 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; } diff --git a/apps/agreements/templates/agreements/orders/base.html b/apps/agreements/templates/agreements/orders/base.html index 3b60f80ea..f9a18d1d7 100644 --- a/apps/agreements/templates/agreements/orders/base.html +++ b/apps/agreements/templates/agreements/orders/base.html @@ -3,7 +3,7 @@ {% block title %}Service orders{% endblock %} {% block body_attributes %}class="psf agreements agreement-orders default-page"{% endblock %} -{% block head %}{{ block.super }}{% endblock %} +{% block head %}{{ block.super }}{% endblock %} {% block crumbs %}
  • Service orders
  • {% endblock %} {% block sidebar %} From dc9833157d427f8f79ef30eac2af35fe2cb603cb Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Mon, 12 Oct 2026 15:46:47 -0500 Subject: [PATCH 6/6] drop staff follow-ups for catalog add-ons --- apps/agreements/models/orders.py | 12 ------------ apps/agreements/orders/catalog.py | 4 +--- apps/agreements/orders/views.py | 2 -- .../agreements/orders/order_detail.html | 10 ---------- apps/agreements/tests/test_orders.py | 16 ---------------- 5 files changed, 1 insertion(+), 43 deletions(-) diff --git a/apps/agreements/models/orders.py b/apps/agreements/models/orders.py index 49c670217..51ec0fa3e 100644 --- a/apps/agreements/models/orders.py +++ b/apps/agreements/models/orders.py @@ -254,18 +254,6 @@ def tier_name(self) -> str: """Return the selected tier's name.""" return self.agreement_obj.tier(self.tier).name - @property - def staff_followups(self) -> list[dict[str, Any]]: - """Pair each selected add-on that has a staff note with its chosen parameter values.""" - followups = [] - for addon in self.agreement_obj.addons: - chosen = self.addons.get(addon.key) - if chosen is None or not addon.staff_note: - continue - values = [(param.label, chosen[param.key]) for param in addon.params if param.key in chosen] - followups.append({"name": addon.name, "note": addon.staff_note, "values": values}) - return followups - @cached_property def cited_terms(self) -> TermsVersion | None: """Return the fixed version after offering or the current draft candidate.""" diff --git a/apps/agreements/orders/catalog.py b/apps/agreements/orders/catalog.py index d74af66d6..c7e504d54 100644 --- a/apps/agreements/orders/catalog.py +++ b/apps/agreements/orders/catalog.py @@ -94,7 +94,6 @@ class AddOn: pricing: dict[str, Any] params: tuple[Param, ...] = () included_in_tiers: tuple[str, ...] = () - staff_note: str = "" @dataclass(frozen=True) @@ -475,7 +474,7 @@ 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", "staff_note"}, + {"key", "name", "description", "price_summary", "params", "included_in_tiers", "pricing"}, {"key", "name", "description", "price_summary", "pricing"}, ) params = _params(row.get("params", []), f"{path}.params") @@ -488,7 +487,6 @@ def _addon(row: dict[str, Any], path: str, tier_keys: set[str]) -> AddOn: params=params, pricing=row["pricing"], included_in_tiers=_tier_refs(row.get("included_in_tiers", []), f"{path}.included_in_tiers", tier_keys), - staff_note=_text(row.get("staff_note", ""), f"{path}.staff_note", blank=True), ) diff --git a/apps/agreements/orders/views.py b/apps/agreements/orders/views.py index 3f5c2fbda..f6e989c3c 100644 --- a/apps/agreements/orders/views.py +++ b/apps/agreements/orders/views.py @@ -305,8 +305,6 @@ def order_detail(request: HttpRequest, pk: UUID, sign_form: SignForm | None = No else: context["html"], context["metadata_hidden"] = agreement_documents.render_agreement_preview(markdown) context["lines"] = order.agreement_list - if context["can_prepare"]: - context["staff_followups"] = [item for line in context["lines"] for item in line.staff_followups] if order.agreement: context.update(action_forms(request, order.agreement)) elif order.is_customer(user): diff --git a/apps/agreements/templates/agreements/orders/order_detail.html b/apps/agreements/templates/agreements/orders/order_detail.html index 503ad63ba..a85c9a862 100644 --- a/apps/agreements/templates/agreements/orders/order_detail.html +++ b/apps/agreements/templates/agreements/orders/order_detail.html @@ -61,16 +61,6 @@

    {{ order.legal_name }}

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

    - {% if staff_followups %} -
    -

    Staff follow-up{% if order.status != "executed" %} once this order is executed{% endif %}:

    -
      - {% for item in staff_followups %} -
    • {{ item.name }}{% for label, value in item.values %} · {{ label }}: {{ value }}{% endfor %}. {{ item.note }}
    • - {% endfor %} -
    -
    - {% endif %} {% 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 %}

    diff --git a/apps/agreements/tests/test_orders.py b/apps/agreements/tests/test_orders.py index 99bb5d775..f2b2a6216 100644 --- a/apps/agreements/tests/test_orders.py +++ b/apps/agreements/tests/test_orders.py @@ -375,22 +375,6 @@ def test_staff_queue_requires_login_and_agreement_group(self) -> None: self.assertEqual(response.status_code, 302) self.assertIn("next=" + url, cast("HttpResponseRedirect", response).url) - def test_staff_see_add_on_follow_ups_that_customers_do_not(self) -> None: - definition = deepcopy(self.program.definition) - definition["agreements"][1]["addons"][3]["staff_note"] = "Record the units in the scheduling system." - Program.objects.filter(pk=self.program.pk).update(definition=definition) - order = make_order( - self.customer, - {"workshop": {"tier": "basic", "addons": {"batch": {"units": 12}}}}, - program=self.program, - ) - url = order.get_absolute_url() - self.assertContains( - self.client.get(url), "Batch preparation · Units: 12. Record the units in the scheduling system." - ) - self.client.force_login(self.customer) - self.assertNotContains(self.client.get(url), "Record the units") - def post(self, **overrides: Any) -> _MonkeyPatchedWSGIResponse: return self.client.post( reverse("agreements:order_create", args=[self.program.slug]), payload(["studio"], **overrides)