Skip to content

Commit 869bc73

Browse files
ewdurbinclaude
andauthored
Add management command to reset sponsorship benefits (#2802)
Add a new management command `reset_sponsorship_benefits` that performs a complete clean slate reset of sponsorship benefits when sponsors transition from one year's package to another (e.g., 2025 to 2026). This addresses the issue where sponsorships created in 2025 were later assigned to 2026 packages but retained 2025 benefit configurations, templates, and asset references, causing inconsistencies in the admin interface and benefit calculations. Command features: - Deletes ALL GenericAssets linked to the sponsorship (including old year references) - Deletes ALL existing sponsor benefits (cascades to features) - Recreates all benefits fresh from the target year's package template - Updates sponsorship year to match package year (with --update-year flag) - Supports dry-run mode for safe preview (with --dry-run flag) - Uses atomic transactions to ensure data consistency - Handles edge cases: duplicates, renamed benefits, missing templates Usage: python manage.py reset_sponsorship_benefits <id> [<id> ...] --update-year python manage.py reset_sponsorship_benefits <id> --dry-run --update-year Tests added to verify: - Full 2025 to 2026 transition scenario - Duplicate benefit handling - Dry-run mode functionality - Year updates - GenericAsset cleanup - Admin visibility (template year matching) - Feature recreation with updated configurations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent bea5da1 commit 869bc73

File tree

2 files changed

+551
-1
lines changed

2 files changed

+551
-1
lines changed
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
from django.core.management.base import BaseCommand
2+
from django.db import transaction
3+
from sponsors.models import Sponsorship, SponsorshipBenefit
4+
5+
6+
class Command(BaseCommand):
7+
help = "Reset benefits for specified sponsorships to match their current package/year templates"
8+
9+
def add_arguments(self, parser):
10+
parser.add_argument(
11+
"sponsorship_ids",
12+
nargs="+",
13+
type=int,
14+
help="IDs of sponsorships to reset benefits for",
15+
)
16+
parser.add_argument(
17+
"--dry-run",
18+
action="store_true",
19+
help="Show what would be reset without actually doing it",
20+
)
21+
parser.add_argument(
22+
"--update-year",
23+
action="store_true",
24+
help="Update sponsorship year to match the package year",
25+
)
26+
27+
def handle(self, *args, **options):
28+
sponsorship_ids = options["sponsorship_ids"]
29+
dry_run = options["dry_run"]
30+
update_year = options["update_year"]
31+
32+
if dry_run:
33+
self.stdout.write(self.style.WARNING("DRY RUN MODE - No changes will be made"))
34+
35+
for sid in sponsorship_ids:
36+
try:
37+
sponsorship = Sponsorship.objects.get(id=sid)
38+
except Sponsorship.DoesNotExist:
39+
self.stdout.write(
40+
self.style.ERROR(f"Sponsorship {sid} does not exist - skipping")
41+
)
42+
continue
43+
44+
self.stdout.write(f"\n{'='*60}")
45+
self.stdout.write(f"Sponsorship ID: {sid}")
46+
self.stdout.write(f"Sponsor: {sponsorship.sponsor.name}")
47+
self.stdout.write(f"Package: {sponsorship.package.name if sponsorship.package else 'None'}")
48+
self.stdout.write(f"Sponsorship Year: {sponsorship.year}")
49+
if sponsorship.package:
50+
self.stdout.write(f"Package Year: {sponsorship.package.year}")
51+
self.stdout.write(f"Status: {sponsorship.status}")
52+
self.stdout.write(f"{'='*60}")
53+
54+
if not sponsorship.package:
55+
self.stdout.write(
56+
self.style.WARNING(" No package associated - skipping")
57+
)
58+
continue
59+
60+
# Check if year mismatch and update if requested
61+
target_year = sponsorship.year
62+
if sponsorship.package.year != sponsorship.year:
63+
self.stdout.write(
64+
self.style.WARNING(
65+
f"Year mismatch: Sponsorship year ({sponsorship.year}) != "
66+
f"Package year ({sponsorship.package.year})"
67+
)
68+
)
69+
if update_year:
70+
target_year = sponsorship.package.year
71+
if not dry_run:
72+
sponsorship.year = target_year
73+
sponsorship.save()
74+
self.stdout.write(
75+
self.style.SUCCESS(
76+
f" ✓ Updated sponsorship year to {target_year}"
77+
)
78+
)
79+
else:
80+
self.stdout.write(
81+
self.style.SUCCESS(
82+
f" [DRY RUN] Would update sponsorship year to {target_year}"
83+
)
84+
)
85+
else:
86+
self.stdout.write(
87+
self.style.WARNING(
88+
f" Use --update-year to update sponsorship year to {sponsorship.package.year}"
89+
)
90+
)
91+
92+
# Get template benefits for this package and target year
93+
template_benefits = SponsorshipBenefit.objects.filter(
94+
packages=sponsorship.package,
95+
year=target_year
96+
)
97+
98+
self.stdout.write(
99+
self.style.SUCCESS(
100+
f"Found {template_benefits.count()} template benefits for year {target_year}"
101+
)
102+
)
103+
104+
if template_benefits.count() == 0:
105+
self.stdout.write(
106+
self.style.ERROR(
107+
f" ERROR: No template benefits found for package "
108+
f"'{sponsorship.package.name}' year {target_year}"
109+
)
110+
)
111+
continue
112+
113+
reset_count = 0
114+
missing_count = 0
115+
116+
# Use transaction to ensure atomicity
117+
with transaction.atomic():
118+
from sponsors.models import SponsorBenefit, GenericAsset
119+
from django.contrib.contenttypes.models import ContentType
120+
121+
# Get count of current benefits before deletion
122+
current_count = sponsorship.benefits.count()
123+
expected_count = template_benefits.count()
124+
125+
self.stdout.write(
126+
f"Current benefits: {current_count}, Expected: {expected_count}"
127+
)
128+
129+
# STEP 1: Delete ALL GenericAssets linked to this sponsorship
130+
sponsorship_ct = ContentType.objects.get_for_model(sponsorship)
131+
generic_assets = GenericAsset.objects.filter(
132+
content_type=sponsorship_ct,
133+
object_id=sponsorship.id
134+
)
135+
asset_count = generic_assets.count()
136+
137+
if asset_count > 0:
138+
if not dry_run:
139+
# Delete each asset individually to handle polymorphic cascade properly
140+
deleted_count = 0
141+
for asset in generic_assets:
142+
asset.delete()
143+
deleted_count += 1
144+
self.stdout.write(
145+
self.style.WARNING(f" 🗑 Deleted {deleted_count} GenericAssets")
146+
)
147+
else:
148+
self.stdout.write(
149+
self.style.WARNING(f" [DRY RUN] Would delete {asset_count} GenericAssets")
150+
)
151+
152+
# STEP 2: Delete ALL existing sponsor benefits (this cascades to features)
153+
if not dry_run:
154+
deleted_count = 0
155+
for benefit in sponsorship.benefits.all():
156+
self.stdout.write(f" 🗑 Deleting benefit: {benefit.name}")
157+
benefit.delete()
158+
deleted_count += 1
159+
self.stdout.write(
160+
self.style.WARNING(f"\nDeleted {deleted_count} existing benefits")
161+
)
162+
else:
163+
self.stdout.write(
164+
self.style.WARNING(f" [DRY RUN] Would delete all {current_count} existing benefits")
165+
)
166+
167+
# STEP 3: Add all benefits from the package template
168+
if not dry_run:
169+
self.stdout.write(f"\nAdding {expected_count} benefits from {target_year} package...")
170+
added_count = 0
171+
for template in template_benefits:
172+
# Create new benefit with all features from template
173+
new_benefit = SponsorBenefit.new_copy(
174+
template,
175+
sponsorship=sponsorship,
176+
added_by_user=False
177+
)
178+
self.stdout.write(f" ✓ Added: {template.name}")
179+
added_count += 1
180+
181+
self.stdout.write(
182+
self.style.SUCCESS(f"\nAdded {added_count} benefits with all features")
183+
)
184+
reset_count = added_count
185+
else:
186+
self.stdout.write(
187+
self.style.SUCCESS(
188+
f" [DRY RUN] Would add {expected_count} benefits from {target_year} package"
189+
)
190+
)
191+
for template in template_benefits[:5]: # Show first 5
192+
self.stdout.write(f" - {template.name}")
193+
if expected_count > 5:
194+
self.stdout.write(f" ... and {expected_count - 5} more")
195+
196+
if dry_run:
197+
# Rollback transaction in dry run
198+
transaction.set_rollback(True)
199+
200+
self.stdout.write(
201+
self.style.SUCCESS(
202+
f"\nSummary for Sponsorship {sid}: "
203+
f"Removed {current_count}, Added {expected_count}"
204+
)
205+
)
206+
207+
if dry_run:
208+
self.stdout.write(
209+
self.style.WARNING("\nDRY RUN COMPLETE - No changes were made")
210+
)
211+
else:
212+
self.stdout.write(
213+
self.style.SUCCESS("\nAll sponsorship benefits have been reset!")
214+
)

0 commit comments

Comments
 (0)