From e3e83b3f9c43b7a671fba181571598c70d9cda65 Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Thu, 30 Oct 2025 16:04:52 -0400 Subject: [PATCH] Add management command to reset sponsorship benefits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [ ...] --update-year python manage.py reset_sponsorship_benefits --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 --- .../commands/reset_sponsorship_benefits.py | 214 +++++++++++ sponsors/tests/test_management_command.py | 338 +++++++++++++++++- 2 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 sponsors/management/commands/reset_sponsorship_benefits.py diff --git a/sponsors/management/commands/reset_sponsorship_benefits.py b/sponsors/management/commands/reset_sponsorship_benefits.py new file mode 100644 index 000000000..16087b894 --- /dev/null +++ b/sponsors/management/commands/reset_sponsorship_benefits.py @@ -0,0 +1,214 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from sponsors.models import Sponsorship, SponsorshipBenefit + + +class Command(BaseCommand): + help = "Reset benefits for specified sponsorships to match their current package/year templates" + + def add_arguments(self, parser): + parser.add_argument( + "sponsorship_ids", + nargs="+", + type=int, + help="IDs of sponsorships to reset benefits for", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be reset without actually doing it", + ) + parser.add_argument( + "--update-year", + action="store_true", + help="Update sponsorship year to match the package year", + ) + + def handle(self, *args, **options): + sponsorship_ids = options["sponsorship_ids"] + dry_run = options["dry_run"] + update_year = options["update_year"] + + if dry_run: + self.stdout.write(self.style.WARNING("DRY RUN MODE - No changes will be made")) + + for sid in sponsorship_ids: + try: + sponsorship = Sponsorship.objects.get(id=sid) + except Sponsorship.DoesNotExist: + self.stdout.write( + self.style.ERROR(f"Sponsorship {sid} does not exist - skipping") + ) + continue + + self.stdout.write(f"\n{'='*60}") + self.stdout.write(f"Sponsorship ID: {sid}") + self.stdout.write(f"Sponsor: {sponsorship.sponsor.name}") + self.stdout.write(f"Package: {sponsorship.package.name if sponsorship.package else 'None'}") + self.stdout.write(f"Sponsorship Year: {sponsorship.year}") + if sponsorship.package: + self.stdout.write(f"Package Year: {sponsorship.package.year}") + self.stdout.write(f"Status: {sponsorship.status}") + self.stdout.write(f"{'='*60}") + + if not sponsorship.package: + self.stdout.write( + self.style.WARNING(" No package associated - skipping") + ) + continue + + # Check if year mismatch and update if requested + target_year = sponsorship.year + if sponsorship.package.year != sponsorship.year: + self.stdout.write( + self.style.WARNING( + f"Year mismatch: Sponsorship year ({sponsorship.year}) != " + f"Package year ({sponsorship.package.year})" + ) + ) + if update_year: + target_year = sponsorship.package.year + if not dry_run: + sponsorship.year = target_year + sponsorship.save() + self.stdout.write( + self.style.SUCCESS( + f" ✓ Updated sponsorship year to {target_year}" + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + f" [DRY RUN] Would update sponsorship year to {target_year}" + ) + ) + else: + self.stdout.write( + self.style.WARNING( + f" Use --update-year to update sponsorship year to {sponsorship.package.year}" + ) + ) + + # Get template benefits for this package and target year + template_benefits = SponsorshipBenefit.objects.filter( + packages=sponsorship.package, + year=target_year + ) + + self.stdout.write( + self.style.SUCCESS( + f"Found {template_benefits.count()} template benefits for year {target_year}" + ) + ) + + if template_benefits.count() == 0: + self.stdout.write( + self.style.ERROR( + f" ERROR: No template benefits found for package " + f"'{sponsorship.package.name}' year {target_year}" + ) + ) + continue + + reset_count = 0 + missing_count = 0 + + # Use transaction to ensure atomicity + with transaction.atomic(): + from sponsors.models import SponsorBenefit, GenericAsset + from django.contrib.contenttypes.models import ContentType + + # Get count of current benefits before deletion + current_count = sponsorship.benefits.count() + expected_count = template_benefits.count() + + self.stdout.write( + f"Current benefits: {current_count}, Expected: {expected_count}" + ) + + # STEP 1: Delete ALL GenericAssets linked to this sponsorship + sponsorship_ct = ContentType.objects.get_for_model(sponsorship) + generic_assets = GenericAsset.objects.filter( + content_type=sponsorship_ct, + object_id=sponsorship.id + ) + asset_count = generic_assets.count() + + if asset_count > 0: + if not dry_run: + # Delete each asset individually to handle polymorphic cascade properly + deleted_count = 0 + for asset in generic_assets: + asset.delete() + deleted_count += 1 + self.stdout.write( + self.style.WARNING(f" 🗑 Deleted {deleted_count} GenericAssets") + ) + else: + self.stdout.write( + self.style.WARNING(f" [DRY RUN] Would delete {asset_count} GenericAssets") + ) + + # STEP 2: Delete ALL existing sponsor benefits (this cascades to features) + if not dry_run: + deleted_count = 0 + for benefit in sponsorship.benefits.all(): + self.stdout.write(f" 🗑 Deleting benefit: {benefit.name}") + benefit.delete() + deleted_count += 1 + self.stdout.write( + self.style.WARNING(f"\nDeleted {deleted_count} existing benefits") + ) + else: + self.stdout.write( + self.style.WARNING(f" [DRY RUN] Would delete all {current_count} existing benefits") + ) + + # STEP 3: Add all benefits from the package template + if not dry_run: + self.stdout.write(f"\nAdding {expected_count} benefits from {target_year} package...") + added_count = 0 + for template in template_benefits: + # Create new benefit with all features from template + new_benefit = SponsorBenefit.new_copy( + template, + sponsorship=sponsorship, + added_by_user=False + ) + self.stdout.write(f" ✓ Added: {template.name}") + added_count += 1 + + self.stdout.write( + self.style.SUCCESS(f"\nAdded {added_count} benefits with all features") + ) + reset_count = added_count + else: + self.stdout.write( + self.style.SUCCESS( + f" [DRY RUN] Would add {expected_count} benefits from {target_year} package" + ) + ) + for template in template_benefits[:5]: # Show first 5 + self.stdout.write(f" - {template.name}") + if expected_count > 5: + self.stdout.write(f" ... and {expected_count - 5} more") + + if dry_run: + # Rollback transaction in dry run + transaction.set_rollback(True) + + self.stdout.write( + self.style.SUCCESS( + f"\nSummary for Sponsorship {sid}: " + f"Removed {current_count}, Added {expected_count}" + ) + ) + + if dry_run: + self.stdout.write( + self.style.WARNING("\nDRY RUN COMPLETE - No changes were made") + ) + else: + self.stdout.write( + self.style.SUCCESS("\nAll sponsorship benefits have been reset!") + ) diff --git a/sponsors/tests/test_management_command.py b/sponsors/tests/test_management_command.py index 100daad2a..e86b14d0e 100644 --- a/sponsors/tests/test_management_command.py +++ b/sponsors/tests/test_management_command.py @@ -1,11 +1,25 @@ from django.test import TestCase +from django.core.management import call_command from model_bakery import baker from unittest import mock +from io import StringIO -from sponsors.models import ProvidedTextAssetConfiguration, ProvidedTextAsset +from sponsors.models import ( + ProvidedTextAssetConfiguration, + ProvidedTextAsset, + Sponsor, + Sponsorship, + SponsorshipBenefit, + SponsorshipPackage, + SponsorshipProgram, + SponsorshipCurrentYear, + GenericAsset, + TieredBenefitConfiguration, +) from sponsors.models.enums import AssetsRelatedTo +from django.contrib.contenttypes.models import ContentType from sponsors.management.commands.create_pycon_vouchers_for_sponsors import ( generate_voucher_codes, @@ -52,3 +66,325 @@ def test_generate_voucher_codes(self, mock_api_call): sponsor_benefit__id=benefit_id, internal_name=code["internal_name"] ) self.assertEqual(asset.value, "test-promo-code") + + +class ResetSponsorshipBenefitsTestCase(TestCase): + """ + Test the reset_sponsorship_benefits management command. + + Scenario: A sponsor applies while 2025 is the current year, the current year + changes to 2026 with new packages, the sponsor is assigned the new package, + then the command is run to reset benefits. + """ + + def setUp(self): + """Set up test data for 2025 and 2026 sponsorships""" + # Create sponsor + self.sponsor = baker.make(Sponsor, name="Test Sponsor Corp") + + # Create program + self.program = baker.make(SponsorshipProgram, name="PSF Sponsorship") + + # Set current year to 2025 + current_year = SponsorshipCurrentYear.objects.first() + if current_year: + current_year.year = 2025 + current_year.save() + else: + SponsorshipCurrentYear.objects.create(year=2025) + + # Create 2025 package and benefits + self.package_2025 = baker.make( + SponsorshipPackage, + name="Gold", + year=2025, + sponsorship_amount=10000, + ) + + # Create 2025 benefits + self.benefit_2025_a = baker.make( + SponsorshipBenefit, + name="Logo on Website", + year=2025, + program=self.program, + internal_value=1000, + ) + self.benefit_2025_b = baker.make( + SponsorshipBenefit, + name="Conference Passes - OLD NAME", + year=2025, + program=self.program, + internal_value=2000, + ) + self.benefit_2025_c = baker.make( + SponsorshipBenefit, + name="Social Media Mention", + year=2025, + program=self.program, + internal_value=500, + ) + + # Add benefits to 2025 package + self.package_2025.benefits.add( + self.benefit_2025_a, + self.benefit_2025_b, + self.benefit_2025_c, + ) + + # Add tiered benefit configuration to 2025 benefit + baker.make( + TieredBenefitConfiguration, + benefit=self.benefit_2025_b, + package=self.package_2025, + quantity=5, + ) + + # Create 2026 package and benefits + self.package_2026 = baker.make( + SponsorshipPackage, + name="Gold", + year=2026, + sponsorship_amount=12000, + ) + + # Create 2026 benefits (some renamed, some new) + self.benefit_2026_a = baker.make( + SponsorshipBenefit, + name="Logo on Website", + year=2026, + program=self.program, + internal_value=1500, + ) + self.benefit_2026_b = baker.make( + SponsorshipBenefit, + name="Conference Passes", # Renamed from "Conference Passes - OLD NAME" + year=2026, + program=self.program, + internal_value=2500, + ) + self.benefit_2026_d = baker.make( + SponsorshipBenefit, + name="Newsletter Feature", # New benefit for 2026 + year=2026, + program=self.program, + internal_value=750, + ) + + # Add benefits to 2026 package (note: Social Media Mention is removed) + self.package_2026.benefits.add( + self.benefit_2026_a, + self.benefit_2026_b, + self.benefit_2026_d, + ) + + # Add tiered benefit configuration to 2026 benefit + baker.make( + TieredBenefitConfiguration, + benefit=self.benefit_2026_b, + package=self.package_2026, + quantity=10, # Increased from 5 + ) + + def test_reset_sponsorship_benefits_from_2025_to_2026(self): + """ + Test that a sponsorship created in 2025 can be reset to 2026 benefits + after being assigned to a 2026 package. + """ + # Step 1: Sponsor applies in 2025 with 2025 package + sponsorship = Sponsorship.new( + self.sponsor, + [self.benefit_2025_a, self.benefit_2025_b, self.benefit_2025_c], + package=self.package_2025, + ) + + # Verify initial state + self.assertEqual(sponsorship.year, 2025) + self.assertEqual(sponsorship.package.year, 2025) + self.assertEqual(sponsorship.benefits.count(), 3) + + # Verify all benefits have 2025 templates + for benefit in sponsorship.benefits.all(): + self.assertEqual(benefit.sponsorship_benefit.year, 2025) + + # Create some GenericAssets with 2025 references + sponsorship_ct = ContentType.objects.get_for_model(sponsorship) + asset_2025 = baker.make( + "sponsors.TextAsset", + content_type=sponsorship_ct, + object_id=sponsorship.id, + internal_name="conference_passes_code_2025", + text="2025-CODE-123", + ) + + # Step 2: Current year changes to 2026 + current_year = SponsorshipCurrentYear.objects.first() + current_year.year = 2026 + current_year.save() + + # Step 3: Sponsor is assigned to 2026 package (simulating admin action) + sponsorship.package = self.package_2026 + sponsorship.save() + + # At this point, sponsorship has: + # - year = 2025 + # - package year = 2026 + # - benefits linked to 2025 templates + # - GenericAssets with 2025 references + self.assertEqual(sponsorship.year, 2025) + self.assertEqual(sponsorship.package.year, 2026) + + # Verify there are GenericAssets with 2025 references + assets_2025 = GenericAsset.objects.filter( + content_type=sponsorship_ct, + object_id=sponsorship.id, + internal_name__contains="2025", + ) + self.assertGreater(assets_2025.count(), 0) + + # Step 4: Run the management command + out = StringIO() + call_command( + "reset_sponsorship_benefits", + str(sponsorship.id), + "--update-year", + stdout=out, + ) + + # Step 5: Verify the reset + sponsorship.refresh_from_db() + + # Verify year was updated + self.assertEqual(sponsorship.year, 2026) + + # Verify benefits were reset to 2026 package + self.assertEqual(sponsorship.benefits.count(), 3) + + # Verify all benefits now point to 2026 templates + for benefit in sponsorship.benefits.all(): + self.assertEqual(benefit.sponsorship_benefit.year, 2026) + + # Verify benefit names match 2026 package + benefit_names = set(sponsorship.benefits.values_list("name", flat=True)) + expected_names = { + "Logo on Website", + "Conference Passes", + "Newsletter Feature", + } + self.assertEqual(benefit_names, expected_names) + + # Verify old benefit was removed + self.assertNotIn("Social Media Mention", benefit_names) + self.assertNotIn("Conference Passes - OLD NAME", benefit_names) + + # Verify new benefit was added + self.assertIn("Newsletter Feature", benefit_names) + + # Verify GenericAssets with 2025 references were deleted + assets_2025_after = GenericAsset.objects.filter( + content_type=sponsorship_ct, + object_id=sponsorship.id, + internal_name__contains="2025", + ) + self.assertEqual(assets_2025_after.count(), 0) + + # Verify benefits are visible in admin (template year matches sponsorship year) + visible_benefits = sponsorship.benefits.filter( + sponsorship_benefit__year=sponsorship.year + ) + self.assertEqual(visible_benefits.count(), sponsorship.benefits.count()) + + # Verify benefit features were recreated with 2026 configurations + conference_passes_benefit = sponsorship.benefits.get(name="Conference Passes") + tiered_features = conference_passes_benefit.features.filter( + polymorphic_ctype__model="tieredbenefit" + ) + self.assertEqual(tiered_features.count(), 1) + + # Verify the quantity was updated from 2025 config (5) to 2026 config (10) + from sponsors.models import TieredBenefit + tiered_benefit = TieredBenefit.objects.get( + sponsor_benefit=conference_passes_benefit + ) + self.assertEqual(tiered_benefit.quantity, 10) + + def test_reset_with_duplicate_benefits(self): + """Test that the reset handles duplicate benefits correctly""" + # Create sponsorship with duplicate benefits + sponsorship = Sponsorship.new( + self.sponsor, + [self.benefit_2025_a], + package=self.package_2025, + ) + + # Manually create a duplicate benefit + from sponsors.models import SponsorBenefit + duplicate = SponsorBenefit.new_copy( + self.benefit_2025_a, + sponsorship=sponsorship, + added_by_user=False, + ) + + # Verify we have a duplicate + self.assertEqual(sponsorship.benefits.count(), 2) + self.assertEqual( + sponsorship.benefits.filter(name="Logo on Website").count(), 2 + ) + + # Update to 2026 package + sponsorship.package = self.package_2026 + sponsorship.save() + + # Run command + out = StringIO() + call_command( + "reset_sponsorship_benefits", + str(sponsorship.id), + "--update-year", + stdout=out, + ) + + # Verify duplicates were handled + sponsorship.refresh_from_db() + self.assertEqual(sponsorship.benefits.count(), 3) # All 2026 benefits + self.assertEqual( + sponsorship.benefits.filter(name="Logo on Website").count(), 1 + ) + + def test_dry_run_mode(self): + """Test that dry run doesn't make any changes""" + # Create sponsorship + sponsorship = Sponsorship.new( + self.sponsor, + [self.benefit_2025_a, self.benefit_2025_b], + package=self.package_2025, + ) + + # Update to 2026 package + sponsorship.package = self.package_2026 + sponsorship.save() + + # Record initial state + initial_year = sponsorship.year + initial_benefit_count = sponsorship.benefits.count() + initial_benefit_ids = set(sponsorship.benefits.values_list("id", flat=True)) + + # Run command in dry-run mode + out = StringIO() + call_command( + "reset_sponsorship_benefits", + str(sponsorship.id), + "--update-year", + "--dry-run", + stdout=out, + ) + + # Verify nothing changed + sponsorship.refresh_from_db() + self.assertEqual(sponsorship.year, initial_year) + self.assertEqual(sponsorship.benefits.count(), initial_benefit_count) + current_benefit_ids = set(sponsorship.benefits.values_list("id", flat=True)) + self.assertEqual(current_benefit_ids, initial_benefit_ids) + + # Verify dry run message was printed + output = out.getvalue() + self.assertIn("DRY RUN", output)