Skip to content

Commit 19551e5

Browse files
JacobCoffeeclaude
andcommitted
feat: legal clause CRUD with ordering in sponsor management UI
Full CRUD for legal clauses under More > Legal Clauses. List view shows clause text preview, benefit count, and up/down move buttons for ordering. Edit page shows how many benefits reference the clause. Delete confirms with impact warning. Removes legal clause CRUD from the "admin-only" list in the guide. Includes 10 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 64b048d commit 19551e5

File tree

9 files changed

+399
-1
lines changed

9 files changed

+399
-1
lines changed

apps/sponsors/manage/forms.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from apps.sponsors.models import (
99
SPONSOR_TEMPLATE_HELP_TEXT,
1010
EmailTargetableConfiguration,
11+
LegalClause,
1112
LogoPlacementConfiguration,
1213
ProvidedFileAssetConfiguration,
1314
ProvidedTextAssetConfiguration,
@@ -699,6 +700,23 @@ def clean(self):
699700
return cleaned
700701

701702

703+
class LegalClauseForm(forms.ModelForm):
704+
"""Form for creating and editing legal clauses."""
705+
706+
class Meta:
707+
"""Meta options."""
708+
709+
model = LegalClause
710+
fields = ["internal_name", "clause", "notes"]
711+
widgets = {
712+
"internal_name": forms.TextInput(attrs={"style": INPUT_STYLE}),
713+
"clause": forms.Textarea(attrs={"rows": 6, "style": INPUT_STYLE + "resize:vertical;"}),
714+
"notes": forms.Textarea(
715+
attrs={"rows": 3, "style": INPUT_STYLE + "resize:vertical;", "placeholder": "Internal notes..."}
716+
),
717+
}
718+
719+
702720
# Dispatcher mapping config type slugs to (model, form) tuples
703721
CONFIG_TYPES = {
704722
"logo_placement": (LogoPlacementConfiguration, LogoPlacementConfigForm, "Logo Placement"),

apps/sponsors/manage/tests.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from apps.sponsors.models import (
1616
Contract,
17+
LegalClause,
1718
Sponsor,
1819
SponsorBenefit,
1920
SponsorContact,
@@ -2367,3 +2368,81 @@ def test_sync_button_shown_on_benefit_edit(self):
23672368
"""Benefit edit page shows Sync button when sponsorships exist."""
23682369
response = self.client.get(reverse("manage_benefit_edit", args=[self.benefit.pk]))
23692370
self.assertContains(response, "Sync to Sponsorships")
2371+
2372+
2373+
class LegalClauseViewTests(SponsorManageTestBase):
2374+
"""Test legal clause CRUD views."""
2375+
2376+
def setUp(self):
2377+
super().setUp()
2378+
self.client.login(username="staff", password="pass")
2379+
self.clause = LegalClause.objects.create(
2380+
internal_name="Trademark Usage",
2381+
clause="Sponsor may use the Python trademark per PSF guidelines.",
2382+
notes="Standard clause",
2383+
)
2384+
2385+
def test_list_loads(self):
2386+
response = self.client.get(reverse("manage_legal_clauses"))
2387+
self.assertEqual(response.status_code, 200)
2388+
self.assertContains(response, "Trademark Usage")
2389+
2390+
def test_create_get(self):
2391+
response = self.client.get(reverse("manage_legal_clause_create"))
2392+
self.assertEqual(response.status_code, 200)
2393+
2394+
def test_create_post(self):
2395+
response = self.client.post(
2396+
reverse("manage_legal_clause_create"),
2397+
{"internal_name": "New Clause", "clause": "Some legal text.", "notes": ""},
2398+
)
2399+
self.assertEqual(response.status_code, 302)
2400+
self.assertTrue(LegalClause.objects.filter(internal_name="New Clause").exists())
2401+
2402+
def test_edit_get(self):
2403+
response = self.client.get(reverse("manage_legal_clause_edit", args=[self.clause.pk]))
2404+
self.assertEqual(response.status_code, 200)
2405+
self.assertContains(response, "Trademark Usage")
2406+
2407+
def test_edit_post(self):
2408+
response = self.client.post(
2409+
reverse("manage_legal_clause_edit", args=[self.clause.pk]),
2410+
{"internal_name": "Updated Name", "clause": "Updated text.", "notes": ""},
2411+
)
2412+
self.assertEqual(response.status_code, 302)
2413+
self.clause.refresh_from_db()
2414+
self.assertEqual(self.clause.internal_name, "Updated Name")
2415+
2416+
def test_delete_get(self):
2417+
response = self.client.get(reverse("manage_legal_clause_delete", args=[self.clause.pk]))
2418+
self.assertEqual(response.status_code, 200)
2419+
self.assertContains(response, "Trademark Usage")
2420+
2421+
def test_delete_post(self):
2422+
response = self.client.post(reverse("manage_legal_clause_delete", args=[self.clause.pk]))
2423+
self.assertEqual(response.status_code, 302)
2424+
self.assertFalse(LegalClause.objects.filter(pk=self.clause.pk).exists())
2425+
2426+
def test_move_up(self):
2427+
clause2 = LegalClause.objects.create(internal_name="Second Clause", clause="Text.")
2428+
self.client.post(
2429+
reverse("manage_legal_clause_move", args=[clause2.pk]),
2430+
{"direction": "up"},
2431+
)
2432+
clause2.refresh_from_db()
2433+
self.clause.refresh_from_db()
2434+
self.assertLess(clause2.order, self.clause.order)
2435+
2436+
def test_move_down(self):
2437+
clause2 = LegalClause.objects.create(internal_name="Second Clause", clause="Text.")
2438+
self.client.post(
2439+
reverse("manage_legal_clause_move", args=[self.clause.pk]),
2440+
{"direction": "down"},
2441+
)
2442+
self.clause.refresh_from_db()
2443+
clause2.refresh_from_db()
2444+
self.assertGreater(self.clause.order, clause2.order)
2445+
2446+
def test_nav_has_legal_clauses_link(self):
2447+
response = self.client.get(reverse("manage_dashboard"))
2448+
self.assertContains(response, "Legal Clauses")

apps/sponsors/manage/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323
path(
2424
"benefit-configs/<int:pk>/delete/", views.BenefitConfigDeleteView.as_view(), name="manage_benefit_config_delete"
2525
),
26+
# Legal clauses
27+
path("legal-clauses/", views.LegalClauseListView.as_view(), name="manage_legal_clauses"),
28+
path("legal-clauses/new/", views.LegalClauseCreateView.as_view(), name="manage_legal_clause_create"),
29+
path("legal-clauses/<int:pk>/edit/", views.LegalClauseUpdateView.as_view(), name="manage_legal_clause_edit"),
30+
path("legal-clauses/<int:pk>/delete/", views.LegalClauseDeleteView.as_view(), name="manage_legal_clause_delete"),
31+
path("legal-clauses/<int:pk>/move/", views.LegalClauseMoveView.as_view(), name="manage_legal_clause_move"),
2632
# Packages
2733
path("packages/", views.PackageListView.as_view(), name="manage_packages"),
2834
path("packages/new/", views.PackageCreateView.as_view(), name="manage_package_create"),

apps/sponsors/manage/views.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
ComposerTermsForm,
3333
CurrentYearForm,
3434
ExecuteContractForm,
35+
LegalClauseForm,
3536
NotificationTemplateForm,
3637
SendSponsorshipNotificationManageForm,
3738
SponsorContactForm,
@@ -47,6 +48,7 @@
4748
BenefitFeature,
4849
BenefitFeatureConfiguration,
4950
Contract,
51+
LegalClause,
5052
Sponsor,
5153
SponsorBenefit,
5254
SponsorContact,
@@ -366,6 +368,86 @@ def post(self, request, pk):
366368
return redirect(reverse("manage_benefit_edit", args=[pk]))
367369

368370

371+
# ── Legal Clause Views ────────────────────────────────────────────────
372+
373+
374+
class LegalClauseListView(SponsorshipAdminRequiredMixin, ListView):
375+
"""List legal clauses with ordering controls."""
376+
377+
model = LegalClause
378+
template_name = "sponsors/manage/legal_clause_list.html"
379+
context_object_name = "clauses"
380+
381+
def get_queryset(self):
382+
"""Return clauses ordered by position."""
383+
return LegalClause.objects.all().order_by("order")
384+
385+
386+
class LegalClauseCreateView(SponsorshipAdminRequiredMixin, CreateView):
387+
"""Create a new legal clause."""
388+
389+
model = LegalClause
390+
form_class = LegalClauseForm
391+
template_name = "sponsors/manage/legal_clause_form.html"
392+
393+
def get_success_url(self):
394+
"""Return URL to clause list."""
395+
messages.success(self.request, f'Legal clause "{self.object.internal_name}" created.')
396+
return reverse("manage_legal_clauses")
397+
398+
def get_context_data(self, **kwargs):
399+
"""Return context with create flag."""
400+
context = super().get_context_data(**kwargs)
401+
context["is_create"] = True
402+
return context
403+
404+
405+
class LegalClauseUpdateView(SponsorshipAdminRequiredMixin, UpdateView):
406+
"""Edit an existing legal clause."""
407+
408+
model = LegalClause
409+
form_class = LegalClauseForm
410+
template_name = "sponsors/manage/legal_clause_form.html"
411+
412+
def get_success_url(self):
413+
"""Return URL to clause list."""
414+
messages.success(self.request, f'Legal clause "{self.object.internal_name}" updated.')
415+
return reverse("manage_legal_clauses")
416+
417+
def get_context_data(self, **kwargs):
418+
"""Return context with benefit count."""
419+
context = super().get_context_data(**kwargs)
420+
context["is_create"] = False
421+
context["benefit_count"] = self.object.benefits.count()
422+
return context
423+
424+
425+
class LegalClauseDeleteView(SponsorshipAdminRequiredMixin, DeleteView):
426+
"""Delete a legal clause."""
427+
428+
model = LegalClause
429+
template_name = "sponsors/manage/legal_clause_confirm_delete.html"
430+
431+
def get_success_url(self):
432+
"""Return URL to clause list."""
433+
messages.success(self.request, f'Legal clause "{self.object.internal_name}" deleted.')
434+
return reverse("manage_legal_clauses")
435+
436+
437+
class LegalClauseMoveView(SponsorshipAdminRequiredMixin, View):
438+
"""Move a legal clause up or down in order."""
439+
440+
def post(self, request, pk):
441+
"""Move clause up or down based on direction parameter."""
442+
clause = get_object_or_404(LegalClause, pk=pk)
443+
direction = request.POST.get("direction")
444+
if direction == "up":
445+
clause.up()
446+
elif direction == "down":
447+
clause.down()
448+
return redirect(reverse("manage_legal_clauses"))
449+
450+
369451
class PackageListView(SponsorshipAdminRequiredMixin, ListView):
370452
"""List sponsorship packages grouped by year."""
371453

apps/sponsors/templates/sponsors/manage/_base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,7 @@
679679
<div class="manage-nav-more" style="position:relative;">
680680
<a href="#" class="{% if active_tab == 'clone' or active_tab == 'guide' %}active{% endif %}" onclick="event.preventDefault();this.parentElement.classList.toggle('open');">More &#9662;</a>
681681
<div class="manage-nav-dropdown">
682+
<a href="{% url 'manage_legal_clauses' %}">Legal Clauses</a>
682683
<a href="{% url 'manage_clone_year' %}">Clone Year</a>
683684
<a href="{% url 'manage_guide' %}">Guide</a>
684685
</div>

apps/sponsors/templates/sponsors/manage/guide.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ <h3 style="font-size:16px;font-weight:700;color:#e74c3c;border-bottom:2px solid
337337
<ul style="font-size:13px;line-height:2;color:#444;padding-left:20px;">
338338
<li><strong>Benefit ordering</strong> &mdash; drag-to-reorder benefits within a program. The manage UI shows them in order but doesn't let you rearrange</li>
339339
<li><strong>Benefit conflicts</strong> &mdash; configuring which benefits are mutually exclusive (M2M relationship in the admin)</li>
340-
<li><strong>Legal clause CRUD</strong> &mdash; creating and editing the legal clauses that get pulled into contracts</li>
340+
<li><s>Legal clause CRUD</s> &mdash; now available under More &rarr; <a href="{% url 'manage_legal_clauses' %}" style="color:#3776ab;">Legal Clauses</a></li>
341341
<li><strong>Sponsorship program management</strong> &mdash; adding or editing programs (Foundation, PyCon, PyPI, etc.)</li>
342342
<li><strong>Advanced import/export</strong> &mdash; django-import-export for bulk data operations</li>
343343
<li><strong>Detailed asset filtering</strong> &mdash; filtering and administering individual uploaded assets across all sponsors</li>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{% extends "sponsors/manage/_base.html" %}
2+
3+
{% block page_title %}Delete {{ object.internal_name }} | Sponsor Management{% endblock %}
4+
5+
{% with active_tab="more" %}
6+
7+
{% block manage_breadcrumbs %}
8+
<div class="manage-crumbs">
9+
<a href="{% url 'manage_dashboard' %}">Dashboard</a>
10+
<span class="sep">/</span>
11+
<a href="{% url 'manage_legal_clauses' %}">Legal Clauses</a>
12+
<span class="sep">/</span>
13+
<span class="current">Delete</span>
14+
</div>
15+
{% endblock %}
16+
17+
{% block manage_content %}
18+
<div class="manage-form">
19+
<h2 style="font-size:18px;font-weight:700;color:#e74c3c;margin:0 0 20px;">
20+
Delete Legal Clause
21+
</h2>
22+
23+
<div class="manage-alert manage-alert-warning" style="margin-bottom:20px;">
24+
Are you sure you want to delete <strong>"{{ object.internal_name }}"</strong>?
25+
{% with bc=object.benefits.count %}
26+
{% if bc %}This clause is used by {{ bc }} benefit{{ bc|pluralize }}. Deleting it will remove it from those benefits' contracts.{% endif %}
27+
{% endwith %}
28+
</div>
29+
30+
<div style="background:#f7f8fa;padding:12px 16px;border-radius:4px;margin-bottom:20px;font-size:13px;color:#555;">
31+
{{ object.clause|truncatechars:300 }}
32+
</div>
33+
34+
<form method="post">
35+
{% csrf_token %}
36+
<div style="display:flex;gap:10px;">
37+
<button type="submit" class="btn btn-danger">Delete Clause</button>
38+
<a href="{% url 'manage_legal_clauses' %}" class="btn btn-secondary">Cancel</a>
39+
</div>
40+
</form>
41+
</div>
42+
{% endblock %}
43+
44+
{% endwith %}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
{% extends "sponsors/manage/_base.html" %}
2+
3+
{% block page_title %}{% if is_create %}New Legal Clause{% else %}Edit {{ object.internal_name }}{% endif %} | Sponsor Management{% endblock %}
4+
5+
{% with active_tab="more" %}
6+
7+
{% block manage_breadcrumbs %}
8+
<div class="manage-crumbs">
9+
<a href="{% url 'manage_dashboard' %}">Dashboard</a>
10+
<span class="sep">/</span>
11+
<a href="{% url 'manage_legal_clauses' %}">Legal Clauses</a>
12+
<span class="sep">/</span>
13+
<span class="current">{% if is_create %}New{% else %}{{ object.internal_name }}{% endif %}</span>
14+
</div>
15+
{% endblock %}
16+
17+
{% block manage_content %}
18+
<div class="manage-form">
19+
<h2 style="font-size:18px;font-weight:700;color:#1a1a2e;margin:0 0 4px;">
20+
{% if is_create %}Create Legal Clause{% else %}Edit Legal Clause{% endif %}
21+
</h2>
22+
{% if not is_create and benefit_count %}
23+
<p style="font-size:13px;color:#777;margin:0 0 20px;">
24+
Used by <span style="color:#e74c3c;">{{ benefit_count }} benefit{{ benefit_count|pluralize }}</span>
25+
</p>
26+
{% else %}
27+
<p style="font-size:13px;color:#777;margin:0 0 20px;">
28+
{% if is_create %}Legal clause text that gets pulled into contracts via benefit associations.{% endif %}
29+
</p>
30+
{% endif %}
31+
32+
{% if form.errors %}
33+
<div class="form-errors">
34+
<strong>Please correct the errors below:</strong>
35+
<ul>
36+
{% for field, errors in form.errors.items %}
37+
{% for error in errors %}
38+
<li>{{ field }}: {{ error }}</li>
39+
{% endfor %}
40+
{% endfor %}
41+
</ul>
42+
</div>
43+
{% endif %}
44+
45+
<form method="post">
46+
{% csrf_token %}
47+
48+
<fieldset class="manage-fieldset">
49+
<legend>Clause Details</legend>
50+
51+
<div class="form-group">
52+
<label for="id_internal_name">Internal Name</label>
53+
{{ form.internal_name }}
54+
<span class="helptext">Friendly name for PSF staff reference (not shown in contracts).</span>
55+
{% if form.internal_name.errors %}<div class="field-errors">{{ form.internal_name.errors.0 }}</div>{% endif %}
56+
</div>
57+
58+
<div class="form-group">
59+
<label for="id_clause">Clause Text</label>
60+
{{ form.clause }}
61+
<span class="helptext">The legal text that appears in contracts. Rendered as markdown.</span>
62+
{% if form.clause.errors %}<div class="field-errors">{{ form.clause.errors.0 }}</div>{% endif %}
63+
</div>
64+
65+
<div class="form-group">
66+
<label for="id_notes">Notes</label>
67+
{{ form.notes }}
68+
{% if form.notes.errors %}<div class="field-errors">{{ form.notes.errors.0 }}</div>{% endif %}
69+
</div>
70+
</fieldset>
71+
72+
<div style="display:flex;gap:10px;align-items:center;padding-top:8px;">
73+
<button type="submit" class="btn btn-primary">
74+
{% if is_create %}Create Clause{% else %}Save Changes{% endif %}
75+
</button>
76+
<a href="{% url 'manage_legal_clauses' %}" class="btn btn-secondary">Cancel</a>
77+
{% if not is_create %}
78+
<a href="{% url 'manage_legal_clause_delete' object.pk %}" class="btn btn-danger" style="margin-left:auto;">Delete</a>
79+
{% endif %}
80+
</div>
81+
</form>
82+
</div>
83+
{% endblock %}
84+
85+
{% endwith %}

0 commit comments

Comments
 (0)