Skip to content

Commit e81ba0f

Browse files
JacobCoffeeclaude
andcommitted
feat: renewal workflow with expiring/expired sponsorship alerts
Dashboard shows cross-year "Expiring Soon" (90-day window with color-coded countdown) and "Recently Expired" sections with one-click "+ Renewal" buttons that launch the Composer with sponsor pre-selected and renewal flag set. Sponsorship list shows expiry tags in the Period column. Detail page shows Expiring Soon/Expired tags and a "+ Renewal" button for finalized sponsorships. Adds sponsors_manage templatetags, guide documentation, and 15 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ea82684 commit e81ba0f

8 files changed

Lines changed: 407 additions & 3 deletions

File tree

apps/sponsors/manage/tests.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2072,3 +2072,226 @@ def test_invalid_step_defaults_to_1(self):
20722072
response = self.client.get(reverse("manage_composer") + "?step=abc")
20732073
self.assertEqual(response.status_code, 200)
20742074
self.assertContains(response, "Select a Sponsor")
2075+
2076+
2077+
class DashboardExpiringSoonTests(SponsorManageTestBase):
2078+
"""Test dashboard expiring/expired sponsorship sections."""
2079+
2080+
@classmethod
2081+
def setUpTestData(cls):
2082+
super().setUpTestData()
2083+
cls.sponsor = Sponsor.objects.create(name="Expiring Corp")
2084+
2085+
def setUp(self):
2086+
super().setUp()
2087+
self.client.login(username="staff", password="pass")
2088+
2089+
def test_expiring_soon_shown_on_dashboard(self):
2090+
"""Finalized sponsorship ending within 90 days appears in Expiring Soon."""
2091+
today = timezone.now().date()
2092+
Sponsorship.objects.create(
2093+
sponsor=self.sponsor,
2094+
submited_by=self.staff_user,
2095+
package=self.package,
2096+
sponsorship_fee=100000,
2097+
year=self.year,
2098+
status=Sponsorship.FINALIZED,
2099+
start_date=today - datetime.timedelta(days=300),
2100+
end_date=today + datetime.timedelta(days=30),
2101+
)
2102+
response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}")
2103+
self.assertEqual(response.status_code, 200)
2104+
self.assertContains(response, "Expiring Soon")
2105+
self.assertContains(response, "Expiring Corp")
2106+
2107+
def test_expiring_far_future_not_shown(self):
2108+
"""Finalized sponsorship ending more than 90 days out is not in Expiring Soon."""
2109+
today = timezone.now().date()
2110+
Sponsorship.objects.create(
2111+
sponsor=self.sponsor,
2112+
submited_by=self.staff_user,
2113+
package=self.package,
2114+
sponsorship_fee=100000,
2115+
year=self.year,
2116+
status=Sponsorship.FINALIZED,
2117+
start_date=today - datetime.timedelta(days=100),
2118+
end_date=today + datetime.timedelta(days=200),
2119+
)
2120+
response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}")
2121+
self.assertNotContains(response, "Expiring Soon")
2122+
2123+
def test_recently_expired_shown_on_dashboard(self):
2124+
"""Finalized sponsorship with past end_date appears in Recently Expired."""
2125+
today = timezone.now().date()
2126+
Sponsorship.objects.create(
2127+
sponsor=self.sponsor,
2128+
submited_by=self.staff_user,
2129+
package=self.package,
2130+
sponsorship_fee=100000,
2131+
year=self.year,
2132+
status=Sponsorship.FINALIZED,
2133+
start_date=today - datetime.timedelta(days=400),
2134+
end_date=today - datetime.timedelta(days=10),
2135+
)
2136+
response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}")
2137+
self.assertEqual(response.status_code, 200)
2138+
self.assertContains(response, "Recently Expired")
2139+
self.assertContains(response, "Expiring Corp")
2140+
2141+
def test_overlapped_expired_not_shown(self):
2142+
"""Expired sponsorship with overlapped_by set is excluded from Recently Expired."""
2143+
today = timezone.now().date()
2144+
renewal = Sponsorship.objects.create(
2145+
sponsor=self.sponsor,
2146+
submited_by=self.staff_user,
2147+
package=self.package,
2148+
sponsorship_fee=100000,
2149+
year=self.year,
2150+
status=Sponsorship.FINALIZED,
2151+
start_date=today - datetime.timedelta(days=30),
2152+
end_date=today + datetime.timedelta(days=335),
2153+
)
2154+
Sponsorship.objects.create(
2155+
sponsor=self.sponsor,
2156+
submited_by=self.staff_user,
2157+
package=self.package,
2158+
sponsorship_fee=100000,
2159+
year=self.year,
2160+
status=Sponsorship.FINALIZED,
2161+
start_date=today - datetime.timedelta(days=400),
2162+
end_date=today - datetime.timedelta(days=10),
2163+
overlapped_by=renewal,
2164+
)
2165+
response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}")
2166+
self.assertNotContains(response, "Recently Expired")
2167+
2168+
def test_applied_sponsorship_not_in_expiring(self):
2169+
"""Only finalized sponsorships appear in expiring sections."""
2170+
today = timezone.now().date()
2171+
Sponsorship.objects.create(
2172+
sponsor=self.sponsor,
2173+
submited_by=self.staff_user,
2174+
package=self.package,
2175+
sponsorship_fee=100000,
2176+
year=self.year,
2177+
status=Sponsorship.APPLIED,
2178+
start_date=today - datetime.timedelta(days=300),
2179+
end_date=today + datetime.timedelta(days=10),
2180+
)
2181+
response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}")
2182+
self.assertNotContains(response, "Expiring Soon")
2183+
2184+
def test_expiring_shown_cross_year(self):
2185+
"""Expiring sponsorships from a prior year show on the current year dashboard."""
2186+
today = timezone.now().date()
2187+
prior_year = self.year - 1
2188+
Sponsorship.objects.create(
2189+
sponsor=self.sponsor,
2190+
submited_by=self.staff_user,
2191+
package=self.package,
2192+
sponsorship_fee=100000,
2193+
year=prior_year,
2194+
status=Sponsorship.FINALIZED,
2195+
start_date=today - datetime.timedelta(days=300),
2196+
end_date=today + datetime.timedelta(days=30),
2197+
)
2198+
response = self.client.get(reverse("manage_dashboard") + f"?year={self.year}")
2199+
self.assertContains(response, "Expiring Soon")
2200+
self.assertContains(response, "Expiring Corp")
2201+
2202+
2203+
class SponsorshipDetailRenewalTests(SponsorshipReviewTestBase):
2204+
"""Test renewal-related features on sponsorship detail view."""
2205+
2206+
def test_create_renewal_button_shown_for_finalized(self):
2207+
"""Finalized sponsorships show the + Renewal button."""
2208+
self.sponsorship.status = Sponsorship.FINALIZED
2209+
self.sponsorship.save(update_fields=["status"])
2210+
response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk]))
2211+
self.assertContains(response, "+ Renewal")
2212+
self.assertContains(response, "renewal=1")
2213+
2214+
def test_create_renewal_button_hidden_for_applied(self):
2215+
"""Applied sponsorships do not show the + Renewal button."""
2216+
response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk]))
2217+
self.assertNotContains(response, "+ Renewal")
2218+
2219+
def test_expiring_soon_tag_shown(self):
2220+
"""Finalized sponsorship with end_date within 90 days shows Expiring Soon tag."""
2221+
today = timezone.now().date()
2222+
self.sponsorship.status = Sponsorship.FINALIZED
2223+
self.sponsorship.start_date = today - datetime.timedelta(days=300)
2224+
self.sponsorship.end_date = today + datetime.timedelta(days=30)
2225+
self.sponsorship.save()
2226+
response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk]))
2227+
self.assertContains(response, "Expiring Soon")
2228+
2229+
def test_expired_tag_shown(self):
2230+
"""Finalized sponsorship with end_date in past shows Expired tag."""
2231+
today = timezone.now().date()
2232+
self.sponsorship.status = Sponsorship.FINALIZED
2233+
self.sponsorship.start_date = today - datetime.timedelta(days=400)
2234+
self.sponsorship.end_date = today - datetime.timedelta(days=10)
2235+
self.sponsorship.save()
2236+
response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk]))
2237+
self.assertContains(response, "Expired")
2238+
2239+
def test_no_expiry_tag_when_not_near_end(self):
2240+
"""Finalized sponsorship with distant end_date shows no expiry tags."""
2241+
today = timezone.now().date()
2242+
self.sponsorship.status = Sponsorship.FINALIZED
2243+
self.sponsorship.start_date = today - datetime.timedelta(days=100)
2244+
self.sponsorship.end_date = today + datetime.timedelta(days=200)
2245+
self.sponsorship.save()
2246+
response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk]))
2247+
self.assertNotContains(response, "Expiring Soon")
2248+
self.assertNotContains(response, ">Expired<")
2249+
2250+
2251+
class SponsorshipListExpiryTagTests(SponsorshipReviewTestBase):
2252+
"""Test expiry tags on sponsorship list view."""
2253+
2254+
def test_expired_tag_shown_in_list(self):
2255+
"""Expired finalized sponsorship shows 'Expired' tag in list."""
2256+
today = timezone.now().date()
2257+
self.sponsorship.status = Sponsorship.FINALIZED
2258+
self.sponsorship.start_date = today - datetime.timedelta(days=400)
2259+
self.sponsorship.end_date = today - datetime.timedelta(days=10)
2260+
self.sponsorship.save()
2261+
response = self.client.get(reverse("manage_sponsorships") + "?status=finalized")
2262+
self.assertContains(response, "Expired")
2263+
2264+
def test_days_left_tag_shown_in_list(self):
2265+
"""Expiring finalized sponsorship shows days-left tag in list."""
2266+
today = timezone.now().date()
2267+
self.sponsorship.status = Sponsorship.FINALIZED
2268+
self.sponsorship.start_date = today - datetime.timedelta(days=300)
2269+
self.sponsorship.end_date = today + datetime.timedelta(days=20)
2270+
self.sponsorship.save()
2271+
response = self.client.get(reverse("manage_sponsorships") + "?status=finalized")
2272+
self.assertContains(response, "d left")
2273+
2274+
2275+
class ComposerRenewalPreFillTests(SponsorManageTestBase):
2276+
"""Test that composer pre-fills renewal flag from query param."""
2277+
2278+
@classmethod
2279+
def setUpTestData(cls):
2280+
super().setUpTestData()
2281+
cls.sponsor = Sponsor.objects.create(name="Renewing Corp")
2282+
2283+
def setUp(self):
2284+
super().setUp()
2285+
self.client.login(username="staff", password="pass")
2286+
2287+
def test_renewal_flag_stored_in_session(self):
2288+
"""Starting composer with renewal=1 stores renewal in session."""
2289+
self.client.get(reverse("manage_composer") + f"?new=1&sponsor_id={self.sponsor.pk}&renewal=1")
2290+
session_data = self.client.session.get("composer", {})
2291+
self.assertTrue(session_data.get("renewal"))
2292+
2293+
def test_renewal_flag_not_stored_without_param(self):
2294+
"""Starting composer without renewal param does not store renewal."""
2295+
self.client.get(reverse("manage_composer") + f"?new=1&sponsor_id={self.sponsor.pk}")
2296+
session_data = self.client.session.get("composer", {})
2297+
self.assertNotIn("renewal", session_data)

