diff --git a/twitch/models.py b/twitch/models.py index 8c46f68..df620f2 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -235,6 +235,11 @@ class Game(auto_prefetch.Model): """Alias for box_art_best_url to provide a common interface with benefits.""" return self.box_art_best_url + @property + def dashboard_box_art_url(self) -> str: + """Return dashboard-safe box art URL without touching deferred image fields.""" + return normalize_twitch_box_art_url(self.box_art or "") + # MARK: TwitchGame class TwitchGameData(auto_prefetch.Model): @@ -586,9 +591,6 @@ class DropCampaign(auto_prefetch.Model): "twitch_id", "name", "image_url", - "image_file", - "image_width", - "image_height", "start_at", "end_at", "allow_is_enabled", @@ -598,9 +600,6 @@ class DropCampaign(auto_prefetch.Model): "game__name", "game__slug", "game__box_art", - "game__box_art_file", - "game__box_art_width", - "game__box_art_height", ) .select_related("game") .prefetch_related( @@ -648,7 +647,7 @@ class DropCampaign(auto_prefetch.Model): game_id, { "name": game_display_name, - "box_art": game.box_art_best_url, + "box_art": game.dashboard_box_art_url, "owners": list(getattr(game, "owners_for_dashboard", [])), "campaigns": [], }, @@ -657,7 +656,7 @@ class DropCampaign(auto_prefetch.Model): game_bucket["campaigns"].append({ "campaign": campaign, "clean_name": campaign.clean_name, - "image_url": campaign.listing_image_url, + "image_url": campaign.dashboard_image_url, "allowed_channels": getattr(campaign, "channels_ordered", []), "game_display_name": game_display_name, "game_twitch_directory_url": game.twitch_directory_url, @@ -680,6 +679,24 @@ class DropCampaign(auto_prefetch.Model): """ return cls.grouped_by_game(cls.active_for_dashboard(now)) + @classmethod + def dashboard_context( + cls, + now: datetime.datetime, + ) -> dict[str, Any]: + """Return dashboard data assembled by model-layer query helpers. + + Args: + now: Current timestamp used for active-window filtering. + + Returns: + Dict with grouped drop campaigns and active reward campaigns. + """ + return { + "campaigns_by_game": cls.campaigns_by_game_for_dashboard(now), + "active_reward_campaigns": RewardCampaign.active_for_dashboard(now), + } + @property def is_active(self) -> bool: """Check if the campaign is currently active.""" @@ -761,6 +778,11 @@ class DropCampaign(auto_prefetch.Model): logger.debug("Failed to resolve DropCampaign.image_file url: %s", exc) return self.image_url or "" + @property + def dashboard_image_url(self) -> str: + """Return dashboard-safe campaign image URL without touching deferred image fields.""" + return self.image_url or "" + @property def duration_iso(self) -> str: """Return the campaign duration in ISO 8601 format (e.g., 'P3DT4H30M'). diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index e74bf7d..fd01e9b 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -36,6 +36,8 @@ from twitch.views import _build_seo_context from twitch.views import _truncate_description if TYPE_CHECKING: + from collections import OrderedDict + from django.core.handlers.wsgi import WSGIRequest from django.db.models import QuerySet from django.test import Client @@ -543,7 +545,7 @@ class TestChannelListView: assert len(context["campaigns_by_game"][game.twitch_id]["campaigns"]) == 1 @pytest.mark.django_db - def test_dashboard_queries_use_indexes(self) -> None: + def test_dashboard_queries_use_indexes(self, client: Client) -> None: """Dashboard source queries should use indexes for active-window filtering.""" now: datetime.datetime = timezone.now() @@ -627,6 +629,9 @@ class TestChannelListView: RewardCampaign.active_for_dashboard(now) ) + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:dashboard")) + assert response.status_code == 200 + campaigns_plan: str = active_campaigns_qs.explain() reward_plan: str = active_reward_campaigns_qs.explain() @@ -650,6 +655,76 @@ class TestChannelListView: assert campaigns_uses_index, campaigns_plan assert rewards_uses_index, reward_plan + @pytest.mark.django_db + def test_dashboard_context_uses_prefetched_data_without_n_plus_one(self) -> None: + """Dashboard context should not trigger extra queries when rendering-used attrs are accessed.""" + now: datetime.datetime = timezone.now() + + org: Organization = Organization.objects.create( + twitch_id="org_dashboard_prefetch", + name="Org Dashboard Prefetch", + ) + game: Game = Game.objects.create( + twitch_id="game_dashboard_prefetch", + name="Game Dashboard Prefetch", + display_name="Game Dashboard Prefetch", + ) + game.owners.add(org) + + channel: Channel = Channel.objects.create( + twitch_id="channel_dashboard_prefetch", + name="channeldashboardprefetch", + display_name="Channel Dashboard Prefetch", + ) + + campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="campaign_dashboard_prefetch", + name="Campaign Dashboard Prefetch", + game=game, + operation_names=["DropCampaignDetails"], + start_at=now - timedelta(hours=1), + end_at=now + timedelta(hours=1), + ) + campaign.allow_channels.add(channel) + + RewardCampaign.objects.create( + twitch_id="reward_dashboard_prefetch", + name="Reward Dashboard Prefetch", + game=game, + starts_at=now - timedelta(hours=1), + ends_at=now + timedelta(hours=1), + ) + + dashboard_data: dict[str, Any] = DropCampaign.dashboard_context(now) + campaigns_by_game: OrderedDict[str, dict[str, Any]] = dashboard_data[ + "campaigns_by_game" + ] + reward_campaigns: list[RewardCampaign] = list( + dashboard_data["active_reward_campaigns"], + ) + + with CaptureQueriesContext(connection) as capture: + game_bucket: dict[str, Any] = campaigns_by_game[game.twitch_id] + _ = game_bucket["name"] + _ = game_bucket["box_art"] + _ = [owner.name for owner in game_bucket["owners"]] + + campaign_entry: dict[str, Any] = game_bucket["campaigns"][0] + campaign_obj: DropCampaign = campaign_entry["campaign"] + + _ = campaign_obj.clean_name + _ = campaign_obj.duration_iso + _ = campaign_obj.start_at + _ = campaign_obj.end_at + _ = campaign_entry["image_url"] + _ = campaign_entry["game_twitch_directory_url"] + _ = [c.display_name for c in campaign_entry["allowed_channels"]] + + _ = [r.is_active for r in reward_campaigns] + _ = [r.game.display_name if r.game else None for r in reward_campaigns] + + assert len(capture) == 0 + @pytest.mark.django_db def test_dashboard_query_plans_reference_expected_index_names(self) -> None: """Dashboard active-window plans should mention concrete index names.""" diff --git a/twitch/views.py b/twitch/views.py index d03ed62..7a208b9 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -1056,14 +1056,7 @@ def dashboard(request: HttpRequest) -> HttpResponse: HttpResponse: The rendered dashboard template. """ now: datetime.datetime = timezone.now() - campaigns_by_game: OrderedDict[str, dict[str, Any]] = ( - DropCampaign.campaigns_by_game_for_dashboard(now) - ) - - # Get active reward campaigns (Quest rewards) - active_reward_campaigns: QuerySet[RewardCampaign] = ( - RewardCampaign.active_for_dashboard(now) - ) + dashboard_data: dict[str, Any] = DropCampaign.dashboard_context(now) # WebSite schema with SearchAction for sitelinks search box # TODO(TheLovinator): Should this be on all pages instead of just the dashboard? # noqa: TD003 @@ -1096,9 +1089,8 @@ def dashboard(request: HttpRequest) -> HttpResponse: request, "twitch/dashboard.html", { - "campaigns_by_game": campaigns_by_game, - "active_reward_campaigns": active_reward_campaigns, "now": now, + **dashboard_data, **seo_context, }, )