Implement emote gallery model method and refactor view to use it
This commit is contained in:
parent
3070dcb296
commit
1d524a2ca9
3 changed files with 239 additions and 25 deletions
|
|
@ -1247,6 +1247,50 @@ class DropBenefit(auto_prefetch.Model):
|
||||||
"""Return a string representation of the drop benefit."""
|
"""Return a string representation of the drop benefit."""
|
||||||
return self.name
|
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
|
@property
|
||||||
def image_best_url(self) -> str:
|
def image_best_url(self) -> str:
|
||||||
"""Return the best URL for the benefit image (local first)."""
|
"""Return the best URL for the benefit image (local first)."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
@pytest.mark.django_db
|
||||||
class TestDropCampaignListView:
|
class TestDropCampaignListView:
|
||||||
"""Tests for drop_campaign_list_view index usage and fat-model delegation."""
|
"""Tests for drop_campaign_list_view index usage and fat-model delegation."""
|
||||||
|
|
|
||||||
|
|
@ -269,31 +269,7 @@ def emote_gallery_view(request: HttpRequest) -> HttpResponse:
|
||||||
Returns:
|
Returns:
|
||||||
HttpResponse: The rendered emote gallery page.
|
HttpResponse: The rendered emote gallery page.
|
||||||
"""
|
"""
|
||||||
emote_benefits: QuerySet[DropBenefit, DropBenefit] = (
|
emotes: list[dict[str, str | DropCampaign]] = DropBenefit.emotes_for_gallery()
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title="Twitch Emotes",
|
page_title="Twitch Emotes",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue