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 %}
{% 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 %}
-
- 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 %}
{% 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")
)