diff --git a/twitch/management/commands/better_import_drops.py b/twitch/management/commands/better_import_drops.py index 40e15b8..ff4c518 100644 --- a/twitch/management/commands/better_import_drops.py +++ b/twitch/management/commands/better_import_drops.py @@ -836,10 +836,10 @@ class Command(BaseCommand): if ( response.extensions and response.extensions.operation_name - and campaign_obj.operation_name != response.extensions.operation_name + and response.extensions.operation_name not in campaign_obj.operation_names ): - campaign_obj.operation_name = response.extensions.operation_name - campaign_obj.save(update_fields=["operation_name"]) + campaign_obj.operation_names.append(response.extensions.operation_name) + campaign_obj.save(update_fields=["operation_names"]) if drop_campaign.time_based_drops: self._process_time_based_drops( diff --git a/twitch/migrations/0007_rename_operation_name_to_operation_names.py b/twitch/migrations/0007_rename_operation_name_to_operation_names.py new file mode 100644 index 0000000..c8cc5fb --- /dev/null +++ b/twitch/migrations/0007_rename_operation_name_to_operation_names.py @@ -0,0 +1,59 @@ +# Generated by Django 6.0.1 on 2026-01-17 05:32 +from __future__ import annotations + +from django.db import migrations +from django.db import models + + +def migrate_operation_name_to_list(apps, schema_editor) -> None: # noqa: ANN001, ARG001 + """Convert operation_name string values to operation_names list.""" + DropCampaign = apps.get_model("twitch", "DropCampaign") + for campaign in DropCampaign.objects.all(): + if campaign.operation_name and campaign.operation_name.strip(): + campaign.operation_names = [campaign.operation_name.strip()] + campaign.save(update_fields=["operation_names"]) + + +def reverse_operation_names_to_string(apps, schema_editor) -> None: # noqa: ARG001, ANN001 + """Convert operation_names list back to operation_name string (first item only).""" + DropCampaign = apps.get_model("twitch", "DropCampaign") + for campaign in DropCampaign.objects.all(): + if campaign.operation_names: + campaign.operation_name = campaign.operation_names[0] + campaign.save(update_fields=["operation_name"]) + + +class Migration(migrations.Migration): + """Rename operation_name field to operation_names and convert to list.""" + + dependencies = [ + ("twitch", "0006_add_chat_badges"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="dropcampaign", + name="twitch_drop_operati_8cfeb5_idx", + ), + migrations.AddField( + model_name="dropcampaign", + name="operation_names", + field=models.JSONField( + blank=True, + default=list, + help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).", # noqa: E501 + ), + ), + migrations.RunPython( + migrate_operation_name_to_list, + reverse_operation_names_to_string, + ), + migrations.AddIndex( + model_name="dropcampaign", + index=models.Index(fields=["operation_names"], name="twitch_drop_operati_fe3bc8_idx"), + ), + migrations.RemoveField( + model_name="dropcampaign", + name="operation_name", + ), + ] diff --git a/twitch/models.py b/twitch/models.py index d411289..e8edd70 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -346,10 +346,10 @@ class DropCampaign(models.Model): help_text="Game associated with this campaign.", ) - operation_name = models.TextField( + operation_names = models.JSONField( + default=list, blank=True, - default="", - help_text="The GraphQL operation name used to fetch this campaign data (e.g., 'ViewerDropsDashboard').", + help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).", # noqa: E501 ) added_at = models.DateTimeField( @@ -371,7 +371,7 @@ class DropCampaign(models.Model): models.Index(fields=["name"]), models.Index(fields=["description"]), models.Index(fields=["allow_is_enabled"]), - models.Index(fields=["operation_name"]), + models.Index(fields=["operation_names"]), models.Index(fields=["added_at"]), models.Index(fields=["updated_at"]), # Composite indexes for common queries diff --git a/twitch/tests/test_better_import_drops.py b/twitch/tests/test_better_import_drops.py index 960f50d..e7fed52 100644 --- a/twitch/tests/test_better_import_drops.py +++ b/twitch/tests/test_better_import_drops.py @@ -169,7 +169,7 @@ class ExtractCampaignsTests(TestCase): # Check that campaign was created with operation_name campaign: DropCampaign = DropCampaign.objects.get(twitch_id="inventory-campaign-1") assert campaign.name == "Test Inventory Campaign" - assert campaign.operation_name == "Inventory" + assert campaign.operation_names == ["Inventory"] def test_handles_inventory_with_null_campaigns(self) -> None: """Ensure Inventory JSON with null dropCampaignsInProgress is handled correctly.""" @@ -467,24 +467,25 @@ class OperationNameFilteringTests(TestCase): command.process_responses([viewer_drops_payload], Path("viewer.json"), {}) command.process_responses([inventory_payload], Path("inventory.json"), {}) - # Verify we can filter by operation_name - viewer_campaigns: QuerySet[DropCampaign, DropCampaign] = DropCampaign.objects.filter( - operation_name="ViewerDropsDashboard", - ) - inventory_campaigns: QuerySet[DropCampaign, DropCampaign] = DropCampaign.objects.filter( - operation_name="Inventory", - ) + # Verify we can filter by operation_names + # SQLite doesn't support JSON contains, so we filter in Python + all_campaigns: QuerySet[DropCampaign, DropCampaign] = DropCampaign.objects.all() + viewer_campaigns: list[DropCampaign] = [c for c in all_campaigns if "ViewerDropsDashboard" in c.operation_names] + inventory_campaigns: list[DropCampaign] = [c for c in all_campaigns if "Inventory" in c.operation_names] - assert viewer_campaigns.count() >= 1 - assert inventory_campaigns.count() >= 1 + assert len(viewer_campaigns) >= 1 + assert len(inventory_campaigns) >= 1 - # Verify the correct campaigns are in each queryset - assert viewer_campaigns.filter(twitch_id="viewer-campaign-1").exists() - assert inventory_campaigns.filter(twitch_id="inventory-campaign-1").exists() + # Verify the correct campaigns are in each list + viewer_ids = [c.twitch_id for c in viewer_campaigns] + inventory_ids = [c.twitch_id for c in inventory_campaigns] + + assert "viewer-campaign-1" in viewer_ids + assert "inventory-campaign-1" in inventory_ids # Cross-check: Inventory campaign should not be in viewer campaigns - assert not viewer_campaigns.filter(twitch_id="inventory-campaign-1").exists() - assert not inventory_campaigns.filter(twitch_id="viewer-campaign-1").exists() + assert "inventory-campaign-1" not in viewer_ids + assert "viewer-campaign-1" not in inventory_ids class GameImportTests(TestCase): @@ -586,7 +587,7 @@ class ExampleJsonImportTests(TestCase): assert campaign.allow_channels.count() == 0 # Operation name provenance - assert campaign.operation_name == "DropCampaignDetails" + assert campaign.operation_names == ["DropCampaignDetails"] # Related game/org normalization game: Game = Game.objects.get(twitch_id="2094865572") diff --git a/twitch/tests/test_feeds.py b/twitch/tests/test_feeds.py index 81e9528..9c12c78 100644 --- a/twitch/tests/test_feeds.py +++ b/twitch/tests/test_feeds.py @@ -39,7 +39,7 @@ class RSSFeedTestCase(TestCase): game=self.game, start_at=timezone.now(), end_at=timezone.now() + timedelta(days=7), - operation_name="DropCampaignDetails", + operation_names=["DropCampaignDetails"], ) def test_organization_feed(self) -> None: @@ -132,7 +132,7 @@ class RSSFeedTestCase(TestCase): game=other_game, start_at=timezone.now(), end_at=timezone.now() + timedelta(days=7), - operation_name="DropCampaignDetails", + operation_names=["DropCampaignDetails"], ) # Get feed for first game @@ -165,7 +165,7 @@ class RSSFeedTestCase(TestCase): game=other_game, start_at=timezone.now(), end_at=timezone.now() + timedelta(days=7), - operation_name="DropCampaignDetails", + operation_names=["DropCampaignDetails"], ) # Get feed for first organization diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index d59cff1..08681f2 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -47,7 +47,7 @@ class TestSearchView: name="Test Campaign", description="A test campaign", game=game, - operation_name="DropCampaignDetails", + operation_names=["DropCampaignDetails"], ) drop: TimeBasedDrop = TimeBasedDrop.objects.create( twitch_id="1011", @@ -268,7 +268,7 @@ class TestChannelListView: twitch_id=f"campaign{i}", name=f"Campaign {i}", game=game, - operation_name="DropCampaignDetails", + operation_names=["DropCampaignDetails"], ) campaign.allow_channels.add(channel) campaigns.append(campaign) @@ -357,7 +357,7 @@ class TestChannelListView: twitch_id=f"campaign_ch2_{i}", name=f"Campaign Ch2 {i}", game=game, - operation_name="DropCampaignDetails", + operation_names=["DropCampaignDetails"], ) campaign.allow_channels.add(channel2) @@ -422,7 +422,7 @@ class TestChannelListView: twitch_id="camp1", name="Campaign", game=game, - operation_name="DropCampaignDetails", + operation_names=["DropCampaignDetails"], start_at=now - datetime.timedelta(hours=1), end_at=now + datetime.timedelta(hours=1), ) @@ -464,7 +464,7 @@ class TestChannelListView: twitch_id="c1", name="Campaign", game=game, - operation_name="DropCampaignDetails", + operation_names=["DropCampaignDetails"], ) url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -482,7 +482,7 @@ class TestChannelListView: twitch_id="c-badge", name="Campaign", game=game, - operation_name="DropCampaignDetails", + operation_names=["DropCampaignDetails"], ) drop = TimeBasedDrop.objects.create( diff --git a/twitch/views.py b/twitch/views.py index edccccd..bf4e5d1 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -392,7 +392,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo "start_at", "end_at", "allow_is_enabled", - "operation_name", + "operation_names", "game", "created_at", "updated_at", @@ -701,7 +701,7 @@ class GameDetailView(DetailView): "allow_is_enabled", "allow_channels", "game", - "operation_name", + "operation_names", "added_at", "updated_at", ), @@ -951,14 +951,16 @@ def debug_view(request: HttpRequest) -> HttpResponse: ) # Distinct GraphQL operation names used to fetch campaigns with counts - operation_names_with_counts: list[dict[str, Any]] = list( - DropCampaign.objects - .annotate(trimmed_op=Trim("operation_name")) - .filter(~Q(trimmed_op__exact="")) - .values("trimmed_op") - .annotate(count=Count("twitch_id")) - .order_by("trimmed_op"), - ) + # Since operation_names is now a JSON list field, we need to flatten and count + operation_names_counter: dict[str, int] = {} + for campaign in DropCampaign.objects.only("operation_names"): + for op_name in campaign.operation_names: + if op_name and op_name.strip(): + operation_names_counter[op_name.strip()] = operation_names_counter.get(op_name.strip(), 0) + 1 + + operation_names_with_counts: list[dict[str, Any]] = [ + {"trimmed_op": op_name, "count": count} for op_name, count in sorted(operation_names_counter.items()) + ] context: dict[str, Any] = { "now": now,