Compare commits

...

2 commits

Author SHA1 Message Date
768d986556
Use 404 image when no image is available
All checks were successful
Deploy to Server / deploy (push) Successful in 10s
2026-03-10 11:27:17 +01:00
6c22559fb5
Add support for Atom feeds 2026-03-10 07:51:55 +01:00
21 changed files with 381 additions and 6 deletions

View file

@ -103,7 +103,7 @@ MEDIA_URL = "/media/"
STATIC_ROOT: Path = DATA_DIR / "staticfiles" STATIC_ROOT: Path = DATA_DIR / "staticfiles"
STATIC_ROOT.mkdir(exist_ok=True) STATIC_ROOT.mkdir(exist_ok=True)
STATIC_URL = "static/" STATIC_URL = "/static/"
STATICFILES_DIRS: list[Path] = [BASE_DIR / "static"] STATICFILES_DIRS: list[Path] = [BASE_DIR / "static"]
TIME_ZONE = "UTC" TIME_ZONE = "UTC"

8
static/404.svg Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect width="100%" height="100%" fill="#9146ff" />
<g fill="#fff" font-family="Arial, Helvetica, sans-serif" font-size="24" font-weight="700">
<text x="50%" y="45%" dominant-baseline="middle" text-anchor="middle">:(</text>
<text x="50%" y="60%" dominant-baseline="middle" text-anchor="middle">404</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 457 B

View file

@ -23,6 +23,43 @@
{% endblock title %} {% endblock title %}
</title> </title>
{% include "includes/meta_tags.html" %} {% include "includes/meta_tags.html" %}
<!-- Feed discovery links -->
<!-- Read {% url 'twitch:docs_rss' %} for more details on available feeds -->
<link rel="alternate"
type="application/rss+xml"
title="All campaigns (RSS)"
href="{% url 'twitch:campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" />
<link rel="alternate"
type="application/rss+xml"
title="Newly added games (RSS)"
href="{% url 'twitch:game_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" />
<link rel="alternate"
type="application/rss+xml"
title="Newly added organizations (RSS)"
href="{% url 'twitch:organization_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added organizations (Atom)"
href="{% url 'twitch:organization_feed_atom' %}" />
<link rel="alternate"
type="application/rss+xml"
title="Newly added reward campaigns (RSS)"
href="{% url 'twitch:reward_campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" />
{# Allow child templates to inject page-specific alternates into the head #}
{% block extra_head %}
{% endblock extra_head %}
<style> <style>
html { html {
color-scheme: light dark; color-scheme: light dark;

View file

@ -4,6 +4,18 @@
{% block title %} {% block title %}
{{ campaign.clean_name }} {{ campaign.clean_name }}
{% endblock title %} {% endblock title %}
{% block extra_head %}
{% if campaign and campaign.game %}
<link rel="alternate"
type="application/rss+xml"
title="{{ campaign.game.display_name }} campaigns (RSS)"
href="{% url 'twitch:game_campaign_feed' campaign.game.twitch_id %}" />
<link rel="alternate"
type="application/atom+xml"
title="{{ campaign.game.display_name }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}" />
{% endif %}
{% endblock extra_head %}
{% block content %} {% block content %}
<!-- Campaign Title --> <!-- Campaign Title -->
<h1> <h1>
@ -71,6 +83,8 @@
{% if campaign.game %} {% if campaign.game %}
<a href="{% url 'twitch:game_campaign_feed' campaign.game.twitch_id %}" <a href="{% url 'twitch:game_campaign_feed' campaign.game.twitch_id %}"
title="RSS feed for {{ campaign.game.display_name }} campaigns">[rss]</a> title="RSS feed for {{ campaign.game.display_name }} campaigns">[rss]</a>
<a href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}"
title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a>
{% endif %} {% endif %}
</div> </div>
{% if allowed_channels %} {% if allowed_channels %}

View file

@ -5,6 +5,16 @@
{% block title %} {% block title %}
Drop Campaigns - Twitch Drops Tracker Drop Campaigns - Twitch Drops Tracker
{% endblock title %} {% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="All campaigns (RSS)"
href="{% url 'twitch:campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" />
{% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
<header> <header>
@ -13,6 +23,8 @@
<div> <div>
<a href="{% url 'twitch:campaign_feed' %}" <a href="{% url 'twitch:campaign_feed' %}"
title="RSS feed for all campaigns">[rss]</a> title="RSS feed for all campaigns">[rss]</a>
<a href="{% url 'twitch:campaign_feed_atom' %}"
title="Atom feed for all campaigns">[atom]</a>
<a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" <a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"
title="Export campaigns as CSV">[csv]</a> title="Export campaigns as CSV">[csv]</a>
<a href="{% url 'twitch:export_campaigns_json' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" <a href="{% url 'twitch:export_campaigns_json' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"

View file

@ -4,6 +4,16 @@
{% block title %} {% block title %}
Twitch drops Twitch drops
{% endblock title %} {% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="All campaigns (RSS)"
href="{% url 'twitch:campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All campaigns (Atom)"
href="{% url 'twitch:campaign_feed_atom' %}" />
{% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
<h1>Twitch Drops</h1> <h1>Twitch Drops</h1>
@ -16,6 +26,9 @@ Hover over the end time to see the exact date and time.
<a href="{% url 'twitch:campaign_feed' %}" <a href="{% url 'twitch:campaign_feed' %}"
style="margin-right: 1rem" style="margin-right: 1rem"
title="RSS feed for all campaigns">RSS feed for campaigns</a> title="RSS feed for all campaigns">RSS feed for campaigns</a>
&nbsp;|&nbsp;
<a href="{% url 'twitch:campaign_feed_atom' %}"
title="Atom feed for campaigns">Atom feed for campaigns</a>
</div> </div>
{% if campaigns_by_game %} {% if campaigns_by_game %}
{% for game_id, game_data in campaigns_by_game.items %} {% for game_id, game_data in campaigns_by_game.items %}

View file

@ -7,6 +7,11 @@
<main> <main>
<h1>RSS Feeds Documentation</h1> <h1>RSS Feeds Documentation</h1>
<p>This page lists all available RSS feeds for TTVDrops.</p> <p>This page lists all available RSS feeds for TTVDrops.</p>
<p>
Note: Atom feeds are also available for the same resources under the
<code>/atom/</code> endpoints (links labeled "Atom" are shown next to RSS links).
Both RSS and Atom formats are supported and served in parallel for backward compatibility.
</p>
<section> <section>
<h2>Global RSS Feeds</h2> <h2>Global RSS Feeds</h2>
<p>These feeds contain all items across the entire site:</p> <p>These feeds contain all items across the entire site:</p>
@ -17,8 +22,16 @@
<p>{{ feed.description }}</p> <p>{{ feed.description }}</p>
<p> <p>
<a href="{{ feed.url }}">Subscribe to {{ feed.title }} RSS Feed</a> <a href="{{ feed.url }}">Subscribe to {{ feed.title }} RSS Feed</a>
{% if feed.atom_url %}
&nbsp;|&nbsp;
<a href="{{ feed.atom_url }}">Subscribe to {{ feed.title }} Atom Feed</a>
{% endif %}
</p> </p>
<pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre> <pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre>
{% if feed.example_xml_atom %}
<h4>Atom example</h4>
<pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -35,13 +48,22 @@
<p>{{ feed.description }}</p> <p>{{ feed.description }}</p>
<p> <p>
Endpoint: <code>{{ feed.url }}</code> Endpoint: <code>{{ feed.url }}</code>
{% if feed.atom_url %}&nbsp;|&nbsp; Atom: <code>{{ feed.atom_url }}</code>{% endif %}
</p> </p>
{% if feed.has_sample %} {% if feed.has_sample %}
<p> <p>
<a href="{{ feed.url }}">View a live example</a> <a href="{{ feed.url }}">View a live example</a>
{% if feed.atom_url %}
&nbsp;|&nbsp;
<a href="{{ feed.atom_url }}">View Atom example</a>
{% endif %}
</p> </p>
{% endif %} {% endif %}
<pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre> <pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre>
{% if feed.example_xml_atom %}
<h4>Atom example</h4>
<pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View file

@ -3,6 +3,18 @@
{% block title %} {% block title %}
{{ game.display_name }} {{ game.display_name }}
{% endblock title %} {% endblock title %}
{% block extra_head %}
{% if game %}
<link rel="alternate"
type="application/rss+xml"
title="{{ game.display_name }} campaigns (RSS)"
href="{% url 'twitch:game_campaign_feed' game.twitch_id %}" />
<link rel="alternate"
type="application/atom+xml"
title="{{ game.display_name }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" />
{% endif %}
{% endblock extra_head %}
{% block content %} {% block content %}
<!-- Game Title --> <!-- Game Title -->
<h1> <h1>
@ -13,6 +25,9 @@
<div> <div>
<a href="{% url 'twitch:game_campaign_feed' game.twitch_id %}" <a href="{% url 'twitch:game_campaign_feed' game.twitch_id %}"
title="RSS feed for {{ game.display_name }} campaigns">RSS feed for {{ game.display_name }} campaigns</a> title="RSS feed for {{ game.display_name }} campaigns">RSS feed for {{ game.display_name }} campaigns</a>
&nbsp;|&nbsp;
<a href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}"
title="Atom feed for {{ game.display_name }} campaigns">Atom feed for {{ game.display_name }} campaigns</a>
</div> </div>
<!-- Game image --> <!-- Game image -->
{% if game.box_art_best_url %} {% if game.box_art_best_url %}

View file

@ -3,6 +3,16 @@
{% block title %} {% block title %}
Games - Grid View Games - Grid View
{% endblock title %} {% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="Newly added games (RSS)"
href="{% url 'twitch:game_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" />
{% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
<header> <header>
@ -10,6 +20,8 @@
<div> <div>
<a href="{% url 'twitch:games_list' %}" title="View games as list">[list]</a> <a href="{% url 'twitch:games_list' %}" title="View games as list">[list]</a>
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a> <a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a>
<a href="{% url 'twitch:game_feed_atom' %}"
title="Atom feed for all games">[atom]</a>
<a href="{% url 'twitch:export_games_csv' %}" <a href="{% url 'twitch:export_games_csv' %}"
title="Export all games as CSV">[csv]</a> title="Export all games as CSV">[csv]</a>
<a href="{% url 'twitch:export_games_json' %}" <a href="{% url 'twitch:export_games_json' %}"

View file

@ -2,12 +2,24 @@
{% block title %} {% block title %}
Games - List View Games - List View
{% endblock title %} {% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="Newly added games (RSS)"
href="{% url 'twitch:game_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Newly added games (Atom)"
href="{% url 'twitch:game_feed_atom' %}" />
{% endblock extra_head %}
{% block content %} {% block content %}
<main> <main>
<h1>Games List</h1> <h1>Games List</h1>
<div> <div>
<a href="{% url 'twitch:games_grid' %}" title="View games as grid">[grid]</a> <a href="{% url 'twitch:games_grid' %}" title="View games as grid">[grid]</a>
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a> <a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a>
<a href="{% url 'twitch:game_feed_atom' %}"
title="Atom feed for all games">[atom]</a>
<a href="{% url 'twitch:export_games_csv' %}" <a href="{% url 'twitch:export_games_csv' %}"
title="Export all games as CSV">[csv]</a> title="Export all games as CSV">[csv]</a>
<a href="{% url 'twitch:export_games_json' %}" <a href="{% url 'twitch:export_games_json' %}"

View file

@ -9,6 +9,8 @@
<a href="{% url 'twitch:organization_feed' %}" <a href="{% url 'twitch:organization_feed' %}"
style="margin-right: 1rem" style="margin-right: 1rem"
title="RSS feed for all organizations">RSS feed for organizations</a> title="RSS feed for all organizations">RSS feed for organizations</a>
<a href="{% url 'twitch:organization_feed_atom' %}"
title="Atom feed for all organizations">[atom]</a>
</div> </div>
<!-- Export Options --> <!-- Export Options -->
<div style="margin-bottom: 1rem; display: flex; gap: 1rem;"> <div style="margin-bottom: 1rem; display: flex; gap: 1rem;">

View file

@ -2,6 +2,20 @@
{% block title %} {% block title %}
{{ organization.name }} {{ organization.name }}
{% endblock title %} {% endblock title %}
{% block extra_head %}
{% if games %}
{% for game in games %}
<link rel="alternate"
type="application/rss+xml"
title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (RSS)"
href="{% url 'twitch:game_campaign_feed' game.twitch_id %}" />
<link rel="alternate"
type="application/atom+xml"
title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (Atom)"
href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" />
{% endfor %}
{% endif %}
{% endblock extra_head %}
{% block content %} {% block content %}
<h1 id="org-name">{{ organization.name }}</h1> <h1 id="org-name">{{ organization.name }}</h1>
<theader> <theader>

View file

@ -4,6 +4,16 @@
{% block title %} {% block title %}
{{ reward_campaign.name }} {{ reward_campaign.name }}
{% endblock title %} {% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="Reward campaigns (RSS)"
href="{% url 'twitch:reward_campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" />
{% endblock extra_head %}
{% block content %} {% block content %}
<!-- Campaign Title --> <!-- Campaign Title -->
{% if reward_campaign.brand %} {% if reward_campaign.brand %}
@ -24,6 +34,8 @@
<a href="{% url 'twitch:reward_campaign_feed' %}" <a href="{% url 'twitch:reward_campaign_feed' %}"
style="margin-right: 1rem" style="margin-right: 1rem"
title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a> title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a>
<a href="{% url 'twitch:reward_campaign_feed_atom' %}"
title="Atom feed for all reward campaigns">[atom]</a>
</div> </div>
<!-- Campaign Summary --> <!-- Campaign Summary -->
{% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %} {% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %}

View file

@ -3,6 +3,16 @@
{% block title %} {% block title %}
Reward Campaigns - Twitch Drops Tracker Reward Campaigns - Twitch Drops Tracker
{% endblock title %} {% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="Reward campaigns (RSS)"
href="{% url 'twitch:reward_campaign_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Reward campaigns (Atom)"
href="{% url 'twitch:reward_campaign_feed_atom' %}" />
{% endblock extra_head %}
{% block content %} {% block content %}
<h1 id="page-title">Reward Campaigns (Quest Rewards)</h1> <h1 id="page-title">Reward Campaigns (Quest Rewards)</h1>
<p>Browse all available quest reward campaigns</p> <p>Browse all available quest reward campaigns</p>
@ -11,6 +21,8 @@
<a href="{% url 'twitch:reward_campaign_feed' %}" <a href="{% url 'twitch:reward_campaign_feed' %}"
style="margin-right: 1rem" style="margin-right: 1rem"
title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a> title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a>
<a href="{% url 'twitch:reward_campaign_feed_atom' %}"
title="Atom feed for all reward campaigns">[atom]</a>
</div> </div>
<!-- Filter Form --> <!-- Filter Form -->
<form id="filter-form" <form id="filter-form"

View file

@ -1,8 +1,38 @@
import io
import logging
from typing import TYPE_CHECKING
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.fields.files import FieldFile
if TYPE_CHECKING:
from collections.abc import Callable
class TwitchConfig(AppConfig): class TwitchConfig(AppConfig):
"""AppConfig subclass for the 'twitch' application.""" """Django app configuration for the Twitch app."""
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "twitch" name = "twitch"
def ready(self) -> None: # noqa: D102
logger: logging.Logger = logging.getLogger("ttvdrops.apps")
# Patch FieldFile.open to swallow FileNotFoundError and provide
# an empty in-memory file-like object so image dimension
# calculations don't crash when the on-disk file was removed.
try:
orig_open: Callable[..., FieldFile] = FieldFile.open
def _safe_open(self: FieldFile, mode: str = "rb") -> FieldFile:
try:
return orig_open(self, mode)
except FileNotFoundError:
# Provide an empty BytesIO so subsequent dimension checks
# read harmlessly and return (None, None).
self._file = io.BytesIO(b"") # pyright: ignore[reportAttributeAccessIssue]
return self
FieldFile.open = _safe_open
except (AttributeError, TypeError) as exc:
logger.debug("Failed to patch FieldFile.open: %s", exc)

View file

@ -920,3 +920,34 @@ class RewardCampaignFeed(Feed):
def item_author_name(self, item: RewardCampaign) -> str: def item_author_name(self, item: RewardCampaign) -> str:
"""Return the author name for the reward campaign.""" """Return the author name for the reward campaign."""
return item.get_feed_author_name() return item.get_feed_author_name()
# Atom feed variants: reuse existing logic but switch the feed generator to Atom
class OrganizationAtomFeed(OrganizationRSSFeed):
"""Atom feed for latest organizations (reuses OrganizationRSSFeed)."""
feed_type = feedgenerator.Atom1Feed
class GameAtomFeed(GameFeed):
"""Atom feed for newly added games (reuses GameFeed)."""
feed_type = feedgenerator.Atom1Feed
class DropCampaignAtomFeed(DropCampaignFeed):
"""Atom feed for latest drop campaigns (reuses DropCampaignFeed)."""
feed_type = feedgenerator.Atom1Feed
class GameCampaignAtomFeed(GameCampaignFeed):
"""Atom feed for latest drop campaigns for a specific game (reuses GameCampaignFeed)."""
feed_type = feedgenerator.Atom1Feed
class RewardCampaignAtomFeed(RewardCampaignFeed):
"""Atom feed for latest reward campaigns (reuses RewardCampaignFeed)."""
feed_type = feedgenerator.Atom1Feed

View file

@ -1,9 +1,10 @@
"""Custom template tags for rendering responsive images with modern formats.""" import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import urlparse from urllib.parse import urlparse
from django import template from django import template
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
@ -11,6 +12,7 @@ if TYPE_CHECKING:
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
register = template.Library() register = template.Library()
logger = logging.getLogger("ttvdrops.image_tags")
def get_format_url(image_url: str, fmt: str) -> str: def get_format_url(image_url: str, fmt: str) -> str:
@ -70,6 +72,18 @@ def picture( # noqa: PLR0913, PLR0917
if not src: if not src:
return SafeString("") return SafeString("")
# If the src points to a local MEDIA file but the underlying file is
# missing on disk, replace with a small static fallback to avoid
# raising FileNotFoundError during template rendering.
try:
media_url: str = settings.MEDIA_URL or "/media/"
if src.startswith(media_url):
name: str = src[len(media_url) :].lstrip("/")
if not default_storage.exists(name):
src = "/static/404.svg"
except (ValueError, OSError) as exc:
logger.debug("Error while resolving media file %s: %s", src, exc)
# For Twitch CDN URLs, skip format conversion and use simple img tag # For Twitch CDN URLs, skip format conversion and use simple img tag
if "static-cdn.jtvnw.net" in src: if "static-cdn.jtvnw.net" in src:
return format_html( return format_html(

View file

@ -95,6 +95,32 @@ class RSSFeedTestCase(TestCase):
assert 'length="42"' in content assert 'length="42"' in content
assert 'type="image/png"' in content assert 'type="image/png"' in content
def test_organization_atom_feed(self) -> None:
"""Test organization Atom feed returns 200 and Atom XML."""
url: str = reverse("twitch:organization_feed_atom")
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/atom+xml; charset=utf-8"
content: str = response.content.decode("utf-8")
assert "<feed" in content
assert "<entry" in content or "<entry" in content
def test_game_atom_feed(self) -> None:
"""Test game Atom feed returns 200 and contains expected content."""
url: str = reverse("twitch:game_feed_atom")
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/atom+xml; charset=utf-8"
content: str = response.content.decode("utf-8")
assert "Owned by Test Organization." in content
expected_atom_link: str = reverse(
"twitch:game_campaign_feed",
args=[self.game.twitch_id],
)
assert expected_atom_link in content
# Atom should include box art URL somewhere in content
assert "https://example.com/box.png" in content
def test_game_feed_enclosure_helpers(self) -> None: def test_game_feed_enclosure_helpers(self) -> None:
"""Helper methods should return values from model fields.""" """Helper methods should return values from model fields."""
feed = GameFeed() feed = GameFeed()

View file

@ -1,13 +1,43 @@
"""Tests for custom image template tags."""
from django.template import Context from django.template import Context
from django.template import Template from django.template import Template
from django.test import SimpleTestCase
from django.test import override_settings
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from twitch.models import Game
from twitch.templatetags.image_tags import get_format_url from twitch.templatetags.image_tags import get_format_url
from twitch.templatetags.image_tags import picture from twitch.templatetags.image_tags import picture
@override_settings(MEDIA_URL="/media/", STATIC_URL="/static/")
class ImageTagsTests(SimpleTestCase):
"""Tests for image template tags and related functionality."""
def test_picture_empty_src_returns_empty(self) -> None:
"""Test that picture tag with empty src returns empty string."""
result = picture("")
assert not str(result)
def test_picture_keeps_external_url(self) -> None:
"""Test that picture tag does not modify external URLs and does not attempt format conversion."""
src = "https://example.com/images/sample.png"
result: SafeString = picture(src, alt="alt", width=16, height=16)
rendered = str(result)
# Should still contain the original external URL
assert src in rendered
def test_model_init_with_missing_image_does_not_raise(self) -> None:
"""Test that initializing a model with a missing image file does not raise an error."""
# Simulate a Game instance with a missing local image file. The
# AppConfig.ready() wrapper should prevent FileNotFoundError during
# model initialization.
g = Game(twitch_id="test-game", box_art_file="campaigns/images/missing.png")
# If initialization reached this point without raising, we consider
# the protection successful.
assert g.twitch_id == "test-game"
class TestGetFormatUrl: class TestGetFormatUrl:
"""Tests for the get_format_url helper function.""" """Tests for the get_format_url helper function."""

View file

@ -3,10 +3,15 @@ from typing import TYPE_CHECKING
from django.urls import path from django.urls import path
from twitch import views from twitch import views
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignFeed from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignFeed from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameFeed from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationRSSFeed from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignFeed from twitch.feeds import RewardCampaignFeed
if TYPE_CHECKING: if TYPE_CHECKING:
@ -105,4 +110,22 @@ urlpatterns: list[URLPattern] = [
RewardCampaignFeed(), RewardCampaignFeed(),
name="reward_campaign_feed", name="reward_campaign_feed",
), ),
# Atom feeds (added alongside RSS to preserve backward compatibility)
path("atom/campaigns/", DropCampaignAtomFeed(), name="campaign_feed_atom"),
path("atom/games/", GameAtomFeed(), name="game_feed_atom"),
path(
"atom/games/<str:twitch_id>/campaigns/",
GameCampaignAtomFeed(),
name="game_campaign_feed_atom",
),
path(
"atom/organizations/",
OrganizationAtomFeed(),
name="organization_feed_atom",
),
path(
"atom/reward-campaigns/",
RewardCampaignAtomFeed(),
name="reward_campaign_feed_atom",
),
] ]

View file

@ -38,10 +38,15 @@ from pygments import highlight
from pygments.formatters import HtmlFormatter from pygments.formatters import HtmlFormatter
from pygments.lexers.data import JsonLexer from pygments.lexers.data import JsonLexer
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignFeed from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignFeed from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameFeed from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationRSSFeed from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignFeed from twitch.feeds import RewardCampaignFeed
from twitch.models import Channel from twitch.models import Channel
from twitch.models import ChatBadge from twitch.models import ChatBadge
@ -1808,30 +1813,46 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
) )
return "" return ""
show_atom: bool = bool(request.GET.get("show_atom"))
feeds: list[dict[str, str]] = [ feeds: list[dict[str, str]] = [
{ {
"title": "All Organizations", "title": "All Organizations",
"description": "Latest organizations added to TTVDrops", "description": "Latest organizations added to TTVDrops",
"url": absolute(reverse("twitch:organization_feed")), "url": absolute(reverse("twitch:organization_feed")),
"atom_url": absolute(reverse("twitch:organization_feed_atom")),
"example_xml": render_feed(OrganizationRSSFeed()), "example_xml": render_feed(OrganizationRSSFeed()),
"example_xml_atom": render_feed(OrganizationAtomFeed())
if show_atom
else "",
}, },
{ {
"title": "All Games", "title": "All Games",
"description": "Latest games added to TTVDrops", "description": "Latest games added to TTVDrops",
"url": absolute(reverse("twitch:game_feed")), "url": absolute(reverse("twitch:game_feed")),
"atom_url": absolute(reverse("twitch:game_feed_atom")),
"example_xml": render_feed(GameFeed()), "example_xml": render_feed(GameFeed()),
"example_xml_atom": render_feed(GameAtomFeed()) if show_atom else "",
}, },
{ {
"title": "All Drop Campaigns", "title": "All Drop Campaigns",
"description": "Latest drop campaigns across all games", "description": "Latest drop campaigns across all games",
"url": absolute(reverse("twitch:campaign_feed")), "url": absolute(reverse("twitch:campaign_feed")),
"atom_url": absolute(reverse("twitch:campaign_feed_atom")),
"example_xml": render_feed(DropCampaignFeed()), "example_xml": render_feed(DropCampaignFeed()),
"example_xml_atom": render_feed(DropCampaignAtomFeed())
if show_atom
else "",
}, },
{ {
"title": "All Reward Campaigns", "title": "All Reward Campaigns",
"description": "Latest reward campaigns (Quest rewards) on Twitch", "description": "Latest reward campaigns (Quest rewards) on Twitch",
"url": absolute(reverse("twitch:reward_campaign_feed")), "url": absolute(reverse("twitch:reward_campaign_feed")),
"atom_url": absolute(reverse("twitch:reward_campaign_feed_atom")),
"example_xml": render_feed(RewardCampaignFeed()), "example_xml": render_feed(RewardCampaignFeed()),
"example_xml_atom": render_feed(RewardCampaignAtomFeed())
if show_atom
else "",
}, },
] ]
@ -1851,10 +1872,25 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
if sample_game if sample_game
else absolute("/rss/games/<game_id>/campaigns/") else absolute("/rss/games/<game_id>/campaigns/")
), ),
"atom_url": (
absolute(
reverse(
"twitch:game_campaign_feed_atom",
args=[sample_game.twitch_id],
),
)
if sample_game
else absolute("/atom/games/<game_id>/campaigns/")
),
"has_sample": bool(sample_game), "has_sample": bool(sample_game),
"example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id) "example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id)
if sample_game if sample_game
else "", else "",
"example_xml_atom": (
render_feed(GameCampaignAtomFeed(), sample_game.twitch_id)
if sample_game and show_atom
else ""
),
}, },
] ]