diff --git a/twitch/migrations/0020_rewardcampaign_tw_reward_ends_starts_idx.py b/twitch/migrations/0020_rewardcampaign_tw_reward_ends_starts_idx.py new file mode 100644 index 0000000..8415fad --- /dev/null +++ b/twitch/migrations/0020_rewardcampaign_tw_reward_ends_starts_idx.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.4 on 2026-04-11 22:41 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + "Add an index on the RewardCampaign model for the ends_at and starts_at fields, to optimize queries that filter by these fields." + + dependencies = [ + ("twitch", "0019_dropcampaign_campaign_list_indexes"), + ] + + operations = [ + migrations.AddIndex( + model_name="rewardcampaign", + index=models.Index( + fields=["ends_at", "-starts_at"], + name="tw_reward_ends_starts_idx", + ), + ), + ] diff --git a/twitch/models.py b/twitch/models.py index 3a6e466..8c46f68 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -595,6 +595,7 @@ class DropCampaign(auto_prefetch.Model): "game", "game__twitch_id", "game__display_name", + "game__name", "game__slug", "game__box_art", "game__box_art_file", @@ -606,6 +607,7 @@ class DropCampaign(auto_prefetch.Model): models.Prefetch( "game__owners", queryset=Organization.objects.only("twitch_id", "name"), + to_attr="owners_for_dashboard", ), models.Prefetch( "allow_channels", @@ -640,14 +642,14 @@ class DropCampaign(auto_prefetch.Model): for campaign in campaigns: game: Game = campaign.game game_id: str = game.twitch_id - game_display_name: str = game.display_name + game_display_name: str = game.get_game_name game_bucket: dict[str, Any] = campaigns_by_game.setdefault( game_id, { "name": game_display_name, "box_art": game.box_art_best_url, - "owners": list(game.owners.all()), + "owners": list(getattr(game, "owners_for_dashboard", [])), "campaigns": [], }, ) @@ -1190,6 +1192,10 @@ class RewardCampaign(auto_prefetch.Model): fields=["starts_at", "ends_at"], name="tw_reward_starts_ends_idx", ), + models.Index( + fields=["ends_at", "-starts_at"], + name="tw_reward_ends_starts_idx", + ), models.Index(fields=["status", "-starts_at"]), ] diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index b3d543e..e74bf7d 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -710,6 +710,7 @@ class TestChannelListView: expected_reward_indexes: set[str] = { "tw_reward_starts_desc_idx", "tw_reward_starts_ends_idx", + "tw_reward_ends_starts_idx", } drop_index_names: set[str] = _index_names(DropCampaign._meta.db_table) @@ -849,6 +850,90 @@ class TestChannelListView: f"baseline={baseline_select_count}, scaled={scaled_select_count}" ) + @pytest.mark.django_db + def test_dashboard_field_access_after_prefetch_has_no_extra_selects(self) -> None: + """Dashboard-accessed fields should not trigger deferred model SELECT queries.""" + now: datetime.datetime = timezone.now() + + org: Organization = Organization.objects.create( + twitch_id="org_dashboard_field_access", + name="Org Dashboard Field Access", + ) + game: Game = Game.objects.create( + twitch_id="game_dashboard_field_access", + name="Game Dashboard Field Access", + display_name="Game Dashboard Field Access", + slug="game-dashboard-field-access", + box_art="https://example.com/game-box-art.jpg", + ) + game.owners.add(org) + + campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="campaign_dashboard_field_access", + name="Campaign Dashboard Field Access", + game=game, + operation_names=["DropCampaignDetails"], + image_url="https://example.com/campaign.jpg", + start_at=now - timedelta(hours=1), + end_at=now + timedelta(hours=1), + ) + channel: Channel = Channel.objects.create( + twitch_id="channel_dashboard_field_access", + name="channeldashboardfieldaccess", + display_name="Channel Dashboard Field Access", + ) + campaign.allow_channels.add(channel) + + RewardCampaign.objects.create( + twitch_id="reward_dashboard_field_access", + name="Reward Dashboard Field Access", + brand="Brand", + summary="Reward summary", + is_sitewide=False, + game=game, + starts_at=now - timedelta(hours=1), + ends_at=now + timedelta(hours=1), + ) + + dashboard_rewards_qs: QuerySet[RewardCampaign] = ( + RewardCampaign.active_for_dashboard(now) + ) + dashboard_campaigns_qs: QuerySet[DropCampaign] = ( + DropCampaign.active_for_dashboard(now) + ) + + rewards_list: list[RewardCampaign] = list(dashboard_rewards_qs) + list(dashboard_campaigns_qs) + + with CaptureQueriesContext(connection) as queries: + # Use pre-evaluated queryset to avoid capturing initial SELECT queries + grouped = DropCampaign.grouped_by_game(dashboard_campaigns_qs) + + for reward in rewards_list: + _ = reward.twitch_id + _ = reward.name + _ = reward.brand + _ = reward.summary + _ = reward.starts_at + _ = reward.ends_at + _ = reward.is_sitewide + _ = reward.is_active + if reward.game: + _ = reward.game.twitch_id + _ = reward.game.display_name + + assert game.twitch_id in grouped + + deferred_selects: list[str] = [ + query_info["sql"] + for query_info in queries.captured_queries + if query_info["sql"].lstrip().upper().startswith("SELECT") + ] + assert not deferred_selects, ( + "Dashboard model field access triggered unexpected deferred SELECT queries. " + f"Queries: {deferred_selects}" + ) + @pytest.mark.django_db def test_dashboard_grouping_reuses_selected_game_relation(self) -> None: """Dashboard grouping should not issue extra standalone Game queries."""