Skip to content

Commit 6f626df

Browse files
committed
Merge remote-tracking branch 'origin/main' into sponsor-mgmt-ui
# Conflicts: # apps/users/views.py # docker-compose.yml # pydotorg/settings/local.py
2 parents 094e94d + 8ba3ecf commit 6f626df

78 files changed

Lines changed: 1306 additions & 537 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/dependabot.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ updates:
2626
cooldown:
2727
default-days: 7
2828
- package-ecosystem: docker
29-
directory: "/"
29+
directory: "/docker"
3030
groups:
3131
docker:
3232
patterns:

‎Makefile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ help: ## Display this help text
1010
# Docker State
1111
# =============================================================================
1212

13-
.state/docker-build-web: Dockerfile pyproject.toml uv.lock
13+
.state/docker-build-web: docker/Dockerfile pyproject.toml uv.lock
1414
docker compose build --force-rm web
1515
mkdir -p .state && touch .state/docker-build-web
1616

‎apps/agreements/orders/catalog.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
CURRENCY_WHOLE_DIGITS = 16
2323
CURRENCY_DECIMAL_PLACES = 2
2424
CHOICE_PAIR_SIZE = 2
25+
HOURS_PER_BUSINESS_DAY = 8
26+
BUSINESS_TIME = re.compile(r"^\s*(\d+)\s+business\s+(hour|day)s?\s*$", re.IGNORECASE)
2527

