Implement emote gallery model method and refactor view to use it

This commit is contained in:
Joakim Hellsén 2026-04-12 04:53:08 +02:00
commit 1d524a2ca9
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
3 changed files with 239 additions and 25 deletions

View file

@ -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)."""

View file

@ -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."""

View file

@ -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",