Use the reward image instead of campaign if only one reward
All checks were successful
Deploy to Server / deploy (push) Successful in 26s

This commit is contained in:
Joakim Hellsén 2026-05-11 22:03:15 +02:00
commit b06dd6b1ac
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
3 changed files with 187 additions and 9 deletions

View file

@ -580,7 +580,7 @@ class Channel(auto_prefetch.Model):
# MARK: DropCampaign # MARK: DropCampaign
class DropCampaign(auto_prefetch.Model): class DropCampaign(auto_prefetch.Model): # noqa: PLR0904
"""Represents a Twitch drop campaign.""" """Represents a Twitch drop campaign."""
twitch_id = models.TextField( twitch_id = models.TextField(
@ -1148,6 +1148,21 @@ class DropCampaign(auto_prefetch.Model):
).order_by("display_name"), ).order_by("display_name"),
to_attr="channels_ordered", to_attr="channels_ordered",
), ),
Prefetch(
"time_based_drops",
queryset=TimeBasedDrop.objects.only(
"twitch_id",
"campaign_id",
).prefetch_related(
Prefetch(
"benefits",
queryset=DropBenefit.objects.only(
"twitch_id",
"image_asset_url",
),
),
),
),
) )
.order_by("-start_at") .order_by("-start_at")
) )
@ -1268,6 +1283,38 @@ class DropCampaign(auto_prefetch.Model):
return self.name return self.name
@property
def single_reward_benefit(self) -> DropBenefit | None:
"""Return the only unique reward benefit for this campaign, if it has one."""
benefits: list[DropBenefit] = []
seen_benefit_keys: set[int | str] = set()
for drop in self.time_based_drops.all(): # pyright: ignore[reportAttributeAccessIssue]
for benefit in drop.benefits.all(): # pyright: ignore[reportAttributeAccessIssue]
benefit_key: int | str = benefit.pk or benefit.twitch_id
if benefit_key in seen_benefit_keys:
continue
seen_benefit_keys.add(benefit_key)
benefits.append(benefit)
if len(benefits) > 1:
return None
return benefits[0] if benefits else None
@property
def single_reward_image_best_url(self) -> str:
"""Return the best image URL for a campaign that has exactly one reward."""
benefit: DropBenefit | None = self.single_reward_benefit
if not benefit:
return ""
return benefit.image_best_url
@property
def meta_image_url(self) -> str:
"""Return the preferred campaign image URL for SEO metadata."""
return self.single_reward_image_best_url or self.image_best_url
@property @property
def image_best_url(self) -> str: def image_best_url(self) -> str:
"""Return the best URL for the campaign image. """Return the best URL for the campaign image.
@ -1311,7 +1358,10 @@ class DropCampaign(auto_prefetch.Model):
@property @property
def dashboard_image_url(self) -> str: def dashboard_image_url(self) -> str:
"""Return dashboard-safe campaign image URL without touching deferred image fields.""" """Return dashboard-safe campaign or single-reward image URL."""
benefit: DropBenefit | None = self.single_reward_benefit
if benefit and benefit.image_asset_url:
return benefit.image_asset_url
return self.image_url or "" return self.image_url or ""
@property @property

View file

@ -1057,6 +1057,46 @@ class TestChannelListView:
assert len(capture) == 0 assert len(capture) == 0
@pytest.mark.django_db
def test_dashboard_uses_single_reward_image_for_campaign_card(self) -> None:
"""Dashboard campaign cards should show the reward image for one-reward campaigns."""
now: datetime.datetime = timezone.now()
game: Game = Game.objects.create(
twitch_id="game_dashboard_single_reward_image",
name="game_dashboard_single_reward_image",
display_name="Game Dashboard Single Reward Image",
)
campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="campaign_dashboard_single_reward_image",
name="Campaign Dashboard Single Reward Image",
game=game,
operation_names=["DropCampaignDetails"],
image_url="https://example.com/campaign.png",
start_at=now - timedelta(hours=1),
end_at=now + timedelta(hours=1),
)
benefit: DropBenefit = DropBenefit.objects.create(
twitch_id="benefit_dashboard_single_reward_image",
name="Benefit Dashboard Single Reward Image",
image_asset_url="https://example.com/benefit.png",
)
drop: TimeBasedDrop = TimeBasedDrop.objects.create(
twitch_id="drop_dashboard_single_reward_image",
name="Drop Dashboard Single Reward Image",
campaign=campaign,
)
drop.benefits.add(benefit)
campaigns_by_game: OrderedDict[str, dict[str, Any]] = (
DropCampaign.campaigns_by_game_for_dashboard(now)
)
assert (
campaigns_by_game[game.twitch_id]["campaigns"][0]["image_url"]
== "https://example.com/benefit.png"
)
@pytest.mark.django_db @pytest.mark.django_db
def test_dashboard_query_plans_reference_expected_index_names(self) -> None: def test_dashboard_query_plans_reference_expected_index_names(self) -> None:
"""Dashboard active-window plans should mention concrete index names.""" """Dashboard active-window plans should mention concrete index names."""
@ -2939,6 +2979,31 @@ class TestSEOMetaTags:
assert "modified_date" in response.context assert "modified_date" in response.context
assert response.context["modified_date"] is not None assert response.context["modified_date"] is not None
def test_campaign_detail_meta_image_uses_single_reward_image(
self,
client: Client,
game_with_campaign: dict[str, Any],
) -> None:
"""Campaign detail SEO image should prefer the reward image for one-reward campaigns."""
campaign: DropCampaign = game_with_campaign["campaign"]
benefit: DropBenefit = DropBenefit.objects.create(
twitch_id="seo-single-reward-benefit",
name="SEO Single Reward Benefit",
image_asset_url="https://example.com/seo-benefit.png",
)
drop: TimeBasedDrop = TimeBasedDrop.objects.create(
twitch_id="seo-single-reward-drop",
name="SEO Single Reward Drop",
campaign=campaign,
)
drop.benefits.add(benefit)
url = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
assert response.context["page_image"] == "https://example.com/seo-benefit.png"
def test_game_detail_view_has_seo_context( def test_game_detail_view_has_seo_context(
self, self,
client: Client, client: Client,
@ -3506,6 +3571,65 @@ class TestDropCampaignImageFallback:
# Should return campaign image, not benefit image # Should return campaign image, not benefit image
assert campaign.image_best_url == "https://example.com/campaign.png" assert campaign.image_best_url == "https://example.com/campaign.png"
def test_meta_image_url_prefers_single_reward_image(self) -> None:
"""Meta image should use the reward image when a campaign has one reward."""
game: Game = Game.objects.create(
twitch_id="game1",
name="test_game",
display_name="Test Game",
)
campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="camp1",
name="Test Campaign",
game=game,
image_url="https://example.com/campaign.png",
)
benefit: DropBenefit = DropBenefit.objects.create(
twitch_id="benefit1",
name="Test Benefit",
image_asset_url="https://example.com/benefit.png",
)
drop: TimeBasedDrop = TimeBasedDrop.objects.create(
twitch_id="drop1",
name="Test Drop",
campaign=campaign,
)
drop.benefits.add(benefit)
assert campaign.meta_image_url == "https://example.com/benefit.png"
def test_meta_image_url_uses_campaign_image_with_multiple_rewards(self) -> None:
"""Meta image should keep the campaign image when a campaign has many rewards."""
game: Game = Game.objects.create(
twitch_id="game1",
name="test_game",
display_name="Test Game",
)
campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="camp1",
name="Test Campaign",
game=game,
image_url="https://example.com/campaign.png",
)
first_benefit: DropBenefit = DropBenefit.objects.create(
twitch_id="benefit1",
name="Test Benefit 1",
image_asset_url="https://example.com/benefit-1.png",
)
second_benefit: DropBenefit = DropBenefit.objects.create(
twitch_id="benefit2",
name="Test Benefit 2",
image_asset_url="https://example.com/benefit-2.png",
)
drop: TimeBasedDrop = TimeBasedDrop.objects.create(
twitch_id="drop1",
name="Test Drop",
campaign=campaign,
)
drop.benefits.add(first_benefit, second_benefit)
assert campaign.meta_image_url == "https://example.com/campaign.png"
def test_image_best_url_returns_empty_when_no_images(self) -> None: def test_image_best_url_returns_empty_when_no_images(self) -> None:
"""Test that image_best_url returns empty string when no images available.""" """Test that image_best_url returns empty string when no images available."""
game: Game = Game.objects.create( game: Game = Game.objects.create(

View file

@ -520,13 +520,17 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
if campaign.description if campaign.description
else f"Twitch drop campaign: {campaign_name}" else f"Twitch drop campaign: {campaign_name}"
) )
campaign_image: str | None = campaign.image_best_url single_reward: DropBenefit | None = campaign.single_reward_benefit
campaign_image_width: int | None = ( single_reward_image: str = single_reward.image_best_url if single_reward else ""
campaign.image_width if campaign.image_file else None campaign_image: str | None = single_reward_image or campaign.image_best_url
) campaign_image_width: int | None = None
campaign_image_height: int | None = ( campaign_image_height: int | None = None
campaign.image_height if campaign.image_file else None if single_reward_image and single_reward and single_reward.image_file:
) campaign_image_width = single_reward.image_width
campaign_image_height = single_reward.image_height
elif campaign.image_file:
campaign_image_width = campaign.image_width
campaign_image_height = campaign.image_height
url: str = build_absolute_uri( url: str = build_absolute_uri(
reverse("twitch:campaign_detail", args=[campaign.twitch_id]), reverse("twitch:campaign_detail", args=[campaign.twitch_id]),