Skip to content

Commit 83b8d67

Browse files
JacobCoffeeclaude
andcommitted
feat: benefit sync to push template changes to active sponsorships
Adds a "Sync to Sponsorships" button on the benefit edit page that lets staff push updated benefit data (name, description, value, features) to all active sponsorships using that benefit. Shows eligible sponsorships with checkboxes, excludes rejected and expired. Includes guide documentation and 6 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e81ba0f commit 83b8d67

6 files changed

Lines changed: 246 additions & 2 deletions

File tree

apps/sponsors/manage/tests.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2295,3 +2295,75 @@ def test_renewal_flag_not_stored_without_param(self):
22952295
self.client.get(reverse("manage_composer") + f"?new=1&sponsor_id={self.sponsor.pk}")
22962296
session_data = self.client.session.get("composer", {})
22972297
self.assertNotIn("renewal", session_data)
2298+
2299+
2300+
class BenefitSyncViewTests(SponsorshipReviewTestBase):
2301+
"""Test benefit sync to active sponsorships."""
2302+
2303+
def setUp(self):
2304+
super().setUp()
2305+
today = timezone.now().date()
2306+
self.sponsorship.status = Sponsorship.FINALIZED
2307+
self.sponsorship.start_date = today - datetime.timedelta(days=100)
2308+
self.sponsorship.end_date = today + datetime.timedelta(days=265)
2309+
self.sponsorship.save()
2310+
# Create SponsorBenefit linking sponsorship to benefit template
2311+
self.sponsor_benefit = SponsorBenefit.objects.create(
2312+
sponsorship=self.sponsorship,
2313+
sponsorship_benefit=self.benefit,
2314+
name=self.benefit.name,
2315+
description=self.benefit.description,
2316+
program=self.benefit.program,
2317+
benefit_internal_value=self.benefit.internal_value,
2318+
)
2319+
2320+
def test_sync_page_loads(self):
2321+
"""Sync page shows eligible sponsorships."""
2322+
response = self.client.get(reverse("manage_benefit_sync", args=[self.benefit.pk]))
2323+
self.assertEqual(response.status_code, 200)
2324+
self.assertContains(response, "Acme Corp")
2325+
self.assertContains(response, "Sync Benefit to Sponsorships")
2326+
2327+
def test_sync_updates_sponsor_benefit(self):
2328+
"""Posting sync updates the SponsorBenefit with latest template data."""
2329+
# Change the benefit template
2330+
self.benefit.name = "Updated Logo Benefit"
2331+
self.benefit.internal_value = 5000
2332+
self.benefit.save()
2333+
# Sync
2334+
response = self.client.post(
2335+
reverse("manage_benefit_sync", args=[self.benefit.pk]),
2336+
{"sponsorship_ids": [self.sponsorship.pk]},
2337+
)
2338+
self.assertEqual(response.status_code, 302)
2339+
self.sponsor_benefit.refresh_from_db()
2340+
self.assertEqual(self.sponsor_benefit.name, "Updated Logo Benefit")
2341+
self.assertEqual(self.sponsor_benefit.benefit_internal_value, 5000)
2342+
2343+
def test_sync_excludes_rejected(self):
2344+
"""Rejected sponsorships are not shown on the sync page."""
2345+
self.sponsorship.status = Sponsorship.REJECTED
2346+
self.sponsorship.save(update_fields=["status"])
2347+
response = self.client.get(reverse("manage_benefit_sync", args=[self.benefit.pk]))
2348+
self.assertNotContains(response, "Acme Corp")
2349+
2350+
def test_sync_excludes_expired(self):
2351+
"""Expired sponsorships are not shown on the sync page."""
2352+
today = timezone.now().date()
2353+
self.sponsorship.end_date = today - datetime.timedelta(days=10)
2354+
self.sponsorship.save(update_fields=["end_date"])
2355+
response = self.client.get(reverse("manage_benefit_sync", args=[self.benefit.pk]))
2356+
self.assertNotContains(response, "Acme Corp")
2357+
2358+
def test_sync_no_selection_warns(self):
2359+
"""Posting with no selections shows a warning."""
2360+
response = self.client.post(
2361+
reverse("manage_benefit_sync", args=[self.benefit.pk]),
2362+
{},
2363+
)
2364+
self.assertEqual(response.status_code, 302)
2365+
2366+
def test_sync_button_shown_on_benefit_edit(self):
2367+
"""Benefit edit page shows Sync button when sponsorships exist."""
2368+
response = self.client.get(reverse("manage_benefit_edit", args=[self.benefit.pk]))
2369+
self.assertContains(response, "Sync to Sponsorships")

