Add DropCampaign image fallback logic and update templates for best image URL

This commit is contained in:
Joakim Hellsén 2026-02-12 02:50:10 +01:00
commit 7f468bbabe
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
8 changed files with 438 additions and 17 deletions

265
example.json Normal file
View file

@ -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"
}
}
]

View file

@ -19,10 +19,10 @@
</p> </p>
{% endfor %} {% endfor %}
<!-- Campaign image --> <!-- Campaign image -->
{% if campaign.image_url %} {% if campaign.image_best_url %}
<img height="160" <img height="160"
width="160" width="160"
src="{{ campaign.image_best_url|default:campaign.image_url }}" src="{{ campaign.image_best_url }}"
alt="{{ campaign.name }}" /> alt="{{ campaign.name }}" />
{% endif %} {% endif %}
<!-- Campaign description --> <!-- Campaign description -->

View file

@ -97,8 +97,8 @@
<div> <div>
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}" <a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}"
style="text-decoration: none"> style="text-decoration: none">
{% if campaign.image_best_url or campaign.image_url %} {% if campaign.image_best_url %}
<img src="{{ campaign.image_best_url|default:campaign.image_url }}" <img src="{{ campaign.image_best_url }}"
alt="Campaign artwork for {{ campaign.name }}" alt="Campaign artwork for {{ campaign.name }}"
width="120" width="120"
height="120" height="120"

View file

@ -44,7 +44,7 @@
{% endif %} {% endif %}
</section> </section>
<section> <section>
<h2>Campaigns without Image URLs ({{ broken_image_campaigns|length }})</h2> <h2>Campaigns without Images ({{ broken_image_campaigns|length }})</h2>
{% if broken_image_campaigns %} {% if broken_image_campaigns %}
<ul> <ul>
{% for c in broken_image_campaigns %} {% for c in broken_image_campaigns %}
@ -81,7 +81,7 @@
{% endif %} {% endif %}
</section> </section>
<section> <section>
<h2>Active Campaigns Missing Image ({{ active_missing_image|length }})</h2> <h2>Active Campaigns without Images ({{ active_missing_image|length }})</h2>
{% if active_missing_image %} {% if active_missing_image %}
<ul> <ul>
{% for c in active_missing_image %} {% for c in active_missing_image %}

View file

@ -15,10 +15,10 @@
<a href="{% url 'twitch:reward_campaign_list' %}">← Back to Reward Campaigns</a> <a href="{% url 'twitch:reward_campaign_list' %}">← Back to Reward Campaigns</a>
</p> </p>
<!-- Campaign image --> <!-- Campaign image -->
{% if reward_campaign.image_url %} {% if reward_campaign.image_best_url %}
<img height="160" <img height="160"
width="160" width="160"
src="{{ reward_campaign.image_best_url|default:reward_campaign.image_url }}" src="{{ reward_campaign.image_best_url }}"
alt="{{ reward_campaign.name }}" /> alt="{{ reward_campaign.name }}" />
{% endif %} {% endif %}
<!-- RSS Feeds --> <!-- RSS Feeds -->

View file

@ -446,7 +446,13 @@ class DropCampaign(auto_prefetch.Model):
@property @property
def image_best_url(self) -> str: 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: try:
if self.image_file and getattr(self.image_file, "url", None): if self.image_file and getattr(self.image_file, "url", None):
return self.image_file.url return self.image_file.url
@ -455,7 +461,18 @@ class DropCampaign(auto_prefetch.Model):
"Failed to resolve DropCampaign.image_file url: %s", "Failed to resolve DropCampaign.image_file url: %s",
exc, 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 @property
def duration_iso(self) -> str: def duration_iso(self) -> str:
@ -537,7 +554,7 @@ class DropCampaign(auto_prefetch.Model):
def get_feed_enclosure_url(self) -> str: def get_feed_enclosure_url(self) -> str:
"""Return the campaign image URL for RSS enclosures.""" """Return the campaign image URL for RSS enclosures."""
return self.image_url return self.image_best_url
# MARK: DropBenefit # MARK: DropBenefit

View file

@ -1322,3 +1322,123 @@ class TestSEOPaginationLinks:
# Should be a dict with rel and url # Should be a dict with rel and url
assert isinstance(pagination_info, dict) assert isinstance(pagination_info, dict)
assert "rel" in pagination_info or pagination_info is None 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

View file

@ -19,6 +19,7 @@ from django.core.paginator import PageNotAnInteger
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.core.serializers import serialize from django.core.serializers import serialize
from django.db.models import Count from django.db.models import Count
from django.db.models import Exists
from django.db.models import F from django.db.models import F
from django.db.models import OuterRef from django.db.models import OuterRef
from django.db.models import Prefetch from django.db.models import Prefetch
@ -856,7 +857,7 @@ 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_url campaign_image: str | None = campaign.image_best_url
campaign_schema: dict[str, str | dict[str, str]] = { campaign_schema: dict[str, str | dict[str, str]] = {
"@context": "https://schema.org", "@context": "https://schema.org",
@ -1510,10 +1511,21 @@ def debug_view(request: HttpRequest) -> HttpResponse:
owners__isnull=True, owners__isnull=True,
).order_by("display_name") ).order_by("display_name")
# Campaigns with missing or obviously broken images # Campaigns with no images at all (no direct URL and no benefit image fallbacks)
broken_image_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter( broken_image_campaigns: QuerySet[DropCampaign] = (
Q(image_url__isnull=True) | Q(image_url__exact="") | ~Q(image_url__startswith="http"), DropCampaign.objects
).select_related("game") .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 # Benefits with missing images
broken_benefit_images: QuerySet[DropBenefit] = DropBenefit.objects.annotate( broken_benefit_images: QuerySet[DropBenefit] = DropBenefit.objects.annotate(
@ -1544,13 +1556,20 @@ def debug_view(request: HttpRequest) -> HttpResponse:
.order_by("game__display_name", "name") .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] = ( active_missing_image: QuerySet[DropCampaign] = (
DropCampaign.objects DropCampaign.objects
.filter(start_at__lte=now, end_at__gte=now) .filter(start_at__lte=now, end_at__gte=now)
.filter( .filter(
Q(image_url__isnull=True) | Q(image_url__exact="") | ~Q(image_url__startswith="http"), 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") .select_related("game")
) )