Enhance RSS feed documentation with example XML and filtered feeds

This commit is contained in:
Joakim Hellsén 2026-02-09 17:27:13 +01:00
commit 2f9c5a9328
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
6 changed files with 130 additions and 72 deletions

View file

@ -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("<p>New Twitch organization added to TTVDrops:</p>"),
SafeText(
f"<p><a href='{reverse('twitch:organization_detail', args=[twitch_id])}' target='_blank' rel='noopener noreferrer'>{name}</a></p>", # 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")

View file

@ -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(
"<p>New Twitch organization added to TTVDrops:</p>\n"
'<p><a href="{}" target="_blank" rel="noopener noreferrer">{}</a></p>',
url,
name,
)
# MARK: Game
class Game(models.Model):

View file

@ -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 '<code class="language-xml">' in html

View file

@ -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/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"),
path("rss/organizations/", OrganizationFeed(), name="organization_feed"),
path("rss/organizations/", OrganizationRSSFeed(), name="organization_feed"),
path("rss/organizations/<str:twitch_id>/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/<str:twitch_id>/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/<str:twitch_id>/campaigns/",
OrganizationCampaignFeed(),

View file

@ -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("<item")
if first_item != -1 and max_items == 1:
second_item = trimmed.find("<item", first_item + 5)
if second_item != -1:
end_channel = trimmed.find("</channel>", 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/<game_id>/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/<org_id>/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,
},