From 7f468bbabe05e8ec224cfec4c5e76617046887d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Thu, 12 Feb 2026 02:50:10 +0100 Subject: [PATCH] Add DropCampaign image fallback logic and update templates for best image URL --- example.json | 265 +++++++++++++++++++ templates/twitch/campaign_detail.html | 4 +- templates/twitch/campaign_list.html | 4 +- templates/twitch/debug.html | 4 +- templates/twitch/reward_campaign_detail.html | 4 +- twitch/models.py | 23 +- twitch/tests/test_views.py | 120 +++++++++ twitch/views.py | 31 ++- 8 files changed, 438 insertions(+), 17 deletions(-) create mode 100644 example.json diff --git a/example.json b/example.json new file mode 100644 index 0000000..029e342 --- /dev/null +++ b/example.json @@ -0,0 +1,265 @@ +[ + { + "data": { + "user": { + "id": "17658559", + "dropCampaign": { + "id": "3b965979-ecd2-11f0-876e-0a58a9feac02", + "self": { + "isAccountConnected": true, + "__typename": "DropCampaignSelfEdge" + }, + "allow": { + "channels": null, + "isEnabled": false, + "__typename": "DropCampaignACL" + }, + "accountLinkURL": "https://link.smite2.com/", + "description": "Viewers will receive 50 Wandering Market Coins for each two hours spent viewing participating streams. Watch to earn 7 drops for a total of 350 Wandering Market Coins for the week!", + "detailsURL": "https://www.smite2.com/news/closed-alpha-twitch-drops/", + "endAt": "2026-01-17T10:58:59.999Z", + "eventBasedDrops": [], + "game": { + "id": "2094865572", + "slug": "smite-2", + "displayName": "SMITE 2", + "__typename": "Game" + }, + "imageURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/47db66e8-933c-484f-ab5a-30ba09093098.png", + "name": "Jan Drops Week 2", + "owner": { + "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", + "name": "Hi-Rez Studios", + "__typename": "Organization" + }, + "startAt": "2026-01-10T11:00:00Z", + "status": "ACTIVE", + "timeBasedDrops": [ + { + "id": "933c8f91-ecd2-11f0-b3fd-0a58a9feac02", + "requiredSubs": 0, + "benefitEdges": [ + { + "benefit": { + "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", + "createdAt": "2025-02-07T21:37:58.881Z", + "entitlementLimit": 1, + "game": { + "id": "2094865572", + "name": "SMITE 2", + "__typename": "Game" + }, + "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", + "isIosAvailable": false, + "name": "Market Coins Bundle 1", + "ownerOrganization": { + "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", + "name": "Hi-Rez Studios", + "__typename": "Organization" + }, + "distributionType": "DIRECT_ENTITLEMENT", + "__typename": "DropBenefit" + }, + "entitlementLimit": 1, + "__typename": "DropBenefitEdge" + } + ], + "endAt": "2026-01-17T10:58:59.999Z", + "name": "Market Coins Bundle 1", + "preconditionDrops": null, + "requiredMinutesWatched": 120, + "startAt": "2026-01-10T11:00:00Z", + "__typename": "TimeBasedDrop" + }, + { + "id": "9909373d-ecd2-11f0-92b1-0a58a9feac02", + "requiredSubs": 0, + "benefitEdges": [ + { + "benefit": { + "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", + "createdAt": "2025-02-07T21:37:58.881Z", + "entitlementLimit": 1, + "game": { + "id": "2094865572", + "name": "SMITE 2", + "__typename": "Game" + }, + "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", + "isIosAvailable": false, + "name": "Market Coins Bundle 1", + "ownerOrganization": { + "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", + "name": "Hi-Rez Studios", + "__typename": "Organization" + }, + "distributionType": "DIRECT_ENTITLEMENT", + "__typename": "DropBenefit" + }, + "entitlementLimit": 1, + "__typename": "DropBenefitEdge" + } + ], + "endAt": "2026-01-17T10:58:59.999Z", + "name": "Market Coins Bundle 2", + "preconditionDrops": null, + "requiredMinutesWatched": 240, + "startAt": "2026-01-10T11:00:00Z", + "__typename": "TimeBasedDrop" + }, + { + "id": "a5289489-ecd2-11f0-b098-0a58a9feac02", + "requiredSubs": 0, + "benefitEdges": [ + { + "benefit": { + "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", + "createdAt": "2025-02-07T21:37:58.881Z", + "entitlementLimit": 1, + "game": { + "id": "2094865572", + "name": "SMITE 2", + "__typename": "Game" + }, + "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", + "isIosAvailable": false, + "name": "Market Coins Bundle 1", + "ownerOrganization": { + "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", + "name": "Hi-Rez Studios", + "__typename": "Organization" + }, + "distributionType": "DIRECT_ENTITLEMENT", + "__typename": "DropBenefit" + }, + "entitlementLimit": 1, + "__typename": "DropBenefitEdge" + } + ], + "endAt": "2026-01-17T10:58:59.999Z", + "name": "Market Coins Bundle 3", + "preconditionDrops": null, + "requiredMinutesWatched": 360, + "startAt": "2026-01-10T11:00:00Z", + "__typename": "TimeBasedDrop" + }, + { + "id": "ab5ea171-ecd2-11f0-9e33-0a58a9feac02", + "requiredSubs": 0, + "benefitEdges": [ + { + "benefit": { + "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", + "createdAt": "2025-02-07T21:37:58.881Z", + "entitlementLimit": 1, + "game": { + "id": "2094865572", + "name": "SMITE 2", + "__typename": "Game" + }, + "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", + "isIosAvailable": false, + "name": "Market Coins Bundle 1", + "ownerOrganization": { + "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", + "name": "Hi-Rez Studios", + "__typename": "Organization" + }, + "distributionType": "DIRECT_ENTITLEMENT", + "__typename": "DropBenefit" + }, + "entitlementLimit": 1, + "__typename": "DropBenefitEdge" + } + ], + "endAt": "2026-01-17T10:58:59.999Z", + "name": "Market Coins Bundle 4", + "preconditionDrops": null, + "requiredMinutesWatched": 480, + "startAt": "2026-01-10T11:00:00Z", + "__typename": "TimeBasedDrop" + }, + { + "id": "b19b7afb-ecd2-11f0-bbd3-0a58a9feac02", + "requiredSubs": 0, + "benefitEdges": [ + { + "benefit": { + "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", + "createdAt": "2025-02-07T21:37:58.881Z", + "entitlementLimit": 1, + "game": { + "id": "2094865572", + "name": "SMITE 2", + "__typename": "Game" + }, + "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", + "isIosAvailable": false, + "name": "Market Coins Bundle 1", + "ownerOrganization": { + "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", + "name": "Hi-Rez Studios", + "__typename": "Organization" + }, + "distributionType": "DIRECT_ENTITLEMENT", + "__typename": "DropBenefit" + }, + "entitlementLimit": 1, + "__typename": "DropBenefitEdge" + } + ], + "endAt": "2026-01-17T10:58:59.999Z", + "name": "Market Coins Bundle 5", + "preconditionDrops": null, + "requiredMinutesWatched": 600, + "startAt": "2026-01-10T11:00:00Z", + "__typename": "TimeBasedDrop" + }, + { + "id": "b82db8e0-ecd2-11f0-8c96-0a58a9feac02", + "requiredSubs": 0, + "benefitEdges": [ + { + "benefit": { + "id": "ccb3fb7f-e59b-11ef-aef0-0a58a9feac02", + "createdAt": "2025-02-07T21:37:58.881Z", + "entitlementLimit": 1, + "game": { + "id": "2094865572", + "name": "SMITE 2", + "__typename": "Game" + }, + "imageAssetURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg", + "isIosAvailable": false, + "name": "Market Coins Bundle 1", + "ownerOrganization": { + "id": "51a157a0-674a-4863-b120-7bb6ee2466a8", + "name": "Hi-Rez Studios", + "__typename": "Organization" + }, + "distributionType": "DIRECT_ENTITLEMENT", + "__typename": "DropBenefit" + }, + "entitlementLimit": 1, + "__typename": "DropBenefitEdge" + } + ], + "endAt": "2026-01-17T10:58:59.999Z", + "name": "Market Coins Bundle 6", + "preconditionDrops": null, + "requiredMinutesWatched": 720, + "startAt": "2026-01-10T11:00:00Z", + "__typename": "TimeBasedDrop" + } + ], + "__typename": "DropCampaign" + }, + "__typename": "User" + } + }, + "extensions": { + "durationMilliseconds": 48, + "operationName": "DropCampaignDetails" + } + } +] diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index 6bd0aee..b133cc2 100644 --- a/templates/twitch/campaign_detail.html +++ b/templates/twitch/campaign_detail.html @@ -19,10 +19,10 @@

