Skip to content

Commit 007719e

Browse files
committed
Add Django command to backfill grant reimbursements
Add a Django management command to migrate old grant data to the new reimbursement model. This command will: - Create default reimbursement categories for each conference - Convert approved_type and amount fields into GrantReimbursement records - Validate data integrity by comparing totals before and after migration The migration preserves the full history of grant amounts while moving to the new flexible category system.
1 parent 374ae22 commit 007719e

File tree

4 files changed

+205
-1
lines changed

4 files changed

+205
-1
lines changed

backend/grants/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from decimal import Decimal
2+
3+
from django.core.management.base import BaseCommand
4+
from grants.models import Grant, GrantReimbursement, GrantReimbursementCategory
5+
from conferences.models import Conference
6+
7+
8+
class Command(BaseCommand):
9+
help = "Backfill GrantReimbursement entries from approved_type data on approved grants."
10+
11+
def handle(self, *args, **options):
12+
self.stdout.write("🚀 Starting backfill of grant reimbursements...")
13+
14+
self._ensure_categories_exist()
15+
self._migrate_grants()
16+
self._validate_migration()
17+
18+
self.stdout.write(self.style.SUCCESS("✅ Backfill completed successfully."))
19+
20+
def _ensure_categories_exist(self):
21+
for conference in Conference.objects.all():
22+
GrantReimbursementCategory.objects.get_or_create(
23+
conference=conference,
24+
category="ticket",
25+
defaults={
26+
"name": "Ticket",
27+
"description": "Conference ticket",
28+
"max_amount": conference.grants_default_ticket_amount
29+
or Decimal("0.00"),
30+
"included_by_default": True,
31+
},
32+
)
33+
GrantReimbursementCategory.objects.get_or_create(
34+
conference=conference,
35+
category="travel",
36+
defaults={
37+
"name": "Travel",
38+
"description": "Travel support",
39+
"max_amount": conference.grants_default_travel_from_extra_eu_amount
40+
or Decimal("400.00"),
41+
"included_by_default": False,
42+
},
43+
)
44+
GrantReimbursementCategory.objects.get_or_create(
45+
conference=conference,
46+
category="accommodation",
47+
defaults={
48+
"name": "Accommodation",
49+
"description": "Accommodation support",
50+
"max_amount": conference.grants_default_accommodation_amount
51+
or Decimal("300.00"),
52+
"included_by_default": True,
53+
},
54+
)
55+
56+
def _migrate_grants(self):
57+
grants = Grant.objects.filter(approved_type__isnull=False).exclude(
58+
approved_type=""
59+
)
60+
61+
self.stdout.write(f"📦 Migrating {grants.count()} grants...")
62+
63+
for grant in grants:
64+
categories = {
65+
c.category: c
66+
for c in GrantReimbursementCategory.objects.filter(
67+
conference=grant.conference
68+
)
69+
}
70+
71+
def add_reimbursement(category_key, amount):
72+
if category_key in categories and amount:
73+
GrantReimbursement.objects.get_or_create(
74+
grant=grant,
75+
category=categories[category_key],
76+
defaults={
77+
"granted_amount": amount,
78+
},
79+
)
80+
81+
add_reimbursement("ticket", grant.ticket_amount)
82+
83+
if grant.approved_type in ("ticket_travel", "ticket_travel_accommodation"):
84+
add_reimbursement("travel", grant.travel_amount)
85+
86+
if grant.approved_type in (
87+
"ticket_accommodation",
88+
"ticket_travel_accommodation",
89+
):
90+
add_reimbursement("accommodation", grant.accommodation_amount)
91+
92+
def _validate_migration(self):
93+
errors = []
94+
grants = Grant.objects.filter(approved_type__isnull=False).exclude(
95+
approved_type=""
96+
)
97+
98+
for grant in grants:
99+
original_total = sum(
100+
filter(
101+
None,
102+
[
103+
grant.ticket_amount,
104+
grant.travel_amount,
105+
grant.accommodation_amount,
106+
],
107+
)
108+
)
109+
reimbursements_total = sum(
110+
r.granted_amount for r in GrantReimbursement.objects.filter(grant=grant)
111+
)
112+
113+
if abs(original_total - reimbursements_total) > Decimal("0.01"):
114+
errors.append(
115+
f"Grant ID {grant.id} total mismatch: expected {original_total}, got {reimbursements_total}"
116+
)
117+
118+
if errors:
119+
self.stdout.write(
120+
self.style.ERROR(f"⚠️ Found {len(errors)} grants with mismatched totals")
121+
)
122+
for msg in errors:
123+
self.stdout.write(self.style.WARNING(f" {msg}"))
124+
else:
125+
self.stdout.write(
126+
self.style.SUCCESS("🧮 All grant totals match correctly.")
127+
)

