Skip to content

Commit aa472df

Browse files
JacobCoffeeclaude
andcommitted
feat: revenue report with charts and per-sponsorship financial breakdown
Adds a Revenue Report page (More > Revenue) with: - Summary cards: total/finalized/approved revenue, deal count + average - Revenue by Package: full-width horizontal bar chart - Year-over-Year comparison chart - Sponsorship detail table with fee, internal value, margin percentage Dashboard revenue card now links directly to the report. Sponsorship detail page gets a collapsible Financial Breakdown section showing fee vs internal value with a coverage bar, and value-by-program mini bar chart. Guide documented. 6 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6abb133 commit aa472df

8 files changed

Lines changed: 412 additions & 6 deletions

File tree

apps/sponsors/manage/tests.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2573,3 +2573,54 @@ def test_shows_sponsorship_count(self):
25732573
def test_nav_has_sponsors_link(self):
25742574
response = self.client.get(reverse("manage_dashboard"))
25752575
self.assertContains(response, "Sponsors")
2576+
2577+
2578+
class RevenueReportViewTests(SponsorshipReviewTestBase):
2579+
"""Test revenue report view."""
2580+
2581+
def test_report_loads(self):
2582+
response = self.client.get(reverse("manage_revenue"))
2583+
self.assertEqual(response.status_code, 200)
2584+
2585+
def test_report_shows_revenue(self):
2586+
self.sponsorship.status = Sponsorship.FINALIZED
2587+
self.sponsorship.save(update_fields=["status"])
2588+
response = self.client.get(reverse("manage_revenue") + f"?year={self.year}")
2589+
self.assertContains(response, "150,000")
2590+
self.assertContains(response, "Acme Corp")
2591+
2592+
def test_report_excludes_applied(self):
2593+
"""Applied sponsorships are not counted in revenue."""
2594+
response = self.client.get(reverse("manage_revenue") + f"?year={self.year}")
2595+
self.assertNotContains(response, "Acme Corp")
2596+
2597+
def test_year_over_year(self):
2598+
self.sponsorship.status = Sponsorship.FINALIZED
2599+
self.sponsorship.save(update_fields=["status"])
2600+
response = self.client.get(reverse("manage_revenue") + f"?year={self.year}")
2601+
self.assertContains(response, str(self.year))
2602+
2603+
def test_dashboard_revenue_links_to_report(self):
2604+
response = self.client.get(reverse("manage_dashboard"))
2605+
self.assertContains(response, "manage_revenue" if False else "/sponsors/manage/revenue/")
2606+
2607+
2608+
class SponsorshipDetailFinancialTests(SponsorshipReviewTestBase):
2609+
"""Test financial breakdown on sponsorship detail page."""
2610+
2611+
def test_financial_breakdown_shown(self):
2612+
self.sponsorship.status = Sponsorship.FINALIZED
2613+
self.sponsorship.sponsorship_fee = 150000
2614+
self.sponsorship.save()
2615+
from apps.sponsors.models import SponsorBenefit
2616+
2617+
SponsorBenefit.objects.create(
2618+
sponsorship=self.sponsorship,
2619+
sponsorship_benefit=self.benefit,
2620+
name="Logo",
2621+
program=self.program,
2622+
benefit_internal_value=5000,
2623+
)
2624+
response = self.client.get(reverse("manage_sponsorship_detail", args=[self.sponsorship.pk]))
2625+
self.assertContains(response, "Financial Breakdown")
2626+
self.assertContains(response, "Foundation")

apps/sponsors/manage/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
path(
2424
"benefit-configs/<int:pk>/delete/", views.BenefitConfigDeleteView.as_view(), name="manage_benefit_config_delete"
2525
),
26+
# Revenue report
27+
path("revenue/", views.RevenueReportView.as_view(), name="manage_revenue"),
2628
# Asset browser
2729
path("assets/", views.AssetBrowserView.as_view(), name="manage_assets"),
2830
# Legal clauses

apps/sponsors/manage/views.py

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from django.conf import settings
1414
from django.contrib import messages
1515
from django.db import transaction
16-
from django.db.models import Q, Sum
16+
from django.db.models import Count, Q, Sum
1717
from django.http import HttpResponse
1818
from django.shortcuts import get_object_or_404, redirect, render
1919
from django.urls import reverse
@@ -555,6 +555,94 @@ def get_context_data(self, **kwargs):
555555
return context
556556

557557

