Skip to content

Commit dfd9f19

Browse files
committed
Fix grant summary to handle NULL pending_status
Use Coalesce("pending_status", "status") to fall back to status when pending_status is NULL. Refactor financial and reimbursement category aggregations to use database-level annotations consistently, replacing the N+1 query loop with Case/When conditional aggregation. Add tests for NULL pending_status, mixed statuses, and financial data edge cases. Fixes PYCON-BACKEND-6H7
1 parent 2b33b16 commit dfd9f19

File tree

2 files changed

+192
-46
lines changed

2 files changed

+192
-46
lines changed

backend/grants/summary.py

Lines changed: 74 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from collections import defaultdict
22

3-
from django.db.models import Count, Exists, OuterRef, Sum
3+
from django.db.models import Case, Count, DecimalField, Exists, OuterRef, Sum, When
4+
from django.db.models.functions import Coalesce
45

56
from conferences.models.conference import Conference
67
from countries import countries
@@ -26,10 +27,12 @@ def calculate(self, conference_id):
2627
"""
2728
statuses = Grant.Status.choices
2829
conference = Conference.objects.get(id=conference_id)
29-
filtered_grants = Grant.objects.for_conference(conference)
30+
filtered_grants = Grant.objects.for_conference(conference).annotate(
31+
effective_status=Coalesce("pending_status", "status")
32+
)
3033

3134
grants_by_country = filtered_grants.values(
32-
"departure_country", "pending_status"
35+
"departure_country", "effective_status"
3336
).annotate(total=Count("id"))
3437

3538
(
@@ -99,6 +102,7 @@ def _aggregate_data_by_country(self, grants_by_country, statuses):
99102
totals_per_continent = {}
100103

101104
for data in grants_by_country:
105+
effective_status: str = data["effective_status"]
102106
country = countries.get(code=data["departure_country"])
103107
continent = country.continent.name if country else "Unknown"
104108
country_name = f"{country.name} {country.emoji}" if country else "Unknown"
@@ -108,13 +112,13 @@ def _aggregate_data_by_country(self, grants_by_country, statuses):
108112
if key not in summary:
109113
summary[key] = {status[0]: 0 for status in statuses}
110114

111-
summary[key][data["pending_status"]] += data["total"]
112-
status_totals[data["pending_status"]] += data["total"]
115+
summary[key][effective_status] += data["total"]
116+
status_totals[effective_status] += data["total"]
113117

114118
# Update continent totals
115119
if continent not in totals_per_continent:
116120
totals_per_continent[continent] = {status[0]: 0 for status in statuses}
117-
totals_per_continent[continent][data["pending_status"]] += data["total"]
121+
totals_per_continent[continent][effective_status] += data["total"]
118122

119123
return summary, status_totals, totals_per_continent
120124

@@ -123,83 +127,106 @@ def _aggregate_data_by_country_type(self, filtered_grants, statuses):
123127
Aggregates grant data by country type and status.
124128
"""
125129
country_type_data = filtered_grants.values(
126-
"country_type", "pending_status"
130+
"country_type", "effective_status"
127131
).annotate(total=Count("id"))
128132
country_type_summary = defaultdict(
129133
lambda: {status[0]: 0 for status in statuses}
130134
)
131135

132136
for data in country_type_data:
133137
country_type = data["country_type"]
134-
pending_status = data["pending_status"]
138+
effective_status: str = data["effective_status"]
135139
total = data["total"]
136-
country_type_summary[country_type][pending_status] += total
140+
country_type_summary[country_type][effective_status] += total
137141

138142
return dict(country_type_summary)
139143

140144
def _aggregate_data_by_gender(self, filtered_grants, statuses):
141145
"""
142146
Aggregates grant data by gender and status.
143147
"""
144-
gender_data = filtered_grants.values("gender", "pending_status").annotate(
148+
gender_data = filtered_grants.values("gender", "effective_status").annotate(
145149
total=Count("id")
146150
)
147151
gender_summary = defaultdict(lambda: {status[0]: 0 for status in statuses})
148152

149153
for data in gender_data:
150154
gender = data["gender"] if data["gender"] else ""
151-
pending_status = data["pending_status"]
155+
effective_status: str = data["effective_status"]
152156
total = data["total"]
153-
gender_summary[gender][pending_status] += total
157+
gender_summary[gender][effective_status] += total
154158

155159
return dict(gender_summary)
156160

157161
def _aggregate_financial_data_by_status(self, filtered_grants, statuses):
158162
"""
159-
Aggregates financial data (total amounts) by grant status.
163+
Aggregates financial data (total amounts) by grant status
164+
using conditional aggregation in a single query.
160165
"""
161-
financial_summary = {status[0]: 0 for status in statuses}
162-
overall_total = 0
163-
164-
for status in statuses:
165-
grants_for_status = filtered_grants.filter(pending_status=status[0])
166-
reimbursements = GrantReimbursement.objects.filter(
167-
grant__in=grants_for_status
166+
reimbursements = GrantReimbursement.objects.filter(
167+
grant__in=filtered_grants
168+
).annotate(effective_status=Coalesce("grant__pending_status", "grant__status"))
169+
170+
aggregations: dict[str, Sum] = {
171+
status_value: Sum(
172+
Case(
173+
When(effective_status=status_value, then="granted_amount"),
174+
default=0,
175+
output_field=DecimalField(),
176+
)
168177
)
169-
total = reimbursements.aggregate(total=Sum("granted_amount"))["total"] or 0
170-
financial_summary[status[0]] = total
171-
if status[0] in self.BUDGET_STATUSES:
172-
overall_total += total
178+
for status_value, _ in statuses
179+
}
180+
result = reimbursements.aggregate(**aggregations)
181+
182+
financial_summary: dict[str, int] = {
183+
status_value: result[status_value] or 0 for status_value, _ in statuses
184+
}
185+
overall_total: int = sum(
186+
amount
187+
for status_value, amount in financial_summary.items()
188+
if status_value in self.BUDGET_STATUSES
189+
)
173190

174191
return financial_summary, overall_total
175192

176193
def _aggregate_data_by_reimbursement_category(self, filtered_grants, statuses):
177194
"""
178195
Aggregates grant data by reimbursement category and status.
179196
"""
180-
category_summary = defaultdict(lambda: {status[0]: 0 for status in statuses})
181-
reimbursements = GrantReimbursement.objects.filter(grant__in=filtered_grants)
182-
for r in reimbursements:
183-
category = r.category.category
184-
status = r.grant.pending_status
185-
category_summary[category][status] += 1
197+
category_data = (
198+
GrantReimbursement.objects.filter(grant__in=filtered_grants)
199+
.annotate(
200+
effective_status=Coalesce("grant__pending_status", "grant__status")
201+
)
202+
.values("category__category", "effective_status")
203+
.annotate(total=Count("id"))
204+
)
205+
category_summary: dict[str, dict[str, int]] = defaultdict(
206+
lambda: {status[0]: 0 for status in statuses}
207+
)
208+
for data in category_data:
209+
category: str = data["category__category"]
210+
effective_status: str = data["effective_status"]
211+
total: int = data["total"]
212+
category_summary[category][effective_status] += total
186213
return dict(category_summary)
187214

188215
def _aggregate_data_by_grant_type(self, filtered_grants, statuses):
189216
"""
190217
Aggregates grant data by grant_type and status.
191218
"""
192219
grant_type_data = filtered_grants.values(
193-
"grant_type", "pending_status"
220+
"grant_type", "effective_status"
194221
).annotate(total=Count("id"))
195222
grant_type_summary = defaultdict(lambda: {status[0]: 0 for status in statuses})
196223

197224
for data in grant_type_data:
198225
grant_types = data["grant_type"]
199-
pending_status = data["pending_status"]
226+
effective_status: str = data["effective_status"]
200227
total = data["total"]
201228
for grant_type in grant_types:
202-
grant_type_summary[grant_type][pending_status] += total
229+
grant_type_summary[grant_type][effective_status] += total
203230

204231
return dict(grant_type_summary)
205232

@@ -224,13 +251,13 @@ def _aggregate_data_by_speaker_status(self, filtered_grants, statuses):
224251

225252
proposed_speaker_data = (
226253
filtered_grants.filter(is_proposed_speaker=True)
227-
.values("pending_status")
254+
.values("effective_status")
228255
.annotate(total=Count("id"))
229256
)
230257

231258
confirmed_speaker_data = (
232259
filtered_grants.filter(is_confirmed_speaker=True)
233-
.values("pending_status")
260+
.values("effective_status")
234261
.annotate(total=Count("id"))
235262
)
236263

@@ -239,14 +266,16 @@ def _aggregate_data_by_speaker_status(self, filtered_grants, statuses):
239266
)
240267

241268
for data in proposed_speaker_data:
242-
pending_status = data["pending_status"]
269+
effective_status: str = data["effective_status"]
243270
total = data["total"]
244-
speaker_status_summary["proposed_speaker"][pending_status] += total
271+
speaker_status_summary["proposed_speaker"][effective_status] += total
245272

246273
for data in confirmed_speaker_data:
247-
pending_status = data["pending_status"]
274+
effective_status_confirmed: str = data["effective_status"]
248275
total = data["total"]
249-
speaker_status_summary["confirmed_speaker"][pending_status] += total
276+
speaker_status_summary["confirmed_speaker"][effective_status_confirmed] += (
277+
total
278+
)
250279

251280
return dict(speaker_status_summary)
252281

@@ -263,13 +292,13 @@ def _aggregate_data_by_requested_needs_summary(self, filtered_grants, statuses):
263292
for field in requested_needs_summary.keys():
264293
field_data = (
265294
filtered_grants.filter(**{field: True})
266-
.values("pending_status")
295+
.values("effective_status")
267296
.annotate(total=Count("id"))
268297
)
269298
for data in field_data:
270-
pending_status = data["pending_status"]
299+
effective_status: str = data["effective_status"]
271300
total = data["total"]
272-
requested_needs_summary[field][pending_status] += total
301+
requested_needs_summary[field][effective_status] += total
273302

274303
return requested_needs_summary
275304

@@ -278,14 +307,14 @@ def _aggregate_data_by_occupation(self, filtered_grants, statuses):
278307
Aggregates grant data by occupation and status.
279308
"""
280309
occupation_data = filtered_grants.values(
281-
"occupation", "pending_status"
310+
"occupation", "effective_status"
282311
).annotate(total=Count("id"))
283312
occupation_summary = defaultdict(lambda: {status[0]: 0 for status in statuses})
284313

285314
for data in occupation_data:
286315
occupation = data["occupation"]
287-
pending_status = data["pending_status"]
316+
effective_status: str = data["effective_status"]
288317
total = data["total"]
289-
occupation_summary[occupation][pending_status] += total
318+
occupation_summary[occupation][effective_status] += total
290319

291320
return dict(occupation_summary)

backend/grants/tests/test_summary.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import pytest
2-
from .factories import GrantFactory
2+
from .factories import (
3+
GrantFactory,
4+
GrantReimbursementCategoryFactory,
5+
GrantReimbursementFactory,
6+
)
37
from grants.summary import GrantSummary
48
from conferences.tests.factories import ConferenceFactory
59

@@ -73,3 +77,116 @@ def test_grant_summary_calculation_by_status(grants_set):
7377
assert summary["status_totals"]["rejected"] == 3
7478
assert summary["status_totals"]["waiting_list"] == 7
7579
assert summary["total_grants"] == 15
80+
81+
82+
@pytest.mark.django_db
83+
def test_grant_summary_with_null_pending_status():
84+
"""Grants with pending_status=None should fall back to status via Coalesce."""
85+
conference = ConferenceFactory()
86+
87+
GrantFactory.create_batch(
88+
3,
89+
conference=conference,
90+
status="approved",
91+
pending_status=None,
92+
departure_country="IT",
93+
gender="female",
94+
)
95+
GrantFactory.create_batch(
96+
2,
97+
conference=conference,
98+
status="rejected",
99+
pending_status=None,
100+
departure_country="FR",
101+
gender="male",
102+
)
103+
104+
summary = GrantSummary().calculate(conference_id=conference.id)
105+
106+
# status_totals should reflect the fallback status values
107+
assert summary["status_totals"]["approved"] == 3
108+
assert summary["status_totals"]["rejected"] == 2
109+
assert summary["total_grants"] == 5
110+
111+
# country stats should also work
112+
assert summary["country_stats"][("Europe", "Italy 🇮🇹", "IT")]["approved"] == 3
113+
assert summary["country_stats"][("Europe", "France 🇫🇷", "FR")]["rejected"] == 2
114+
115+
# gender stats should use the fallback too
116+
assert summary["gender_stats"]["female"]["approved"] == 3
117+
assert summary["gender_stats"]["male"]["rejected"] == 2
118+
119+
120+
@pytest.mark.django_db
121+
def test_grant_summary_with_mixed_pending_status():
122+
"""Mix of grants with and without pending_status works correctly."""
123+
conference = ConferenceFactory()
124+
125+
# Grant with pending_status set (pending_status takes precedence)
126+
GrantFactory.create_batch(
127+
2,
128+
conference=conference,
129+
status="pending",
130+
pending_status="approved",
131+
departure_country="IT",
132+
)
133+
# Grant with pending_status=None (falls back to status)
134+
GrantFactory.create_batch(
135+
3,
136+
conference=conference,
137+
status="rejected",
138+
pending_status=None,
139+
departure_country="IT",
140+
)
141+
142+
summary = GrantSummary().calculate(conference_id=conference.id)
143+
144+
assert summary["status_totals"]["approved"] == 2
145+
assert summary["status_totals"]["rejected"] == 3
146+
assert summary["total_grants"] == 5
147+
148+
149+
@pytest.mark.django_db
150+
def test_grant_summary_financial_data_with_null_pending_status():
151+
"""Financial aggregation handles null pending_status via Coalesce fallback."""
152+
conference = ConferenceFactory()
153+
154+
ticket_category = GrantReimbursementCategoryFactory(
155+
conference=conference,
156+
ticket=True,
157+
)
158+
159+
grant_with_status = GrantFactory(
160+
conference=conference,
161+
status="approved",
162+
pending_status=None,
163+
departure_country="IT",
164+
)
165+
GrantReimbursementFactory(
166+
grant=grant_with_status,
167+
category=ticket_category,
168+
granted_amount=100,
169+
)
170+
171+
grant_with_pending = GrantFactory(
172+
conference=conference,
173+
status="pending",
174+
pending_status="confirmed",
175+
departure_country="IT",
176+
)
177+
GrantReimbursementFactory(
178+
grant=grant_with_pending,
179+
category=ticket_category,
180+
granted_amount=200,
181+
)
182+
183+
summary = GrantSummary().calculate(conference_id=conference.id)
184+
185+
assert summary["financial_summary"]["approved"] == 100
186+
assert summary["financial_summary"]["confirmed"] == 200
187+
# approved + confirmed are both BUDGET_STATUSES
188+
assert summary["total_amount"] == 300
189+
190+
# reimbursement category summary should also work
191+
assert summary["reimbursement_category_summary"]["ticket"]["approved"] == 1
192+
assert summary["reimbursement_category_summary"]["ticket"]["confirmed"] == 1

0 commit comments

Comments
 (0)