diff --git a/twitch/models.py b/twitch/models.py index f8f3df4..0e08d39 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -580,7 +580,7 @@ class Channel(auto_prefetch.Model): # MARK: DropCampaign -class DropCampaign(auto_prefetch.Model): +class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 """Represents a Twitch drop campaign.""" twitch_id = models.TextField( @@ -1148,6 +1148,21 @@ class DropCampaign(auto_prefetch.Model): ).order_by("display_name"), 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") ) @@ -1268,6 +1283,38 @@ class DropCampaign(auto_prefetch.Model): 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 def image_best_url(self) -> str: """Return the best URL for the campaign image. @@ -1311,7 +1358,10 @@ class DropCampaign(auto_prefetch.Model): @property 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 "" @property diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 4beb39d..397077a 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -1057,6 +1057,46 @@ class TestChannelListView: 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 def test_dashboard_query_plans_reference_expected_index_names(self) -> None: """Dashboard active-window plans should mention concrete index names.""" @@ -2939,6 +2979,31 @@ class TestSEOMetaTags: assert "modified_date" in response.context 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( self, client: Client, @@ -3506,6 +3571,65 @@ class TestDropCampaignImageFallback: # Should return campaign image, not benefit image 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: """Test that image_best_url returns empty string when no images available.""" game: Game = Game.objects.create( diff --git a/twitch/views.py b/twitch/views.py index 0e2badf..84cecc0 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -520,13 +520,17 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo if campaign.description else f"Twitch drop campaign: {campaign_name}" ) - campaign_image: str | None = campaign.image_best_url - campaign_image_width: int | None = ( - campaign.image_width if campaign.image_file else None - ) - campaign_image_height: int | None = ( - campaign.image_height if campaign.image_file else None - ) + single_reward: DropBenefit | None = campaign.single_reward_benefit + single_reward_image: str = single_reward.image_best_url if single_reward else "" + campaign_image: str | None = single_reward_image or campaign.image_best_url + campaign_image_width: int | None = None + campaign_image_height: int | None = 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( reverse("twitch:campaign_detail", args=[campaign.twitch_id]),