Add support for Atom feeds

This commit is contained in:
Joakim Hellsén 2026-03-10 07:51:55 +01:00
commit 6c22559fb5
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
16 changed files with 293 additions and 0 deletions

View file

@ -920,3 +920,34 @@ class RewardCampaignFeed(Feed):
def item_author_name(self, item: RewardCampaign) -> str:
"""Return the author name for the reward campaign."""
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

@ -95,6 +95,32 @@ class RSSFeedTestCase(TestCase):
assert 'length="42"' 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:
"""Helper methods should return values from model fields."""
feed = GameFeed()

View file

@ -3,10 +3,15 @@ from typing import TYPE_CHECKING
from django.urls import path
from twitch import views
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignFeed
if TYPE_CHECKING:
@ -105,4 +110,22 @@ urlpatterns: list[URLPattern] = [
RewardCampaignFeed(),
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.lexers.data import JsonLexer
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignFeed
from twitch.models import Channel
from twitch.models import ChatBadge
@ -1808,30 +1813,46 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
)
return ""
show_atom: bool = bool(request.GET.get("show_atom"))
feeds: list[dict[str, str]] = [
{
"title": "All Organizations",
"description": "Latest organizations added to TTVDrops",
"url": absolute(reverse("twitch:organization_feed")),
"atom_url": absolute(reverse("twitch:organization_feed_atom")),
"example_xml": render_feed(OrganizationRSSFeed()),
"example_xml_atom": render_feed(OrganizationAtomFeed())
if show_atom
else "",
},
{
"title": "All Games",
"description": "Latest games added to TTVDrops",
"url": absolute(reverse("twitch:game_feed")),
"atom_url": absolute(reverse("twitch:game_feed_atom")),
"example_xml": render_feed(GameFeed()),
"example_xml_atom": render_feed(GameAtomFeed()) if show_atom else "",
},
{
"title": "All Drop Campaigns",
"description": "Latest drop campaigns across all games",
"url": absolute(reverse("twitch:campaign_feed")),
"atom_url": absolute(reverse("twitch:campaign_feed_atom")),
"example_xml": render_feed(DropCampaignFeed()),
"example_xml_atom": render_feed(DropCampaignAtomFeed())
if show_atom
else "",
},
{
"title": "All Reward Campaigns",
"description": "Latest reward campaigns (Quest rewards) on Twitch",
"url": absolute(reverse("twitch:reward_campaign_feed")),
"atom_url": absolute(reverse("twitch:reward_campaign_feed_atom")),
"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
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),
"example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id)
if sample_game
else "",
"example_xml_atom": (
render_feed(GameCampaignAtomFeed(), sample_game.twitch_id)
if sample_game and show_atom
else ""
),
},
]