Skip to content

Commit 62df6d5

Browse files
authored
Fix summary to handle pending status properly (#4582)
1 parent 2b33b16 commit 62df6d5

File tree

2 files changed

+196
-47
lines changed

2 files changed

+196
-47
lines changed

backend/grants/summary.py

Lines changed: 81 additions & 46 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+
current_or_pending_status=Coalesce("pending_status", "status")
32+
)
3033

3134
grants_by_country = filtered_grants.values(
32-
"departure_country", "pending_status"
35+
"departure_country", "current_or_pending_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+
current_or_pending_status: str = data["current_or_pending_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][current_or_pending_status] += data["total"]
116+
status_totals[current_or_pending_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][current_or_pending_status] += data["total"]
118122

119123
return summary, status_totals, totals_per_continent
120124

@@ -123,83 +127,110 @@ 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", "current_or_pending_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+
current_or_pending_status: str = data["current_or_pending_status"]
135139
total = data["total"]
136-
country_type_summary[country_type][pending_status] += total
140+
country_type_summary[country_type][current_or_pending_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(
145-
total=Count("id")
146-
)
148+
gender_data = filtered_grants.values(
149+
"gender", "current_or_pending_status"
150+
).annotate(total=Count("id"))
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+
current_or_pending_status: str = data["current_or_pending_status"]
152156
total = data["total"]
153-
gender_summary[gender][pending_status] += total
157+
gender_summary[gender][current_or_pending_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
166+
reimbursements = GrantReimbursement.objects.filter(
167+
grant__in=filtered_grants
168+
).annotate(
169+
current_or_pending_status=Coalesce("grant__pending_status", "grant__status")
170+
)
163171

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
172+
aggregations: dict[str, Sum] = {
173+
status_value: Sum(
174+
Case(
175+
When(current_or_pending_status=status_value, then="granted_amount"),
176+
default=0,
177+
output_field=DecimalField(),
178+
)
168179
)
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
180+
for status_value, _ in statuses
181+
}
182+
result = reimbursements.aggregate(**aggregations)
183+
184+
financial_summary: dict[str, int] = {
185+
status_value: int(result[status_value] or 0) for status_value, _ in statuses
186+
}
187+
overall_total: int = sum(
188+
amount
189+
for status_value, amount in financial_summary.items()
190+
if status_value in self.BUDGET_STATUSES
191+
)
173192

174193
return financial_summary, overall_total
175194

176195
def _aggregate_data_by_reimbursement_category(self, filtered_grants, statuses):
177196
"""
178197
Aggregates grant data by reimbursement category and status.
179198
"""
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
199+
category_data = (
200+
GrantReimbursement.objects.filter(grant__in=filtered_grants)
201+
.annotate(
202+
current_or_pending_status=Coalesce(
203+
"grant__pending_status", "grant__status"
204+
)
205+
)
206+
.values("category__category", "current_or_pending_status")
207+
.annotate(total=Count("id"))
208+
)
209+
category_summary: dict[str, dict[str, int]] = defaultdict(
210+
lambda: {status[0]: 0 for status in statuses}
211+
)
212+
for data in category_data:
213+
category: str = data["category__category"]
214+
current_or_pending_status: str = data["current_or_pending_status"]
215+
total: int = data["total"]
216+
category_summary[category][current_or_pending_status] += total
186217
return dict(category_summary)
187218

188219
def _aggregate_data_by_grant_type(self, filtered_grants, statuses):
189220
"""
190221
Aggregates grant data by grant_type and status.
191222
"""
192223
grant_type_data = filtered_grants.values(
193-
"grant_type", "pending_status"
224+
"grant_type", "current_or_pending_status"
194225
).annotate(total=Count("id"))
195226
grant_type_summary = defaultdict(lambda: {status[0]: 0 for status in statuses})
196227

197228
for data in grant_type_data:
198229
grant_types = data["grant_type"]
199-
pending_status = data["pending_status"]
230+
current_or_pending_status: str = data["current_or_pending_status"]
200231
total = data["total"]
201232
for grant_type in grant_types:
202-
grant_type_summary[grant_type][pending_status] += total
233+
grant_type_summary[grant_type][current_or_pending_status] += total
203234

204235
return dict(grant_type_summary)
205236

@@ -224,13 +255,13 @@ def _aggregate_data_by_speaker_status(self, filtered_grants, statuses):
224255

225256
proposed_speaker_data = (
226257
filtered_grants.filter(is_proposed_speaker=True)
227-
.values("pending_status")
258+
.values("current_or_pending_status")
228259
.annotate(total=Count("id"))
229260
)
230261

231262
confirmed_speaker_data = (
232263
filtered_grants.filter(is_confirmed_speaker=True)
233-
.values("pending_status")
264+
.values("current_or_pending_status")
234265
.annotate(total=Count("id"))
235266
)
236267

@@ -239,14 +270,18 @@ def _aggregate_data_by_speaker_status(self, filtered_grants, statuses):
239270
)
240271

241272
for data in proposed_speaker_data:
242-
pending_status = data["pending_status"]
273+
current_or_pending_status: str = data["current_or_pending_status"]
243274
total = data["total"]
244-
speaker_status_summary["proposed_speaker"][pending_status] += total
275+
speaker_status_summary["proposed_speaker"][current_or_pending_status] += (
276+
total
277+
)
245278

246279
for data in confirmed_speaker_data:
247-
pending_status = data["pending_status"]
280+
current_or_pending_status: str = data["current_or_pending_status"]
248281
total = data["total"]
249-
speaker_status_summary["confirmed_speaker"][pending_status] += total
282+
speaker_status_summary["confirmed_speaker"][current_or_pending_status] += (
283+
total
284+
)
250285

251286
return dict(speaker_status_summary)
252287

@@ -263,13 +298,13 @@ def _aggregate_data_by_requested_needs_summary(self, filtered_grants, statuses):
263298
for field in requested_needs_summary.keys():
264299
field_data = (
265300
filtered_grants.filter(**{field: True})
266-
.values("pending_status")
301+
.values("current_or_pending_status")
267302
.annotate(total=Count("id"))
268303
)
269304
for data in field_data:
270-
pending_status = data["pending_status"]
305+
current_or_pending_status: str = data["current_or_pending_status"]
271306
total = data["total"]
272-
requested_needs_summary[field][pending_status] += total
307+
requested_needs_summary[field][current_or_pending_status] += total
273308

274309
return requested_needs_summary
275310

@@ -278,14 +313,14 @@ def _aggregate_data_by_occupation(self, filtered_grants, statuses):
278313
Aggregates grant data by occupation and status.
279314
"""
280315
occupation_data = filtered_grants.values(
281-
"occupation", "pending_status"
316+
"occupation", "current_or_pending_status"
282317
).annotate(total=Count("id"))
283318
occupation_summary = defaultdict(lambda: {status[0]: 0 for status in statuses})
284319

285320
for data in occupation_data:
286321
occupation = data["occupation"]
287-
pending_status = data["pending_status"]
322+
current_or_pending_status: str = data["current_or_pending_status"]
288323
total = data["total"]
289-
occupation_summary[occupation][pending_status] += total
324+
occupation_summary[occupation][current_or_pending_status] += total
290325

291326
return dict(occupation_summary)

backend/grants/tests/test_summary.py

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

0 commit comments

Comments
 (0)