apps/sponsors/manage/views.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import contextlib
77
import csv
8+
import datetime
89
import io
910
import zipfile
1011
from tempfile import NamedTemporaryFile
@@ -16,6 +17,7 @@
1617
from django.http import HttpResponse
1718
from django.shortcuts import get_object_or_404, redirect, render
1819
from django.urls import reverse
20+
from django.utils import timezone as tz
1921
from django.views import View
2022
from django.views.generic import CreateView, DeleteView, DetailView, FormView, ListView, TemplateView, UpdateView
2123

@@ -155,6 +157,31 @@ def get_context_data(self, **kwargs):
155157
or 0
156158
)
157159

160+
# Expiring sponsorships (finalized, end_date within 90 days from today)
161+
# Cross-year: shown on every dashboard regardless of selected year
162+
today = tz.now().date()
163+
expiring_soon = (
164+
Sponsorship.objects.filter(
165+
status=Sponsorship.FINALIZED,
166+
end_date__gte=today,
167+
end_date__lte=today + datetime.timedelta(days=90),
168+
)
169+
.select_related("sponsor", "package")
170+
.order_by("end_date")[:10]
171+
)
172+
173+
# Recently expired (finalized, end_date in the past, not overlapped)
174+
# Cross-year: shown on every dashboard regardless of selected year
175+
recently_expired = (
176+
Sponsorship.objects.filter(
177+
status=Sponsorship.FINALIZED,
178+
end_date__lt=today,
179+
overlapped_by__isnull=True,
180+
)
181+
.select_related("sponsor", "package")
182+
.order_by("-end_date")[:10]
183+
)
184+
158185
# Sponsors without a sponsorship for this year
159186
sponsors_with_sponsorship_ids = year_sponsorships.values_list("sponsor_id", flat=True) if selected_year else []
160187
unsponsored = (
@@ -179,7 +206,10 @@ def get_context_data(self, **kwargs):
179206
"total_revenue": total_revenue,
180207
"needs_review": needs_review,
181208
"pending_contracts": pending_contracts,
209+
"expiring_soon": expiring_soon,
210+
"recently_expired": recently_expired,
182211
"unsponsored": unsponsored,
212+
"today": today,
183213
}
184214
)
185215
return context
@@ -488,6 +518,7 @@ def get_context_data(self, **kwargs):
488518
context["filter_status"] = self.filter_status
489519
context["filter_year"] = self.filter_year
490520
context["filter_search"] = self.filter_search
521+
context["today"] = tz.now().date()
491522
# Individual count vars for template
492523
context["count_applied"] = Sponsorship.objects.filter(status=Sponsorship.APPLIED).count()
493524
context["count_approved"] = Sponsorship.objects.filter(status=Sponsorship.APPROVED).count()
@@ -553,6 +584,16 @@ def get_context_data(self, **kwargs):
553584
context["historical_contracts"] = Contract.objects.none()
554585
# Communication history
555586
context["notification_logs"] = sp.notification_logs.select_related("sent_by").all()[:20]
587+
# Renewal info
588+
today = tz.now().date()
589+
context["today"] = today
590+
context["can_create_renewal"] = sp.status == Sponsorship.FINALIZED and sp.sponsor_id is not None
591+
context["is_expiring_soon"] = (
592+
sp.status == Sponsorship.FINALIZED
593+
and sp.end_date
594+
and today <= sp.end_date <= today + datetime.timedelta(days=90)
595+
)
596+
context["is_expired"] = sp.status == Sponsorship.FINALIZED and sp.end_date and sp.end_date < today
556597
return context
557598

558599

@@ -1775,6 +1816,8 @@ def get(self, request):
17751816
data["sponsor_id"] = sponsor.pk
17761817
except (Sponsor.DoesNotExist, TypeError, ValueError):
17771818
pass
1819+
if request.GET.get("renewal") == "1":
1820+
data["renewal"] = True
17781821
self._set_composer_data(request, data)
17791822
# Skip to step 2 if sponsor was pre-selected
17801823
if data.get("sponsor_id"):

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@
292292
height: 24px !important;
293293
padding: 0 8px !important;
294294
font-size: 11px !important;
295+
white-space: nowrap !important;
295296
}
296297

297298
/* ── Tags / Badges ── */

0 commit comments

Comments
 (0)