From 2f9c5a9328f77c24659715a907394ea99b8921e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Mon, 9 Feb 2026 17:27:13 +0100 Subject: [PATCH] Enhance RSS feed documentation with example XML and filtered feeds --- templates/twitch/docs_rss.html | 43 ++++++++++-------- twitch/feeds.py | 56 ++++++------------------ twitch/models.py | 14 ++++++ twitch/tests/test_views.py | 4 ++ twitch/urls.py | 6 +-- twitch/views.py | 79 ++++++++++++++++++++++++++++++---- 6 files changed, 130 insertions(+), 72 deletions(-) diff --git a/templates/twitch/docs_rss.html b/templates/twitch/docs_rss.html index df336ab..d6f8f64 100644 --- a/templates/twitch/docs_rss.html +++ b/templates/twitch/docs_rss.html @@ -18,6 +18,10 @@

Subscribe to {{ feed.title }} RSS Feed

+
+ Example XML +
{{ feed.example_xml|escape }}
+
{% endfor %} @@ -25,26 +29,31 @@

Filtered RSS Feeds

- You can also subscribe to RSS feeds for specific games or organizations. These feeds are available on each game or organization detail page. + You can subscribe to RSS feeds scoped to a specific game or organization. When available, links below point to live examples; otherwise use the endpoint template.

-

Game-Specific Campaign Feeds

+

- Subscribe to campaigns for a specific game using: /rss/games/<game_id>/campaigns/ + Versioned paths under /rss/v1/ are available and return the same XML structure.

- {% if sample_game %} -

- Example: {{ sample_game.display_name }} Campaigns RSS Feed -

- {% endif %} -

Organization-Specific Campaign Feeds

-

- Subscribe to campaigns for a specific organization using: /rss/organizations/<org_id>/campaigns/ -

- {% if sample_org %} -

- Example: {{ sample_org.name }} Campaigns RSS Feed -

- {% endif %}

How to Use RSS Feeds

diff --git a/twitch/feeds.py b/twitch/feeds.py index 348f42d..22375e8 100644 --- a/twitch/feeds.py +++ b/twitch/feeds.py @@ -9,6 +9,7 @@ from django.contrib.humanize.templatetags.humanize import naturaltime from django.contrib.syndication.views import Feed from django.db.models.query import QuerySet from django.urls import reverse +from django.utils import feedgenerator from django.utils import timezone from django.utils.html import format_html from django.utils.html import format_html_join @@ -283,55 +284,33 @@ def _construct_drops_summary(drops_data: list[dict]) -> SafeText: # MARK: /rss/organizations/ -class OrganizationFeed(Feed): +class OrganizationRSSFeed(Feed): """RSS feed for latest organizations.""" + # Spec: https://cyber.harvard.edu/rss/rss.html + feed_type = feedgenerator.Rss201rev2Feed title: str = "TTVDrops Organizations" link: str = "/organizations/" description: str = "Latest organizations on TTVDrops" feed_copyright: str = "Information wants to be free." - def items(self) -> list[Organization]: + def items(self) -> QuerySet[Organization, Organization]: """Return the latest 200 organizations.""" - return list(Organization.objects.order_by("-added_at")[:200]) + return Organization.objects.order_by("-added_at")[:200] - def item_title(self, item: Model) -> SafeText: + def item_title(self, item: Organization) -> SafeText: """Return the organization name as the item title.""" - if not isinstance(item, Organization): - logger.error("item_title called with non-Organization item: %s", type(item)) - return SafeText("New Twitch organization added") - return SafeText(getattr(item, "name", str(item))) - def item_description(self, item: Model) -> SafeText: + def item_description(self, item: Organization) -> SafeText: """Return a description of the organization.""" - if not isinstance(item, Organization): - logger.error("item_description called with non-Organization item: %s", type(item)) - return SafeText("No description available.") + return SafeText(item.feed_description) - description_parts: list[SafeText] = [] - - name: str = getattr(item, "name", "") - twitch_id: str = getattr(item, "twitch_id", "") - - # Link to ttvdrops organization page - description_parts.extend(( - SafeText("

New Twitch organization added to TTVDrops:

"), - SafeText( - f"

{name}

