Skip to content

Commit 40fd5f8

Browse files
committed
sponsor notification history
1 parent f3dab4b commit 40fd5f8

11 files changed

Lines changed: 442 additions & 14 deletions

File tree

apps/sponsors/manage/urls.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@
101101
),
102102
# Composer wizard
103103
path("composer/", views.ComposerView.as_view(), name="manage_composer"),
104-
# Notification template CRUD
104+
# Notification template CRUD + history
105105
path("notifications/", views.NotificationTemplateListView.as_view(), name="manage_notification_templates"),
106+
path("notifications/history/", views.NotificationHistoryView.as_view(), name="manage_notification_history"),
106107
path(
107108
"notifications/new/",
108109
views.NotificationTemplateCreateView.as_view(),

apps/sponsors/manage/views.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
Sponsorship,
5353
SponsorshipBenefit,
5454
SponsorshipCurrentYear,
55+
SponsorshipNotificationLog,
5556
SponsorshipPackage,
5657
SponsorshipProgram,
5758
)
@@ -534,6 +535,8 @@ def get_context_data(self, **kwargs):
534535
# Benefit add form (only when editable)
535536
if sp.open_for_editing:
536537
context["add_benefit_form"] = AddBenefitToSponsorshipForm(sponsorship=sp)
538+
# Communication history
539+
context["notification_logs"] = sp.notification_logs.select_related("sent_by").all()[:20]
537540
return context
538541

539542

@@ -1215,6 +1218,33 @@ def get_success_url(self):
12151218
return reverse("manage_notification_templates")
12161219

12171220

1221+
class NotificationHistoryView(SponsorshipAdminRequiredMixin, ListView):
1222+
"""Show history of all sent notifications across all sponsorships."""
1223+
1224+
model = SponsorshipNotificationLog
1225+
template_name = "sponsors/manage/notification_history.html"
1226+
context_object_name = "logs"
1227+
paginate_by = 50
1228+
1229+
def get_queryset(self):
1230+
"""Return logs ordered by most recent, with related data."""
1231+
qs = SponsorshipNotificationLog.objects.select_related("sponsorship__sponsor", "sent_by").order_by("-sent_at")
1232+
search = self.request.GET.get("search", "").strip()
1233+
if search:
1234+
qs = qs.filter(
1235+
Q(subject__icontains=search)
1236+
| Q(recipients__icontains=search)
1237+
| Q(sponsorship__sponsor__name__icontains=search)
1238+
)
1239+
return qs
1240+
1241+
def get_context_data(self, **kwargs):
1242+
"""Add search term to context."""
1243+
context = super().get_context_data(**kwargs)
1244+
context["filter_search"] = self.request.GET.get("search", "")
1245+
return context
1246+
1247+
12181248
# ── Sponsor contact management ───────────────────────────────────────
12191249

12201250