558+
# ── Revenue Report ────────────────────────────────────────────────────
559+
560+
561+
class RevenueReportView(SponsorshipAdminRequiredMixin, TemplateView):
562+
"""Financial summary with revenue breakdowns and charts."""
563+
564+
template_name = "sponsors/manage/revenue_report.html"
565+
566+
def _year_summary(self, year):
567+
"""Return revenue stats for a single year."""
568+
qs = Sponsorship.objects.filter(year=year, status__in=[Sponsorship.APPROVED, Sponsorship.FINALIZED])
569+
total = qs.aggregate(total=Sum("sponsorship_fee"))["total"] or 0
570+
count = qs.count()
571+
return {"year": year, "total": total, "count": count, "avg": total // count if count else 0}
572+
573+
def _package_breakdown(self, year_qs):
574+
"""Return revenue grouped by package tier."""
575+
rows = (
576+
year_qs.values("package__name")
577+
.annotate(revenue=Sum("sponsorship_fee"), count=Count("id"))
578+
.order_by("-revenue")
579+
)
580+
return [
581+
{"name": r["package__name"] or "Custom / No Package", "revenue": r["revenue"] or 0, "count": r["count"]}
582+
for r in rows
583+
]
584+
585+
def get_context_data(self, **kwargs):
586+
"""Return context with revenue breakdowns."""
587+
context = super().get_context_data(**kwargs)
588+
589+
# Year selection
590+
all_years = Sponsorship.objects.values_list("year", flat=True).distinct().order_by("-year")
591+
all_years = [y for y in all_years if y]
592+
593+
selected_year = self.request.GET.get("year")
594+
if selected_year:
595+
selected_year = int(selected_year)
596+
elif all_years:
597+
selected_year = all_years[0]
598+
599+
# Year-over-year comparison
600+
yoy = [self._year_summary(y) for y in all_years]
601+
max_yoy = max((s["total"] for s in yoy), default=1) or 1
602+
603+
# Current year detail
604+
year_qs = Sponsorship.objects.filter(
605+
year=selected_year, status__in=[Sponsorship.APPROVED, Sponsorship.FINALIZED]
606+
)
607+
total_revenue = year_qs.aggregate(total=Sum("sponsorship_fee"))["total"] or 0
608+
total_count = year_qs.count()
609+
finalized_revenue = (
610+
year_qs.filter(status=Sponsorship.FINALIZED).aggregate(total=Sum("sponsorship_fee"))["total"] or 0
611+
)
612+
approved_revenue = (
613+
year_qs.filter(status=Sponsorship.APPROVED).aggregate(total=Sum("sponsorship_fee"))["total"] or 0
614+
)
615+
616+
# Package breakdown
617+
by_package = self._package_breakdown(year_qs)
618+
max_pkg = max((p["revenue"] for p in by_package), default=1) or 1
619+
620+
# Per-sponsorship detail table
621+
sponsorships = (
622+
year_qs.select_related("sponsor", "package").prefetch_related("benefits").order_by("-sponsorship_fee")
623+
)
624+
for sp in sponsorships:
625+
sp.internal_total = sp.estimated_cost
626+
627+
context.update(
628+
{
629+
"years": all_years,
630+
"selected_year": selected_year,
631+
"total_revenue": total_revenue,
632+
"total_count": total_count,
633+
"avg_deal": total_revenue // total_count if total_count else 0,
634+
"finalized_revenue": finalized_revenue,
635+
"approved_revenue": approved_revenue,
636+
"by_package": by_package,
637+
"max_pkg_revenue": max_pkg,
638+
"yoy": yoy,
639+
"max_yoy_revenue": max_yoy,
640+
"sponsorships": sponsorships,
641+
}
642+
)
643+
return context
644+
645+
558646
# ── Sponsor Directory ─────────────────────────────────────────────────
559647

560648

@@ -567,8 +655,6 @@ class SponsorListView(SponsorshipAdminRequiredMixin, ListView):
567655

568656
def get_queryset(self):
569657
"""Return sponsors filtered by search, annotated with sponsorship count."""
570-
from django.db.models import Count
571-
572658
qs = Sponsor.objects.annotate(
573659
sponsorship_count=Count("sponsorship"),
574660
contact_count=Count("contacts"),
@@ -843,6 +929,14 @@ def get_context_data(self, **kwargs):
843929
).order_by("-last_update")
844930
else:
845931
context["historical_contracts"] = Contract.objects.none()
932+
# Financial breakdown by program
933+
program_values = (
934+
sp.benefits.values("program__name").annotate(total=Sum("benefit_internal_value")).order_by("-total")
935+
)
936+
context["program_breakdown"] = [
937+
{"name": pv["program__name"] or "Other", "total": pv["total"] or 0} for pv in program_values
938+
]
939+
context["max_program_value"] = max((pv["total"] or 0 for pv in program_values), default=1) or 1
846940
# Communication history
847941
context["notification_logs"] = sp.notification_logs.select_related("sent_by").all()[:20]
848942
# Renewal info

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_revenue' %}">Revenue</a>
682683
<a href="{% url 'manage_sponsors' %}">Sponsors</a>
683684
<a href="{% url 'manage_legal_clauses' %}">Legal Clauses</a>
684685
<a href="{% url 'manage_assets' %}">Assets</a>

apps/sponsors/templates/sponsors/manage/dashboard.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@
2929
<div class="card-value">{{ total_sponsorships }}</div>
3030
<div class="card-label">Sponsorships</div>
3131
</div>
32-
<div class="manage-card gold">
32+
<a href="{% url 'manage_revenue' %}?year={{ selected_year }}" class="manage-card gold" style="text-decoration:none;cursor:pointer;">
3333
<div class="card-value">${{ total_revenue|floatformat:"0" }}</div>
34-
<div class="card-label">Revenue</div>
35-
</div>
34+
<div class="card-label">Revenue &rarr;</div>
35+
</a>
3636
<div class="manage-card">
3737
<div class="card-value">{{ count_applied }}</div>
3838
<div class="card-label">Pending Review</div>

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ <h2 style="font-size:22px;font-weight:700;color:#1a1a2e;margin:0 0 8px;">Sponsor
2424
<a href="#sponsors-contacts" style="color:#3776ab;text-decoration:none;">Sponsors &amp; Contacts</a><br>
2525
<a href="#renewals" style="color:#3776ab;text-decoration:none;">Renewals &amp; Expiry</a><br>
2626
<a href="#assets" style="color:#3776ab;text-decoration:none;">Assets</a><br>
27+
<a href="#revenue" style="color:#3776ab;text-decoration:none;">Revenue Report</a><br>
2728
<a href="#bulk" style="color:#3776ab;text-decoration:none;">Bulk Actions &amp; Export</a><br>
2829
<a href="#admin-only" style="color:#3776ab;text-decoration:none;">What's Still in Django Admin</a><br>
2930
</div>
@@ -331,6 +332,26 @@ <h3 style="font-size:16px;font-weight:700;color:#3776ab;border-bottom:2px solid
331332
</p>
332333
</div>
333334

335+
<!-- Revenue Report -->
336+
<div class="manage-section" id="revenue">
337+
<h3 style="font-size:16px;font-weight:700;color:#3776ab;border-bottom:2px solid #3776ab;padding-bottom:6px;">Revenue Report</h3>
338+
339+
<p style="font-size:13px;line-height:1.8;color:#444;">
340+
The <a href="{% url 'manage_revenue' %}" style="color:#3776ab;">Revenue Report</a> (under More) gives a financial overview of sponsorship income by year. It includes:
341+
</p>
342+
343+
<ul style="font-size:13px;line-height:2;color:#444;padding-left:20px;">
344+
<li><strong>Summary cards</strong> &mdash; total revenue, finalized revenue, approved (pending), sponsorship count with average deal size</li>
345+
<li><strong>Revenue by package</strong> &mdash; horizontal bar chart showing income by package tier with deal counts</li>
346+
<li><strong>Year-over-year</strong> &mdash; compare total revenue across all years at a glance</li>
347+
<li><strong>Sponsorship detail table</strong> &mdash; every sponsorship for the selected year with fee, internal value, margin percentage, and period</li>
348+
</ul>
349+
350+
<p style="font-size:13px;line-height:1.8;color:#444;">
351+
Only approved and finalized sponsorships are counted toward revenue. Applied and rejected sponsorships are excluded.
352+
</p>
353+
</div>
354+
334355
<!-- Bulk Actions & Export -->
335356
<div class="manage-section" id="bulk">
336357
<h3 style="font-size:16px;font-weight:700;color:#3776ab;border-bottom:2px solid #3776ab;padding-bottom:6px;">Bulk Actions &amp; Export</h3>

0 commit comments

Comments
 (0)