2628
RULE_FIELDS = {
2729
"fixed": {"amount"},
@@ -45,6 +47,15 @@ class Tier:
4547
includes: str = ""
4648

4749

50+
def business_hours(target: str) -> int | None:
51+
"""Convert a response target such as "2 Business Days" to Coverage Window hours, if it has that form."""
52+
match = BUSINESS_TIME.match(target)
53+
if not match:
54+
return None
55+
count = int(match[1])
56+
return count * HOURS_PER_BUSINESS_DAY if match[2].lower() == "day" else count
57+
58+
4859
@dataclass(frozen=True)
4960
class Service:
5061
"""A service available at every tier or at an explicit subset."""

‎apps/agreements/orders/forms.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,12 @@ def __init__(
196196
cast("forms.MultipleChoiceField", self.fields["services"]).choices = [
197197
(service.key, service.name) for service in agreement.services
198198
]
199-
self.initial["services"] = line.services if line else []
199+
# New packages start with every base service the tier offers; the builder keeps unedited
200+
# services in step with tier changes.
201+
self.services_follow_tier = line is None and not self.is_bound
202+
self.initial["services"] = (
203+
line.services if line else [service.key for service in agreement.services_at(self.initial["tier"])]
204+
)
200205
if staff:
201206
self.fields["special_terms"] = forms.CharField(
202207
required=False,

‎apps/agreements/orders/views.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from apps.agreements.forms.signing import SignForm
2525
from apps.agreements.models import Order, OrderLine, Program
2626
from apps.agreements.orders import documents
27+
from apps.agreements.orders.catalog import business_hours
2728
from apps.agreements.orders.forms import OrderBuilder
2829
from apps.agreements.orders.pricing import build_quote, money
2930
from apps.agreements.registry import get_kind
@@ -36,9 +37,11 @@
3637
from django.db.models import QuerySet
3738
from django.http import HttpRequest, HttpResponse
3839

40+
from apps.agreements.orders.catalog import Agreement
3941
from apps.users.models import User
4042

4143
KIND = "order"
44+
SEVERITIES = (("Critical", "P1"), ("High", "P2"), ("Medium", "P3"), ("Low", "P4"))
4245

4346

4447
def _orders() -> QuerySet[Order]:
@@ -86,6 +89,29 @@ def _program_or_404(request: HttpRequest, slug: str) -> Program:
8689
return get_object_or_404(Program.visible_to(request.user), slug=slug)
8790

8891

92+
def _response_ladders(agreements: list[Agreement]) -> list[list[dict[str, Any]]]:
93+
"""Scale every response target against the slowest one in the program, so agreements compare too."""
94+
hours = [[[business_hours(target) for target in tier.response_targets] for tier in a.tiers] for a in agreements]
95+
slowest = max((h for agreement in hours for tier in agreement for h in tier if h), default=0)
96+
return [
97+
[
98+
{
99+
"name": name,
100+
"code": code,
101+
"cells": [
102+
{
103+
"target": tier.response_targets[index],
104+
"bar": round(100 * h / slowest, 1) if slowest and (h := tier_hours[index]) else None,
105+
}
106+
for tier, tier_hours in zip(agreement.tiers, agreement_hours, strict=True)
107+
],
108+
}
109+
for index, (name, code) in enumerate(SEVERITIES)
110+
]
111+
for agreement, agreement_hours in zip(agreements, hours, strict=True)
112+
]
113+
114+
89115
@never_cache
90116
@require_GET
91117
def program_list(request: HttpRequest) -> HttpResponse:
@@ -113,6 +139,7 @@ def program_detail(request: HttpRequest, slug: str) -> HttpResponse:
113139
"program": program,
114140
"catalog": program.catalog,
115141
"agreements": agreements,
142+
"families": list(zip(agreements, _response_ladders(agreements), strict=True)),
116143
"under_review": any(agreement.terms.under_review for agreement in agreements),
117144
"orders": _order_rows(_own_orders(request.user).filter(program=program)[:5]),
118145
"nav": "programs",

‎apps/agreements/static/agreements/package-builder.css‎

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@
22

33
/* ---------- Program list and overview ---------- */
44

5-
.agreement-orders .tier-table { margin: 0.5em 0 1.3125em; }
6-
.agreement-orders .tier-table th,
7-
.agreement-orders .tier-table td { padding: 0.45em 0.6em; border-bottom: 1px solid #e6e8ea; }
8-
.agreement-orders .tier-table thead th { background-color: #e6e8ea; color: #444; font-family: SourceSansProBold, Arial, sans-serif; }
95
.agreement-orders .agreement-summary { border-top: 5px solid #caccce; padding-top: 0.5em; margin-top: 1.75em; }
106
.agreement-orders .agreement-summary h2 { margin-top: 0.25em; }
117
.agreement-orders .agreement-services { color: #666; font-size: 0.9em; }
@@ -26,9 +22,79 @@
2622
.program-overview .po-tagline { margin-bottom: 0.5em; }
2723
.program-overview .po-table-wrap { max-width: 100%; overflow-x: auto; }
2824
.program-overview .po-terms { font-size: 0.9em; color: #666; }
25+
.program-overview .po-list-title { margin: 1em 0 0.15em; font-size: 1.0625em; }
26+
.program-overview .agreement-services { margin-bottom: 0.4em; }
27+
.program-overview .po-list { margin: 0 0 1em; font-size: 0.9375em; line-height: 1.5; }
28+
.program-overview .po-list dt { margin-top: 0.5em; border: 0; font-family: SourceSansProBold, Arial, sans-serif; color: #222; }
29+
.program-overview .po-list dd { margin: 0; padding: 0; border: 0; color: #555; }
30+
.program-overview .po-list-note { margin-left: 0.35em; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.9em; color: #2b5b84; }
31+
32+
/* Tier comparison: plan columns, with response targets drawn to one program-wide scale. */
33+
.program-overview .po-plans {
34+
--po-blue: #3776ab;
35+
--po-blue-dark: #2b5b84;
36+
--po-blue-quiet: #a9c4dc;
37+
--po-yellow: #ffd343;
38+
--po-line: #e6e8ea;
39+
--po-col: #f3f8fc;
40+
width: 100%;
41+
margin: 0.75em 0 1.75em;
42+
table-layout: fixed;
43+
border-collapse: separate;
44+
border-spacing: 0;
45+
font-variant-numeric: tabular-nums;
46+
}
47+
.program-overview .po-plans tr { background: none; }
48+
.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); }
49+
.program-overview .po-plans tbody th { width: 30%; padding-left: 0; font-family: SourceSansProRegular, Arial, sans-serif; color: #444; }
50+
.program-overview .po-plans thead :is(th, td) { border-bottom: 1px solid #caccce; }
51+
.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); }
52+
.program-overview .po-plan-name { display: block; font-family: SourceSansProBold, Arial, sans-serif; font-size: 0.9375em; color: var(--po-blue-dark); }
53+
.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; }
54+
.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; }
55+
.program-overview .po-requests :is(th, td) { padding-block: 0.75em; border-bottom: 1px solid var(--po-line); }
56+
.program-overview .po-requests td { font-family: SourceSansProBold, Arial, sans-serif; font-size: 1.125em; color: #222; }
57+
.program-overview .po-group th { padding: 1.5em 0 0.4em; font-family: SourceSansProBold, Arial, sans-serif; color: #222; }
58+
.program-overview .po-group-note { margin-left: 0.4em; font-family: SourceSansProRegular, Arial, sans-serif; font-size: 0.875em; color: #666; }
59+
.program-overview .po-severity :is(th, td) { padding-block: 0.6em; border-top: 1px solid var(--po-line); }
60+
.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; }
61+
.program-overview .po-target { display: block; color: #333; font-size: 0.9375em; white-space: nowrap; }
62+
.program-overview .po-bar { display: block; width: 100%; height: 6px; margin-top: 0.4em; border-radius: 3px; overflow: hidden; }
63+
.program-overview .po-bar-track { fill: #eef1f4; }
64+
.program-overview .po-bar-fill { fill: var(--po-blue); transition: fill 180ms cubic-bezier(0.22, 1, 0.36, 1); }
65+
66+
/* Pointing at any cell in a tier lights that tier's whole column and quiets the others. */
67+
.program-overview .po-plans:has(:is(thead th, td):hover) .po-bar-fill { fill: var(--po-blue-quiet); }
68+
.program-overview .po-plans:has(tr > :nth-child(2):is(th, td):hover) tr > :nth-child(2),
69+
.program-overview .po-plans:has(tr > :nth-child(3):is(th, td):hover) tr > :nth-child(3),
70+
.program-overview .po-plans:has(tr > :nth-child(4):is(th, td):hover) tr > :nth-child(4),
71+
.program-overview .po-plans:has(tr > :nth-child(5):is(th, td):hover) tr > :nth-child(5),
72+
.program-overview .po-plans:has(tr > :nth-child(6):is(th, td):hover) tr > :nth-child(6) { background-color: var(--po-col); }
73+
.program-overview .po-plans:has(tr > :nth-child(2):is(th, td):hover) tr > :nth-child(2) .po-bar-fill,
74+
.program-overview .po-plans:has(tr > :nth-child(3):is(th, td):hover) tr > :nth-child(3) .po-bar-fill,
75+
.program-overview .po-plans:has(tr > :nth-child(4):is(th, td):hover) tr > :nth-child(4) .po-bar-fill,
76+
.program-overview .po-plans:has(tr > :nth-child(5):is(th, td):hover) tr > :nth-child(5) .po-bar-fill,
77+
.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); }
78+
.program-overview .po-plans:has(tr > :nth-child(2):is(th, td):hover) thead tr > :nth-child(2),
79+
.program-overview .po-plans:has(tr > :nth-child(3):is(th, td):hover) thead tr > :nth-child(3),
80+
.program-overview .po-plans:has(tr > :nth-child(4):is(th, td):hover) thead tr > :nth-child(4),
81+
.program-overview .po-plans:has(tr > :nth-child(5):is(th, td):hover) thead tr > :nth-child(5),
82+
.program-overview .po-plans:has(tr > :nth-child(6):is(th, td):hover) thead tr > :nth-child(6) { border-top-color: var(--po-yellow); }
83+
84+
@media (prefers-reduced-motion: no-preference) {
85+
.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; }
86+
.program-overview .po-severity:nth-child(4) .po-bar-fill { animation-delay: 70ms; }
87+
.program-overview .po-severity:nth-child(5) .po-bar-fill { animation-delay: 140ms; }
88+
.program-overview .po-severity:nth-child(6) .po-bar-fill { animation-delay: 210ms; }
89+
}
90+
@keyframes po-bar-grow { from { transform: scaleX(0); } }
2991

3092
@media (max-width: 40em) {
3193
.program-overview .po-invite { grid-template-columns: minmax(0, 1fr); }
94+
.program-overview .po-plans :is(th, td) { padding-inline: 0.45em; }
95+
.program-overview .po-plans tbody th { width: 34%; padding-left: 0; line-height: 1.25; }
96+
.program-overview .po-plan-fee { font-size: 1.25em; }
97+
.program-overview .po-target { font-size: 0.875em; white-space: normal; line-height: 1.25; }
3298
.program-overview .po-invite-cta { justify-self: start; }
3399
}
34100

@@ -38,10 +104,6 @@
38104
.agreement-orders .order-orgs { color: #666; font-size: 0.95em; }
39105
.agreement-orders .num { font-variant-numeric: tabular-nums; }
40106

41-
@media (max-width: 800px) {
42-
.agreement-orders .tier-table { display: block; overflow-x: auto; }
43-
}
44-
45107
/* ================================================================
46108
Package builder (order form)
47109
================================================================ */
@@ -234,7 +296,7 @@
234296
.package-builder .pb-extra-family + .pb-extra-family { margin-top: 1.5em; }
235297
.package-builder .pb-extra-family > h3 { margin: 0 0 0.75em; font-size: 1.125em; font-family: SourceSansProBold, Arial, sans-serif; }
236298
.package-builder #pb-extras-empty { padding: 1.5em 0; color: var(--pb-muted); }
237-
.package-builder .pb-comparison-scroll { max-width: 100%; overflow-x: auto; }
299+
.package-builder .pb-comparison-scroll { position: relative; max-width: 100%; overflow-x: auto; }
238300
.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; }
239301
.package-builder .pb-comparison-label { width: 38%; }
240302
.package-builder .pb-comparison :is(th, td) { padding: 0.65em; border-bottom: 1px solid var(--pb-line); text-align: left; vertical-align: top; }

‎apps/agreements/static/agreements/package-builder.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
include: el.querySelector(".pb-include-input"),
3232
radios: $$(".pb-tier-input", el),
3333
services: $$(".pb-base-service", el),
34+
servicesFollowTier: "servicesFollowTier" in el.dataset,
3435
extraGroup,
3536
extras: extraGroup ? $$(".pb-extra", extraGroup) : [],
3637
};
@@ -134,6 +135,8 @@
134135
const allowedTiers = row.dataset.serviceTiers.split(" ").filter(Boolean);
135136
const available = !allowedTiers.length || allowedTiers.includes(serviceTierKey);
136137
const box = serviceBox(row);
138+
// Base services the customer hasn't edited track what the chosen tier offers.
139+
if (tier && family.servicesFollowTier && !box.dataset.edited) box.checked = available;
137140
row.hidden = !available && !box.checked;
138141
box.disabled = !tier || (!available && !box.checked);
139142
const conflict = Boolean(tier) && !available && box.checked;
@@ -815,6 +818,8 @@
815818
} else if (target.classList.contains("pb-tier-input") && !family.include.checked) {
816819
family.include.checked = true;
817820
announce(`${family.name} added at the ${tierColumn(target).dataset.tierName} tier.`);
821+
} else if (target.closest(".pb-base-service")) {
822+
target.dataset.edited = "true";
818823
}
819824
} else if (target.name !== "discount") {
820825
return;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{% load agreements %}
2+
{# One navigation for every agreements page; `nav` marks the current item. #}
3+
<nav class="section-navigation sidebar-widget" aria-label="Agreements">
4+
<ul class="section-nav menu">
5+
{% if token %}
6+
<li><a href="{% url 'agreements:sign_link' token %}">Agreement to sign</a></li>
7+
{% else %}
8+
<li class="{% if nav == 'programs' %}current_item selected{% endif %}"><a href="{% url 'agreements:program_list' %}">Programs</a></li>
9+
{% if user.is_authenticated %}
10+
<li class="{% if nav == 'orders' %}current_item selected{% endif %}"><a href="{% url 'agreements:order_list' %}">Your orders</a></li>
11+
{% endif %}
12+
{% endif %}
13+
</ul>
14+
</nav>
15+
{% if not token and user|can_prepare_agreements %}
16+
<nav class="sidebar-widget staff-nav" aria-labelledby="staff-nav-title">
17+
<h2 class="widget-title" id="staff-nav-title">PSF staff</h2>
18+
<ul class="section-nav menu">
19+
<li class="{% if nav == 'queue' %}current_item selected{% endif %}"><a href="{% url 'agreements:queue' %}">Agreements</a></li>
20+
<li class="{% if nav == 'staff' %}current_item selected{% endif %}"><a href="{% url 'agreements:staff_orders' %}">All orders</a></li>
21+
<li class="{% if nav == 'custom' %}current_item selected{% endif %}"><a href="{% url 'agreements:custom_create' %}">New contract</a></li>
22+
<li class="{% if nav == 'terms' %}current_item selected{% endif %}"><a href="{% url 'agreements:terms_list' %}">Terms</a></li>
23+
</ul>
24+
</nav>
25+
{% endif %}

‎apps/agreements/templates/agreements/base.html‎

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
{% block page_title %}{% block title %}Agreements{% endblock %} | Python Software Foundation{% endblock %}
55
{% block og_title %}PSF Agreements{% endblock %}
66

7-
{% block body_attributes %}class="psf agreements"{% endblock %}
7+
{% block body_attributes %}class="psf agreements default-page"{% endblock %}
88

99
{% block section-logo %}<a href="/psf-landing/"><img class="psf-logo" src="{{ STATIC_URL }}img/psf-logo.png" alt="Python Software Foundation"></a>{% endblock %}
1010

@@ -27,17 +27,7 @@
2727

2828
{% block left_sidebar %}
2929
<aside class="left-sidebar">
30+
{% include "agreements/_nav.html" %}
3031
{% block sidebar %}{% endblock %}
31-
{% if user|can_prepare_agreements %}
32-
<div class="section-navigation sidebar-widget">
33-
<ul class="section-nav menu" role="menu">
34-
<li class="{% if nav == 'queue' %}current_item selected{% endif %}" role="menuitem"><a href="{% url 'agreements:queue' %}">Agreements</a></li>
35-
<li class="{% if nav == 'custom' %}current_item selected{% endif %}" role="menuitem"><a href="{% url 'agreements:custom_create' %}">New contract</a></li>
36-
<li class="{% if nav == 'terms' %}current_item selected{% endif %}" role="menuitem"><a href="{% url 'agreements:terms_list' %}">Terms</a></li>
37-
<li role="menuitem"><a href="{% url 'agreements:program_list' %}">Programs</a></li>
38-
<li role="menuitem"><a href="{% url 'agreements:staff_orders' %}">Orders</a></li>
39-
</ul>
40-
</div>
41-
{% endif %}
4232
</aside>
4333
{% endblock left_sidebar %}

‎apps/agreements/templates/agreements/detail.html‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,17 @@
33
{% block title %}{{ agreement.title }}: {{ agreement.counterparty_name }}{% endblock %}
44

55
{% block crumbs %}
6-
{% if can_prepare %}<li><a href="{% url 'agreements:queue' %}">Agreements</a><span class="prompt">&gt;&gt;&gt;</span></li>{% endif %}
7-
<li>{{ agreement.counterparty_name }}</li>
6+
{% if can_prepare %}
7+
<li><a href="{% url 'agreements:queue' %}">Agreements</a><span class="prompt">&gt;&gt;&gt;</span></li>
8+
<li>{{ agreement.counterparty_name }}</li>
9+
{% elif agreement.order %}
10+
<li><a href="{% url 'agreements:program_list' %}">Programs</a><span class="prompt">&gt;&gt;&gt;</span></li>
11+
<li><a href="{% url 'agreements:order_list' %}">Your orders</a><span class="prompt">&gt;&gt;&gt;</span></li>
12+
<li><a href="{{ agreement.order.get_absolute_url }}">{{ agreement.order.legal_name }}</a><span class="prompt">&gt;&gt;&gt;</span></li>
13+
<li>Agreement</li>
14+
{% else %}
15+
<li>{{ agreement.counterparty_name }}</li>
16+
{% endif %}
817
{% endblock %}
918

1019
{% block content %}

0 commit comments

Comments
 (0)