apps/sponsors/management/commands/seed_sponsor_manage_data.py

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Create realistic test data for the sponsor management UI."""
22

3-
from datetime import timedelta
3+
from datetime import datetime, timedelta
44

55
from django.conf import settings
66
from django.contrib.auth import get_user_model
@@ -16,6 +16,7 @@
1616
Sponsorship,
1717
SponsorshipBenefit,
1818
SponsorshipCurrentYear,
19+
SponsorshipNotificationLog,
1920
SponsorshipPackage,
2021
SponsorshipProgram,
2122
)
@@ -101,9 +102,13 @@ def handle(self, *args, **options):
101102
sponsors = self._create_sponsors()
102103
created_count = self._create_sponsorships(sponsors, user, current_year, today)
103104
self._create_notification_templates()
105+
notif_count = self._create_notification_logs(user, today)
104106

105107
self.stdout.write(
106-
self.style.SUCCESS(f"Created {created_count} sponsorships across {len(sponsors)} sponsors, years {years}.")
108+
self.style.SUCCESS(
109+
f"Created {created_count} sponsorships across {len(sponsors)} sponsors, years {years}. "
110+
f"{notif_count} notification logs."
111+
)
107112
)
108113
self.stdout.write("View at: http://localhost:8000/sponsors/manage/sponsorships/")
109114

@@ -291,8 +296,117 @@ def _create_notification_templates(self):
291296
if created:
292297
self.stdout.write(f" Created {created} notification templates.")
293298

299+
def _create_notification_logs(self, user, today):
300+
"""Create realistic notification log entries for existing sponsorships."""
301+
# Skip if logs already exist for seeded sponsors
302+
names = [s["name"] for s in SPONSORS]
303+
if SponsorshipNotificationLog.objects.filter(sponsorship__sponsor__name__in=names).exists():
304+
return 0
305+
306+
messages = [
307+
{
308+
"subject": "Welcome to the PSF Sponsorship Program, {name}!",
309+
"content": (
310+
"Dear {name},\n\n"
311+
"Thank you for your {level} sponsorship! "
312+
"Your sponsorship period runs from {start} to {end}.\n\n"
313+
"We will be in touch shortly with next steps regarding your benefits.\n\n"
314+
"Best regards,\nPSF Sponsorship Team"
315+
),
316+
"contact_types": "primary, administrative",
317+
"days_after_applied": 1,
318+
"statuses": ("approved", "finalized"),
319+
},
320+
{
321+
"subject": "Action Required: Sponsorship assets needed for {name}",
322+
"content": (
323+
"Dear {name},\n\n"
324+
"This is a reminder that we still need your sponsorship assets "
325+
"(logos, descriptions, etc.) for your {level} sponsorship.\n\n"
326+
"Please submit them at your earliest convenience.\n\n"
327+
"Best regards,\nPSF Sponsorship Team"
328+
),
329+
"contact_types": "primary",
330+
"days_after_applied": 15,
331+
"statuses": ("finalized",),
332+
},
333+
{
334+
"subject": "Contract Sent: {name} {level} Sponsorship",
335+
"content": (
336+
"Dear {name},\n\n"
337+
"Please find attached the sponsorship contract for your "
338+
"{level} sponsorship.\n\n"
339+
"Please review and sign at your earliest convenience.\n\n"
340+
"Best regards,\nPSF Sponsorship Team"
341+
),
342+
"contact_types": "primary, administrative, accounting",
343+
"days_after_applied": 7,
344+
"statuses": ("approved", "finalized"),
345+
},
346+
{
347+
"subject": "Sponsorship Finalized: Welcome aboard, {name}!",
348+
"content": (
349+
"Dear {name},\n\n"
350+
"Great news! Your {level} sponsorship has been finalized. "
351+
"Your benefits are now active.\n\n"
352+
"If you have any questions, don't hesitate to reach out.\n\n"
353+
"Best regards,\nPSF Sponsorship Team"
354+
),
355+
"contact_types": "primary, manager",
356+
"days_after_applied": 20,
357+
"statuses": ("finalized",),
358+
},
359+
]
360+
361+
created = 0
362+
sponsorships = Sponsorship.objects.filter(
363+
sponsor__name__in=names,
364+
status__in=[Sponsorship.APPROVED, Sponsorship.FINALIZED],
365+
).select_related("sponsor", "package")
366+
367+
for sp in sponsorships:
368+
primary_email = f"contact@{sp.sponsor.name.lower().replace(' ', '')}.com"
369+
billing_email = f"billing@{sp.sponsor.name.lower().replace(' ', '')}.com"
370+
371+
for msg in messages:
372+
if sp.status not in msg["statuses"]:
373+
continue
374+
375+
recipients = primary_email
376+
if "accounting" in msg["contact_types"] or "administrative" in msg["contact_types"]:
377+
recipients = f"{primary_email}, {billing_email}"
378+
379+
base_date = sp.applied_on or today
380+
sent_at = timezone.make_aware(
381+
datetime.combine(base_date + timedelta(days=msg["days_after_applied"]), datetime.min.time())
382+
)
383+
384+
SponsorshipNotificationLog.objects.create(
385+
sponsorship=sp,
386+
subject=msg["subject"].format(
387+
name=sp.sponsor.name,
388+
level=sp.level_name,
389+
),
390+
content=msg["content"].format(
391+
name=sp.sponsor.name,
392+
level=sp.level_name,
393+
start=sp.start_date or "TBD",
394+
end=sp.end_date or "TBD",
395+
),
396+
recipients=recipients,
397+
contact_types=msg["contact_types"],
398+
sent_by=user,
399+
sent_at=sent_at,
400+
)
401+
created += 1
402+
403+
if created:
404+
self.stdout.write(f" Created {created} notification log entries.")
405+
return created
406+
294407
def _clean(self):
295408
names = [s["name"] for s in SPONSORS]
409+
SponsorshipNotificationLog.objects.filter(sponsorship__sponsor__name__in=names).delete()
296410
Contract.objects.filter(sponsorship__sponsor__name__in=names).delete()
297411
Sponsorship.objects.filter(sponsor__name__in=names).delete()
298412
Sponsor.objects.filter(name__in=names).delete()
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Generated by Django 5.2.11 on 2026-03-22 20:15
2+
3+
import django.db.models.deletion
4+
import django.utils.timezone
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
dependencies = [
11+
("sponsors", "0103_alter_benefitfeature_polymorphic_ctype_and_more"),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="SponsorshipNotificationLog",
18+
fields=[
19+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
20+
("subject", models.CharField(max_length=500)),
21+
("content", models.TextField(blank=True)),
22+
("recipients", models.TextField(help_text="Comma-separated email addresses")),
23+
("contact_types", models.CharField(blank=True, max_length=200)),
24+
("sent_at", models.DateTimeField(default=django.utils.timezone.now)),
25+
(
26+
"sent_by",
27+
models.ForeignKey(
28+
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
29+
),
30+
),
31+
(
32+
"sponsorship",
33+
models.ForeignKey(
34+
on_delete=django.db.models.deletion.CASCADE,
35+
related_name="notification_logs",
36+
to="sponsors.sponsorship",
37+
),
38+
),
39+
],
40+
options={
41+
"verbose_name": "Notification Log",
42+
"verbose_name_plural": "Notification Logs",
43+
"ordering": ["-sent_at"],
44+
},
45+
),
46+
]

apps/sponsors/models/__init__.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@
2929
TieredBenefitConfiguration,
3030
)
3131
from apps.sponsors.models.contract import Contract, LegalClause, signed_contract_random_path
32-
from apps.sponsors.models.notifications import SPONSOR_TEMPLATE_HELP_TEXT, SponsorEmailNotificationTemplate
32+
from apps.sponsors.models.notifications import (
33+
SPONSOR_TEMPLATE_HELP_TEXT,
34+
SponsorEmailNotificationTemplate,
35+
SponsorshipNotificationLog,
36+
)
3337
from apps.sponsors.models.sponsors import Sponsor, SponsorBenefit, SponsorContact
3438
from apps.sponsors.models.sponsorship import (
3539
Sponsorship,
@@ -40,19 +44,15 @@
4044
)
4145

4246
__all__ = [
43-
# notifications
4447
"SPONSOR_TEMPLATE_HELP_TEXT",
45-
# benefits
4648
"BaseEmailTargetable",
4749
"BaseLogoPlacement",
4850
"BaseTieredBenefit",
4951
"BenefitFeature",
5052
"BenefitFeatureConfiguration",
51-
# contract
5253
"Contract",
5354
"EmailTargetable",
5455
"EmailTargetableConfiguration",
55-
# assets
5656
"FileAsset",
5757
"GenericAsset",
5858
"ImgAsset",
@@ -70,15 +70,14 @@
7070
"RequiredTextAsset",
7171
"RequiredTextAssetConfiguration",
7272
"ResponseAsset",
73-
# sponsors
7473
"Sponsor",
7574
"SponsorBenefit",
7675
"SponsorContact",
7776
"SponsorEmailNotificationTemplate",
78-
# sponsorship
7977
"Sponsorship",
8078
"SponsorshipBenefit",
8179
"SponsorshipCurrentYear",
80+
"SponsorshipNotificationLog",
8281
"SponsorshipPackage",
8382
"SponsorshipProgram",
8483
"TextAsset",

apps/sponsors/models/notifications.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Email notification template models for sponsor communications."""
22