{% endfor %} - {% if campaign.image_url %} + {% if campaign.image_best_url %} {{ campaign.name }} {% endif %} diff --git a/templates/twitch/campaign_list.html b/templates/twitch/campaign_list.html index 30e2d59..19bbdef 100644 --- a/templates/twitch/campaign_list.html +++ b/templates/twitch/campaign_list.html @@ -97,8 +97,8 @@
- {% if campaign.image_best_url or campaign.image_url %} - Campaign artwork for {{ campaign.name }}
-

Campaigns without Image URLs ({{ broken_image_campaigns|length }})

+

Campaigns without Images ({{ broken_image_campaigns|length }})

{% if broken_image_campaigns %}
    {% for c in broken_image_campaigns %} @@ -81,7 +81,7 @@ {% endif %}
-

Active Campaigns Missing Image ({{ active_missing_image|length }})

+

Active Campaigns without Images ({{ active_missing_image|length }})

{% if active_missing_image %}
    {% for c in active_missing_image %} diff --git a/templates/twitch/reward_campaign_detail.html b/templates/twitch/reward_campaign_detail.html index 980fcd5..bf8f869 100644 --- a/templates/twitch/reward_campaign_detail.html +++ b/templates/twitch/reward_campaign_detail.html @@ -15,10 +15,10 @@ ← Back to Reward Campaigns

    - {% if reward_campaign.image_url %} + {% if reward_campaign.image_best_url %} {{ reward_campaign.name }} {% endif %} diff --git a/twitch/models.py b/twitch/models.py index 5b0fb0b..b358662 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -446,7 +446,13 @@ class DropCampaign(auto_prefetch.Model): @property def image_best_url(self) -> str: - """Return the best URL for the campaign image (local first).""" + """Return the best URL for the campaign image. + + Priority: + 1. Local cached image file + 2. Campaign image URL + 3. First benefit image URL (if campaign has no image) + """ try: if self.image_file and getattr(self.image_file, "url", None): return self.image_file.url @@ -455,7 +461,18 @@ class DropCampaign(auto_prefetch.Model): "Failed to resolve DropCampaign.image_file url: %s", exc, ) - return self.image_url or "" + + if self.image_url: + return self.image_url + + # If no campaign image, use the first benefit image + for drop in self.time_based_drops.all(): # pyright: ignore[reportAttributeAccessIssue] + for benefit in drop.benefits.all(): # pyright: ignore[reportAttributeAccessIssue] + benefit_image_url: str = benefit.image_best_url + if benefit_image_url: + return benefit_image_url + + return "" @property def duration_iso(self) -> str: @@ -537,7 +554,7 @@ class DropCampaign(auto_prefetch.Model): def get_feed_enclosure_url(self) -> str: """Return the campaign image URL for RSS enclosures.""" - return self.image_url + return self.image_best_url # MARK: DropBenefit diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 789a823..d5a99bb 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -1322,3 +1322,123 @@ class TestSEOPaginationLinks: # Should be a dict with rel and url assert isinstance(pagination_info, dict) assert "rel" in pagination_info or pagination_info is None + + +@pytest.mark.django_db +class TestDropCampaignImageFallback: + """Tests for DropCampaign image_best_url property with benefit fallback.""" + + def test_image_best_url_returns_campaign_image_url(self) -> None: + """Test that image_best_url returns campaign image_url when present.""" + 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", + ) + assert campaign.image_best_url == "https://example.com/campaign.png" + + def test_image_best_url_uses_benefit_image_when_campaign_has_no_image(self) -> None: + """Test that image_best_url returns first benefit image when campaign has no image.""" + 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="", # No campaign image + ) + 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.image_best_url == "https://example.com/benefit.png" + + def test_image_best_url_prefers_campaign_image_over_benefit_image(self) -> None: + """Test that campaign image is preferred over benefit image.""" + 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", # Campaign has image + ) + 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) + + # Should return campaign image, not benefit image + assert campaign.image_best_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( + 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="", # No campaign image + ) + # No benefits or drops + + assert not campaign.image_best_url + + def test_image_best_url_uses_benefit_best_url(self) -> None: + """Test that benefit's image_best_url property is used (prefers local file).""" + 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="", # No campaign image + ) + 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) + + # Should use benefit's image_asset_url (since no local file) + assert campaign.image_best_url == benefit.image_best_url diff --git a/twitch/views.py b/twitch/views.py index 5452f62..ca3fc11 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -19,6 +19,7 @@ from django.core.paginator import PageNotAnInteger from django.core.paginator import Paginator from django.core.serializers import serialize from django.db.models import Count +from django.db.models import Exists from django.db.models import F from django.db.models import OuterRef from django.db.models import Prefetch @@ -856,7 +857,7 @@ 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_url + campaign_image: str | None = campaign.image_best_url campaign_schema: dict[str, str | dict[str, str]] = { "@context": "https://schema.org", @@ -1510,10 +1511,21 @@ def debug_view(request: HttpRequest) -> HttpResponse: owners__isnull=True, ).order_by("display_name") - # Campaigns with missing or obviously broken images - broken_image_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter( - Q(image_url__isnull=True) | Q(image_url__exact="") | ~Q(image_url__startswith="http"), - ).select_related("game") + # Campaigns with no images at all (no direct URL and no benefit image fallbacks) + broken_image_campaigns: QuerySet[DropCampaign] = ( + DropCampaign.objects + .filter( + Q(image_url__isnull=True) | Q(image_url__exact="") | ~Q(image_url__startswith="http"), + ) + .exclude( + Exists( + TimeBasedDrop.objects.filter(campaign=OuterRef("pk")).filter( + benefits__image_asset_url__startswith="http", + ), + ), + ) + .select_related("game") + ) # Benefits with missing images broken_benefit_images: QuerySet[DropBenefit] = DropBenefit.objects.annotate( @@ -1544,13 +1556,20 @@ def debug_view(request: HttpRequest) -> HttpResponse: .order_by("game__display_name", "name") ) - # Campaigns currently active but image missing + # Active campaigns with no images at all (no direct URL and no benefit image fallbacks) active_missing_image: QuerySet[DropCampaign] = ( DropCampaign.objects .filter(start_at__lte=now, end_at__gte=now) .filter( Q(image_url__isnull=True) | Q(image_url__exact="") | ~Q(image_url__startswith="http"), ) + .exclude( + Exists( + TimeBasedDrop.objects.filter(campaign=OuterRef("pk")).filter( + benefits__image_asset_url__startswith="http", + ), + ), + ) .select_related("game") )