", # noqa: E501 - ), - )) - return SafeText("".join(str(part) for part in description_parts)) - - def item_link(self, item: Model) -> str: + def item_link(self, item: Organization) -> str: """Return the link to the organization detail.""" - if not isinstance(item, Organization): - logger.error("item_link called with non-Organization item: %s", type(item)) - return reverse("twitch:dashboard") - return reverse("twitch:organization_detail", args=[item.twitch_id]) - def item_pubdate(self, item: Model) -> datetime.datetime: + def item_pubdate(self, item: Organization) -> datetime.datetime: """Returns the publication date to the feed item. Fallback to added_at or now if missing. @@ -341,24 +320,15 @@ class OrganizationFeed(Feed): return added_at return timezone.now() - def item_updateddate(self, item: Model) -> datetime.datetime: + def item_updateddate(self, item: Organization) -> datetime.datetime: """Returns the organization's last update time.""" updated_at: datetime.datetime | None = getattr(item, "updated_at", None) if updated_at: return updated_at return timezone.now() - def item_guid(self, item: Model) -> str: - """Return a unique identifier for each organization.""" - twitch_id: str = getattr(item, "twitch_id", "unknown") - return twitch_id + "@ttvdrops.com" - - def item_author_name(self, item: Model) -> str: + def item_author_name(self, item: Organization) -> str: """Return the author name for the organization.""" - if not isinstance(item, Organization): - logger.error("item_author_name called with non-Organization item: %s", type(item)) - return "Twitch" - return getattr(item, "name", "Twitch") diff --git a/twitch/models.py b/twitch/models.py index c1ed572..9373017 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -6,10 +6,12 @@ from typing import TYPE_CHECKING from django.db import models from django.urls import reverse from django.utils import timezone +from django.utils.html import format_html if TYPE_CHECKING: import datetime + logger: logging.Logger = logging.getLogger("ttvdrops") @@ -55,6 +57,18 @@ class Organization(models.Model): """Return a string representation of the organization.""" return self.name or self.twitch_id + def feed_description(self: Organization) -> str: + """Return a description of the organization for RSS feeds.""" + name: str = self.name or "Unknown Organization" + url: str = reverse("twitch:organization_detail", args=[self.twitch_id]) + + return format_html( + "

New Twitch organization added to TTVDrops:

\n" + '

{}

', + url, + name, + ) + # MARK: Game class Game(models.Model): diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 08681f2..7608946 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -573,3 +573,7 @@ class TestChannelListView: response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:docs_rss")) assert response.status_code == 200 assert "feeds" in response.context + assert "filtered_feeds" in response.context + assert response.context["feeds"][0]["example_xml"] + html: str = response.content.decode() + assert '' in html diff --git a/twitch/urls.py b/twitch/urls.py index e096132..ef96467 100644 --- a/twitch/urls.py +++ b/twitch/urls.py @@ -9,7 +9,7 @@ from twitch.feeds import DropCampaignFeed from twitch.feeds import GameCampaignFeed from twitch.feeds import GameFeed from twitch.feeds import OrganizationCampaignFeed -from twitch.feeds import OrganizationFeed +from twitch.feeds import OrganizationRSSFeed from twitch.feeds import RewardCampaignFeed if TYPE_CHECKING: @@ -24,7 +24,7 @@ rss_feeds_latest: list[URLPattern] = [ path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"), path("rss/games/", GameFeed(), name="game_feed"), path("rss/games//campaigns/", GameCampaignFeed(), name="game_campaign_feed"), - path("rss/organizations/", OrganizationFeed(), name="organization_feed"), + path("rss/organizations/", OrganizationRSSFeed(), name="organization_feed"), path("rss/organizations//campaigns/", OrganizationCampaignFeed(), name="organization_campaign_feed"), path("rss/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed"), ] @@ -33,7 +33,7 @@ v1_rss_feeds: list[URLPattern] = [ path("rss/v1/campaigns/", DropCampaignFeed(), name="campaign_feed_v1"), path("rss/v1/games/", GameFeed(), name="game_feed_v1"), path("rss/v1/games//campaigns/", GameCampaignFeed(), name="game_campaign_feed_v1"), - path("rss/v1/organizations/", OrganizationFeed(), name="organization_feed_v1"), + path("rss/v1/organizations/", OrganizationRSSFeed(), name="organization_feed_v1"), path( "rss/v1/organizations//campaigns/", OrganizationCampaignFeed(), diff --git a/twitch/views.py b/twitch/views.py index 07024c4..2ec8113 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -21,11 +21,11 @@ from django.db.models import Prefetch from django.db.models import Q from django.db.models import Subquery from django.db.models.functions import Trim -from django.db.models.query import QuerySet from django.http import Http404 from django.http import HttpRequest from django.http import HttpResponse from django.shortcuts import render +from django.urls import reverse from django.utils import timezone from django.views.generic import DetailView from django.views.generic import ListView @@ -33,6 +33,12 @@ from pygments import highlight from pygments.formatters import HtmlFormatter from pygments.lexers.data import JsonLexer +from twitch.feeds import DropCampaignFeed +from twitch.feeds import GameCampaignFeed +from twitch.feeds import GameFeed +from twitch.feeds import OrganizationCampaignFeed +from twitch.feeds import OrganizationRSSFeed +from twitch.feeds import RewardCampaignFeed from twitch.models import Channel from twitch.models import ChatBadge from twitch.models import ChatBadgeSet @@ -44,9 +50,9 @@ from twitch.models import RewardCampaign from twitch.models import TimeBasedDrop if TYPE_CHECKING: - from django.db.models import QuerySet - from django.http import HttpRequest - from django.http import HttpResponse + from collections.abc import Callable + + from django.db.models.query import QuerySet logger: logging.Logger = logging.getLogger("ttvdrops.views") @@ -507,6 +513,7 @@ class GamesGridView(ListView): return ( super() .get_queryset() + .prefetch_related("owners") .annotate( campaign_count=Count("drop_campaigns", distinct=True), active_count=Count( @@ -1009,38 +1016,92 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse: Returns: Rendered HTML response with list of RSS feeds. """ + + def _pretty_example(xml_str: str, max_items: int = 1) -> str: + try: + trimmed = xml_str.strip() + first_item = trimmed.find("", second_item) + if end_channel != -1: + trimmed = trimmed[:second_item] + trimmed[end_channel:] + formatted = trimmed.replace("><", ">\n<") + return "\n".join(line for line in formatted.splitlines() if line.strip()) + except Exception: # pragma: no cover - defensive formatting for docs only + logger.exception("Failed to pretty-print RSS example") + return xml_str + + def render_feed(feed_view: Callable[..., HttpResponse], *args: object) -> str: + try: + response: HttpResponse = feed_view(request, *args) + return _pretty_example(response.content.decode("utf-8")) + except Exception: # pragma: no cover - defensive logging for docs only + logger.exception("Failed to render %s for RSS docs", feed_view.__class__.__name__) + return "" + feeds: list[dict[str, str]] = [ { "title": "All Organizations", "description": "Latest organizations added to TTVDrops", - "url": "/rss/organizations/", + "url": reverse("twitch:organization_feed"), + "example_xml": render_feed(OrganizationRSSFeed()), }, { "title": "All Games", "description": "Latest games added to TTVDrops", - "url": "/rss/games/", + "url": reverse("twitch:game_feed"), + "example_xml": render_feed(GameFeed()), }, { "title": "All Drop Campaigns", "description": "Latest drop campaigns across all games", - "url": "/rss/campaigns/", + "url": reverse("twitch:campaign_feed"), + "example_xml": render_feed(DropCampaignFeed()), }, { "title": "All Reward Campaigns", "description": "Latest reward campaigns (Quest rewards) on Twitch", - "url": "/rss/reward-campaigns/", + "url": reverse("twitch:reward_campaign_feed"), + "example_xml": render_feed(RewardCampaignFeed()), }, ] - # Get sample game and organization for examples sample_game: Game | None = Game.objects.first() sample_org: Organization | None = Organization.objects.first() + filtered_feeds: list[dict[str, str | bool]] = [ + { + "title": "Campaigns for a Single Game", + "description": "Latest drop campaigns for one game.", + "url": ( + reverse("twitch:game_campaign_feed", args=[sample_game.twitch_id]) + if sample_game + else "/rss/games//campaigns/" + ), + "has_sample": bool(sample_game), + "example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id) if sample_game else "", + }, + { + "title": "Campaigns for an Organization", + "description": "Drop campaigns across games owned by one organization.", + "url": ( + reverse("twitch:organization_campaign_feed", args=[sample_org.twitch_id]) + if sample_org + else "/rss/organizations//campaigns/" + ), + "has_sample": bool(sample_org), + "example_xml": render_feed(OrganizationCampaignFeed(), sample_org.twitch_id) if sample_org else "", + }, + ] + return render( request, "twitch/docs_rss.html", { "feeds": feeds, + "filtered_feeds": filtered_feeds, "sample_game": sample_game, "sample_org": sample_org, },