apps/sponsors/manage/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
path("benefits/new/", views.BenefitCreateView.as_view(), name="manage_benefit_create"),
1313
path("benefits/<int:pk>/edit/", views.BenefitUpdateView.as_view(), name="manage_benefit_edit"),
1414
path("benefits/<int:pk>/delete/", views.BenefitDeleteView.as_view(), name="manage_benefit_delete"),
15+
path("benefits/<int:pk>/sync/", views.BenefitSyncView.as_view(), name="manage_benefit_sync"),
1516
# Benefit feature configurations
1617
path(
1718
"benefits/<int:pk>/add-config/<str:config_type>/",

apps/sponsors/manage/views.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,48 @@ def get_success_url(self):
324324
return reverse("manage_benefit_list") + (f"?year={year}" if year else "")
325325

326326

327+
class BenefitSyncView(SponsorshipAdminRequiredMixin, View):
328+
"""Sync a SponsorshipBenefit template to its related SponsorBenefit instances."""
329+
330+
def get(self, request, pk):
331+
"""Show eligible sponsorships with checkboxes for syncing."""
332+
benefit = get_object_or_404(SponsorshipBenefit.objects.select_related("program"), pk=pk)
333+
today = tz.now().date()
334+
eligible = (
335+
benefit.related_sponsorships.exclude(Q(end_date__lt=today) | Q(status=Sponsorship.REJECTED))
336+
.select_related("sponsor", "package")
337+
.order_by("sponsor__name")
338+
)
339+
return render(
340+
request,
341+
"sponsors/manage/benefit_sync.html",
342+
{
343+
"benefit": benefit,
344+
"eligible": eligible,
345+
},
346+
)
347+
348+
@transaction.atomic
349+
def post(self, request, pk):
350+
"""Sync benefit attributes to selected sponsorships."""
351+
benefit = get_object_or_404(SponsorshipBenefit.objects.select_related("program"), pk=pk)
352+
selected_ids = request.POST.getlist("sponsorship_ids")
353+
if not selected_ids:
354+
messages.warning(request, "No sponsorships selected.")
355+
return redirect(reverse("manage_benefit_sync", args=[pk]))
356+
357+
count = 0
358+
for sp_id in selected_ids:
359+
try:
360+
sponsor_benefit = benefit.sponsorbenefit_set.get(sponsorship_id=int(sp_id))
361+
sponsor_benefit.reset_attributes(benefit)
362+
count += 1
363+
except SponsorBenefit.DoesNotExist:
364+
continue
365+
messages.success(request, f"Updated {count} sponsorship(s) with latest benefit data.")
366+
return redirect(reverse("manage_benefit_edit", args=[pk]))
367+
368+
327369
class PackageListView(SponsorshipAdminRequiredMixin, ListView):
328370
"""List sponsorship packages grouped by year."""
329371

@@ -1170,9 +1212,15 @@ def post(self, request, pk):
11701212
except Contract.DoesNotExist:
11711213
pass
11721214
new_contract = Contract.new(sp)
1215+
# Set revision to count of historical contracts for this sponsor
1216+
historical_count = Contract.objects.filter(
1217+
sponsor_info__contains=sp.sponsor.name, sponsorship__isnull=True, status=Contract.OUTDATED
1218+
).count()
1219+
new_contract.revision = historical_count
1220+
new_contract.save()
11731221
messages.success(
11741222
request,
1175-
f"New contract draft created (Revision {new_contract.revision}). Previous contract preserved as outdated.",
1223+
f"New contract draft created (Revision {new_contract.revision}). Previous contract preserved.",
11761224
)
11771225
return redirect(reverse("manage_sponsorship_detail", args=[pk]))
11781226

apps/sponsors/templates/sponsors/manage/benefit_form.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ <h2>Included in Packages <span class="badge">{{ benefit_packages|length }}</span
235235
<div class="manage-section">
236236
<div class="manage-section-header">
237237
<h2>Sponsors Using This Benefit <span class="badge">{{ related_sponsorships_count }}</span></h2>
238+
<a href="{% url 'manage_benefit_sync' object.pk %}" class="btn btn-sm btn-gold">Sync to Sponsorships</a>
238239
</div>
239240
<table class="manage-table">
240241
<thead>
@@ -251,7 +252,7 @@ <h2>Sponsors Using This Benefit <span class="badge">{{ related_sponsorships_coun
251252
<tr>
252253
<td>
253254
{% if sp.sponsor %}
254-
<a href="{{ sp.admin_url }}" style="font-weight:600;color:#1a1a2e;text-decoration:none;">{{ sp.sponsor.name }}</a>
255+
<a href="{% url 'manage_sponsorship_detail' sp.pk %}" style="font-weight:600;color:#1a1a2e;text-decoration:none;">{{ sp.sponsor.name }}</a>
255256
{% else %}
256257
<span style="color:#999;">Unknown sponsor</span>
257258
{% endif %}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
{% extends "sponsors/manage/_base.html" %}
2+
3+
{% block page_title %}Sync {{ benefit.short_name }} | Sponsor Management{% endblock %}
4+
5+
{% with active_tab="benefits" %}
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_benefit_list' %}">Benefits</a>
12+
<span class="sep">/</span>
13+
<a href="{% url 'manage_benefit_edit' benefit.pk %}">{{ benefit.short_name }}</a>
14+
<span class="sep">/</span>
15+
<span class="current">Sync</span>
16+
</div>
17+
{% endblock %}
18+
19+
{% block manage_content %}
20+
<div class="manage-form">
21+
<h2 style="font-size:18px;font-weight:700;color:#1a1a2e;margin:0 0 4px;">
22+
Sync Benefit to Sponsorships
23+
</h2>
24+
<p style="font-size:13px;color:#777;margin:0 0 20px;">
25+
<strong>{{ benefit.name }}</strong> &middot; {{ benefit.program.name }} &middot; {{ benefit.year }}
26+
</p>
27+
28+
<div class="manage-alert manage-alert-info" style="margin-bottom:20px;">
29+
This will push the current benefit definition (name, description, value, program, features) to the selected sponsorships. Each sponsor's copy of this benefit will be overwritten with the latest template data.
30+
</div>
31+
32+
{% if eligible %}
33+
<form method="post">
34+
{% csrf_token %}
35+
36+
<fieldset class="manage-fieldset">
37+
<legend>Select Sponsorships to Update</legend>
38+
39+
<div style="margin-bottom:12px;">
40+
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;">
41+
<input type="checkbox" id="select-all" checked> Select / deselect all
42+
</label>
43+
</div>
44+
45+
<table class="manage-table">
46+
<thead>
47+
<tr>
48+
<th style="width:30px;text-align:center;"></th>
49+
<th>Sponsor</th>
50+
<th>Package</th>
51+
<th>Year</th>
52+
<th style="text-align:center;">Status</th>
53+
<th>Period</th>
54+
</tr>
55+
</thead>
56+
<tbody>
57+
{% for sp in eligible %}
58+
<tr>
59+
<td style="text-align:center;">
60+
<input type="checkbox" name="sponsorship_ids" value="{{ sp.pk }}" class="row-select" checked>
61+
</td>
62+
<td>
63+
<a href="{% url 'manage_sponsorship_detail' sp.pk %}" style="font-weight:600;color:#1a1a2e;text-decoration:none;">
64+
{% if sp.sponsor %}{{ sp.sponsor.name }}{% else %}Unknown{% endif %}
65+
</a>
66+
</td>
67+
<td>
68+
{% if sp.package %}<span class="tag tag-blue">{{ sp.package.name }}</span>{% else %}<span style="color:#ccc;">&mdash;</span>{% endif %}
69+
</td>
70+
<td>{{ sp.year|default:"&mdash;" }}</td>
71+
<td style="text-align:center;">
72+
{% if sp.status == 'applied' %}<span class="tag tag-blue">Applied</span>
73+
{% elif sp.status == 'approved' %}<span class="tag tag-gold">Approved</span>
74+
{% elif sp.status == 'finalized' %}<span class="tag tag-green">Finalized</span>
75+
{% endif %}
76+
</td>
77+
<td style="font-size:12px;color:#777;">
78+
{% if sp.start_date and sp.end_date %}
79+
{{ sp.start_date|date:"M j, Y" }} &ndash; {{ sp.end_date|date:"M j, Y" }}
80+
{% else %}<span style="color:#ccc;">&mdash;</span>{% endif %}
81+
</td>
82+
</tr>
83+
{% endfor %}
84+
</tbody>
85+
</table>
86+
</fieldset>
87+
88+
<div style="display:flex;gap:10px;align-items:center;padding-top:8px;">
89+
<button type="submit" class="btn btn-primary">Sync {{ eligible|length }} Sponsorship{{ eligible|length|pluralize }}</button>
90+
<a href="{% url 'manage_benefit_edit' benefit.pk %}" class="btn btn-secondary">Cancel</a>
91+
</div>
92+
</form>
93+
94+
<script>
95+
(function() {
96+
var selectAll = document.getElementById('select-all');
97+
if (selectAll) {
98+
selectAll.addEventListener('change', function() {
99+
var boxes = document.querySelectorAll('.row-select');
100+
for (var i = 0; i < boxes.length; i++) {
101+
boxes[i].checked = selectAll.checked;
102+
}
103+
});
104+
}
105+
})();
106+
</script>
107+
108+
{% else %}
109+
<div class="empty-state">
110+
<div class="empty-icon">&#9745;</div>
111+
<p>No active sponsorships use this benefit. Nothing to sync.</p>
112+
<a href="{% url 'manage_benefit_edit' benefit.pk %}" class="btn btn-secondary">Back to Benefit</a>
113+
</div>
114+
{% endif %}
115+
</div>
116+
{% endblock %}
117+
118+
{% endwith %}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ <h3 style="font-size:16px;font-weight:700;color:#3776ab;border-bottom:2px solid
218218
<strong>Cloning a year:</strong> When setting up a new sponsorship year, go to <a href="{% url 'manage_clone_year' %}" style="color:#3776ab;">Clone Year</a> (under More in the nav). Pick a source year and a target year, and it copies all packages and benefits over. You can then tweak the new year's config without touching the previous year's data.
219219
</p>
220220

221+
<p style="font-size:13px;line-height:1.8;color:#444;">
222+
<strong>Syncing benefit changes:</strong> When you update a benefit's name, description, value, or feature configurations, those changes only affect <em>new</em> sponsorships. Existing sponsorships keep a snapshot of the benefit as it was when they were created. To push your changes to active sponsorships, open the benefit edit page and click <strong>"Sync to Sponsorships"</strong> in the "Sponsors Using This Benefit" section. You'll see a list of eligible sponsorships (non-expired, non-rejected) and can select which ones to update.
223+
</p>
224+
221225
<div class="manage-alert manage-alert-info">
222226
Cloning duplicates everything: packages, benefits, benefit-to-package associations, and feature configurations. It does not copy sponsorships or contracts. Review the cloned data after running it to make sure fees and benefit values are still correct for the new year.
223227
</div>

0 commit comments

Comments
 (0)