diff --git a/twitch/models.py b/twitch/models.py index 0372da7..e26c419 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -1247,6 +1247,50 @@ class DropBenefit(auto_prefetch.Model): """Return a string representation of the drop benefit.""" return self.name + @classmethod + def emotes_for_gallery(cls) -> list[dict[str, str | DropCampaign]]: + """Return emote gallery entries with only fields needed by the template. + + The emote gallery needs benefit image URL and campaign name/twitch_id. + """ + emote_benefits: QuerySet[DropBenefit, DropBenefit] = ( + cls.objects + .filter(distribution_type="EMOTE") + .only("twitch_id", "image_asset_url", "image_file") + .prefetch_related( + Prefetch( + "drops", + queryset=( + TimeBasedDrop.objects.select_related("campaign").only( + "campaign_id", + "campaign__twitch_id", + "campaign__name", + ) + ), + to_attr="_emote_drops_for_gallery", + ), + ) + ) + + emotes: list[dict[str, str | DropCampaign]] = [] + for benefit in emote_benefits: + drop: TimeBasedDrop | None = next( + ( + drop + for drop in getattr(benefit, "_emote_drops_for_gallery", []) + if drop.campaign_id + ), + None, + ) + if not drop: + continue + emotes.append({ + "image_url": benefit.image_best_url, + "campaign": drop.campaign, + }) + + return emotes + @property def image_best_url(self) -> str: """Return the best URL for the benefit image (local first).""" diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 2ce8203..8efb123 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -3818,6 +3818,200 @@ class TestBadgeSetDetailView: ) +@pytest.mark.django_db +class TestEmoteGalleryView: + """Tests for emote gallery model delegation and query safety.""" + + def test_emote_gallery_view_uses_model_helper( + self, + client: Client, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Emote gallery view should delegate data loading to the model layer.""" + game: Game = Game.objects.create( + twitch_id="emote_gallery_delegate_game", + name="Emote Delegate Game", + display_name="Emote Delegate Game", + ) + campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="emote_gallery_delegate_campaign", + name="Emote Delegate Campaign", + game=game, + operation_names=["DropCampaignDetails"], + ) + expected: list[dict[str, str | DropCampaign]] = [ + { + "image_url": "https://example.com/emote.png", + "campaign": campaign, + }, + ] + + calls: dict[str, int] = {"count": 0} + + def _fake_emotes_for_gallery( + _cls: type[DropBenefit], + ) -> list[dict[str, str | DropCampaign]]: + calls["count"] += 1 + return expected + + monkeypatch.setattr( + DropBenefit, + "emotes_for_gallery", + classmethod(_fake_emotes_for_gallery), + ) + + response: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:emote_gallery"), + ) + assert response.status_code == 200 + + context: ContextList | dict[str, Any] = response.context + if isinstance(context, list): + context = context[-1] + + assert calls["count"] == 1 + assert context["emotes"] == expected + + def test_emotes_for_gallery_uses_prefetched_fields_without_extra_queries( + self, + ) -> None: + """Accessing template-used fields should not issue follow-up SELECT queries.""" + now: datetime.datetime = timezone.now() + + game: Game = Game.objects.create( + twitch_id="emote_gallery_fields_game", + name="Emote Fields Game", + display_name="Emote Fields Game", + ) + campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="emote_gallery_fields_campaign", + name="Emote Fields Campaign", + game=game, + operation_names=["DropCampaignDetails"], + start_at=now - timedelta(hours=1), + end_at=now + timedelta(hours=1), + ) + drop: TimeBasedDrop = TimeBasedDrop.objects.create( + twitch_id="emote_gallery_fields_drop", + name="Emote Fields Drop", + campaign=campaign, + ) + benefit: DropBenefit = DropBenefit.objects.create( + twitch_id="emote_gallery_fields_benefit", + name="Emote Fields Benefit", + distribution_type="EMOTE", + image_asset_url="https://example.com/emote_fields.png", + ) + drop.benefits.add(benefit) + + emotes: list[dict[str, str | DropCampaign]] = DropBenefit.emotes_for_gallery() + assert len(emotes) == 1 + + with CaptureQueriesContext(connection) as capture: + for emote in emotes: + _ = emote["image_url"] + campaign_obj = emote["campaign"] + assert isinstance(campaign_obj, DropCampaign) + _ = campaign_obj.twitch_id + _ = campaign_obj.name + + assert len(capture) == 0 + + def test_emotes_for_gallery_skips_emotes_without_campaign_link(self) -> None: + """Gallery should only include EMOTE benefits reachable from a campaign drop.""" + game: Game = Game.objects.create( + twitch_id="emote_gallery_skip_game", + name="Emote Skip Game", + display_name="Emote Skip Game", + ) + campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="emote_gallery_skip_campaign", + name="Emote Skip Campaign", + game=game, + operation_names=["DropCampaignDetails"], + ) + drop: TimeBasedDrop = TimeBasedDrop.objects.create( + twitch_id="emote_gallery_skip_drop", + name="Emote Skip Drop", + campaign=campaign, + ) + + included: DropBenefit = DropBenefit.objects.create( + twitch_id="emote_gallery_included_benefit", + name="Included Emote", + distribution_type="EMOTE", + image_asset_url="https://example.com/included-emote.png", + ) + orphaned: DropBenefit = DropBenefit.objects.create( + twitch_id="emote_gallery_orphaned_benefit", + name="Orphaned Emote", + distribution_type="EMOTE", + image_asset_url="https://example.com/orphaned-emote.png", + ) + + drop.benefits.add(included) + + emotes: list[dict[str, str | DropCampaign]] = DropBenefit.emotes_for_gallery() + + image_urls: list[str] = [str(item["image_url"]) for item in emotes] + campaign_ids: list[str] = [ + campaign_obj.twitch_id + for campaign_obj in (item["campaign"] for item in emotes) + if isinstance(campaign_obj, DropCampaign) + ] + + assert included.image_asset_url in image_urls + assert orphaned.image_asset_url not in image_urls + assert campaign.twitch_id in campaign_ids + + def test_emote_gallery_view_renders_only_campaign_linked_emotes( + self, + client: Client, + ) -> None: + """Emote gallery page should not render EMOTE benefits without campaign-linked drops.""" + game: Game = Game.objects.create( + twitch_id="emote_gallery_view_game", + name="Emote View Game", + display_name="Emote View Game", + ) + campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="emote_gallery_view_campaign", + name="Emote View Campaign", + game=game, + operation_names=["DropCampaignDetails"], + ) + drop: TimeBasedDrop = TimeBasedDrop.objects.create( + twitch_id="emote_gallery_view_drop", + name="Emote View Drop", + campaign=campaign, + ) + + linked: DropBenefit = DropBenefit.objects.create( + twitch_id="emote_gallery_view_linked", + name="Linked Emote", + distribution_type="EMOTE", + image_asset_url="https://example.com/linked-view-emote.png", + ) + orphaned: DropBenefit = DropBenefit.objects.create( + twitch_id="emote_gallery_view_orphaned", + name="Orphaned View Emote", + distribution_type="EMOTE", + image_asset_url="https://example.com/orphaned-view-emote.png", + ) + + drop.benefits.add(linked) + + response: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:emote_gallery"), + ) + assert response.status_code == 200 + + html: str = response.content.decode("utf-8") + assert linked.image_asset_url in html + assert orphaned.image_asset_url not in html + assert reverse("twitch:campaign_detail", args=[campaign.twitch_id]) in html + + @pytest.mark.django_db class TestDropCampaignListView: """Tests for drop_campaign_list_view index usage and fat-model delegation.""" diff --git a/twitch/views.py b/twitch/views.py index 8484cfb..b90a3b1 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -269,31 +269,7 @@ def emote_gallery_view(request: HttpRequest) -> HttpResponse: Returns: HttpResponse: The rendered emote gallery page. """ - emote_benefits: QuerySet[DropBenefit, DropBenefit] = ( - DropBenefit.objects - .filter(distribution_type="EMOTE") - .select_related() - .prefetch_related( - Prefetch( - "drops", - queryset=TimeBasedDrop.objects.select_related("campaign"), - to_attr="_emote_drops", - ), - ) - ) - - emotes: list[dict[str, str | DropCampaign]] = [] - for benefit in emote_benefits: - # Find the first drop with a campaign for this benefit - drop: TimeBasedDrop | None = next( - (d for d in getattr(benefit, "_emote_drops", []) if d.campaign), - None, - ) - if drop and drop.campaign: - emotes.append({ - "image_url": benefit.image_best_url, - "campaign": drop.campaign, - }) + emotes: list[dict[str, str | DropCampaign]] = DropBenefit.emotes_for_gallery() seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch Emotes",