33
from django.conf import settings
4+
from django.db import models
5+
from django.utils import timezone
46

57
from apps.mailing.models import BaseEmailTemplate
68

@@ -57,3 +59,45 @@ def get_email_message(self, sponsorship, **kwargs):
5759
to=recipients,
5860
context={"sponsorship": sponsorship},
5961
)
62+
63+
64+
class SponsorshipNotificationLog(models.Model):
65+
"""Persisted record of every notification sent to a sponsorship."""
66+
67+
sponsorship = models.ForeignKey(
68+
"sponsors.Sponsorship",
69+
on_delete=models.CASCADE,
70+
related_name="notification_logs",
71+
)
72+
subject = models.CharField(max_length=500)
73+
content = models.TextField(blank=True)
74+
recipients = models.TextField(help_text="Comma-separated email addresses")
75+
contact_types = models.CharField(max_length=200, blank=True)
76+
sent_by = models.ForeignKey(
77+
settings.AUTH_USER_MODEL,
78+
on_delete=models.SET_NULL,
79+
null=True,
80+
blank=True,
81+
)
82+
sent_at = models.DateTimeField(default=timezone.now)
83+
84+
class Meta:
85+
"""Meta configuration for SponsorshipNotificationLog."""
86+
87+
ordering = ["-sent_at"]
88+
verbose_name = "Notification Log"
89+
verbose_name_plural = "Notification Logs"
90+
91+
def __str__(self):
92+
"""Return a human-readable representation of the log entry."""
93+
return f"{self.subject}{self.sponsorship} ({self.sent_at:%Y-%m-%d %H:%M})"
94+
95+
@property
96+
def recipient_list(self):
97+
"""Return recipients as a list of email addresses."""
98+
return [r.strip() for r in self.recipients.split(",") if r.strip()]
99+
100+
@property
101+
def contact_type_list(self):
102+
"""Return contact types as a list of strings."""
103+
return [t.strip() for t in self.contact_types.split(",") if t.strip()]

0 commit comments

Comments
 (0)