backend/grants/tests/factories.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class Meta:
2424
description = factory.Faker("sentence")
2525
max_amount = factory.fuzzy.FuzzyDecimal(50, 500, precision=2)
2626
category = factory.fuzzy.FuzzyChoice(
27-
GrantReimbursementCategory.CategoryType.choices, getter=lambda x: x[0]
27+
GrantReimbursementCategory.Category.choices, getter=lambda x: x[0]
2828
)
2929
included_by_default = factory.Faker("boolean")
3030

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import pytest
2+
from decimal import Decimal
3+
from django.core.management import call_command
4+
from grants.models import GrantReimbursement
5+
from grants.tests.factories import GrantFactory
6+
7+
8+
@pytest.mark.django_db
9+
@pytest.mark.parametrize(
10+
"approved_type,expected_categories",
11+
[
12+
("ticket_only", ["ticket"]),
13+
("ticket_travel", ["ticket", "travel"]),
14+
("ticket_accommodation", ["ticket", "accommodation"]),
15+
("ticket_travel_accommodation", ["ticket", "travel", "accommodation"]),
16+
],
17+
)
18+
def test_backfill_grant_reimbursements_all_types(approved_type, expected_categories):
19+
ticket_amount = Decimal("100.00")
20+
travel_amount = Decimal("150.00")
21+
accommodation_amount = Decimal("200.00")
22+
23+
grant = GrantFactory(
24+
approved_type=approved_type,
25+
status="confirmed",
26+
ticket_amount=ticket_amount,
27+
travel_amount=travel_amount,
28+
accommodation_amount=accommodation_amount,
29+
)
30+
31+
call_command("backfill_grant_reimbursements")
32+
33+
reimbursements = GrantReimbursement.objects.filter(grant=grant)
34+
assert reimbursements.count() == len(expected_categories)
35+
36+
for category in expected_categories:
37+
r = reimbursements.get(category__category=category)
38+
if category == "ticket":
39+
assert r.granted_amount == ticket_amount
40+
elif category == "travel":
41+
assert r.granted_amount == travel_amount
42+
elif category == "accommodation":
43+
assert r.granted_amount == accommodation_amount
44+
45+
46+
def test_grant_reimbursement_does_not_duplicate_on_rerun():
47+
grant = GrantFactory(
48+
approved_type="ticket_travel_accommodation",
49+
status="approved",
50+
ticket_amount=Decimal("100.00"),
51+
travel_amount=Decimal("100.00"),
52+
accommodation_amount=Decimal("100.00"),
53+
)
54+
55+
call_command("backfill_grant_reimbursements")
56+
initial_count = GrantReimbursement.objects.filter(grant=grant).count()
57+
assert initial_count == 3
58+
59+
call_command("backfill_grant_reimbursements")
60+
second_count = GrantReimbursement.objects.filter(grant=grant).count()
61+
assert second_count == 3 # no duplicates
62+
63+
64+
def test_total_amount_consistency():
65+
grant = GrantFactory(
66+
approved_type="ticket_travel",
67+
status="approved",
68+
ticket_amount=Decimal("120.00"),
69+
travel_amount=Decimal("80.00"),
70+
accommodation_amount=Decimal("0.00"),
71+
)
72+
73+
call_command("backfill_grant_reimbursements")
74+
reimbursements = GrantReimbursement.objects.filter(grant=grant)
75+
total = sum(r.granted_amount for r in reimbursements)
76+
expected = grant.ticket_amount + grant.travel_amount
77+
assert total == expected

0 commit comments

Comments
 (0)