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)