From a73fdc4e667fbd1c312f890b3c493503627d7e11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Helle=C5=9Ben?= Date: Sat, 14 Mar 2026 01:25:21 +0100 Subject: [PATCH] Add stylesheet to RSS/Atom feeds --- static/rss_styles.xslt | 146 +++++++++++ twitch/feeds.py | 511 ++++++++++++++++++++++++++++++------- twitch/models.py | 158 +----------- twitch/tests/test_feeds.py | 301 ++++++++++++++++++++-- 4 files changed, 846 insertions(+), 270 deletions(-) create mode 100644 static/rss_styles.xslt diff --git a/static/rss_styles.xslt b/static/rss_styles.xslt new file mode 100644 index 0000000..cd96e40 --- /dev/null +++ b/static/rss_styles.xslt @@ -0,0 +1,146 @@ + + + + + + + + + + + <xsl:choose> + <xsl:when test="rss/channel/title"> + <xsl:value-of select="rss/channel/title" /> + </xsl:when> + <xsl:otherwise> + <xsl:value-of select="atom:feed/atom:title" /> + </xsl:otherwise> + </xsl:choose> + + + + +

+ + + + + + + + +

+

+ + + + + + + + +

+ + +
+

+ + + + +

+

+ +

+
+ +
+
+
+ + +
+
diff --git a/twitch/feeds.py b/twitch/feeds.py index 89f2572..14ee22c 100644 --- a/twitch/feeds.py +++ b/twitch/feeds.py @@ -7,6 +7,8 @@ from django.contrib.humanize.templatetags.humanize import naturaltime from django.contrib.syndication.views import Feed from django.db.models import Prefetch from django.db.models.query import QuerySet +from django.http.request import HttpRequest +from django.templatetags.static import static from django.urls import reverse from django.utils import feedgenerator from django.utils import timezone @@ -34,6 +36,125 @@ if TYPE_CHECKING: from twitch.models import DropBenefit logger: logging.Logger = logging.getLogger("ttvdrops") +RSS_STYLESHEETS: list[str] = [static("rss_styles.xslt")] + + +class BrowserFriendlyRss201rev2Feed(feedgenerator.Rss201rev2Feed): + """RSS 2.0 feed generator with a browser-renderable XML content type.""" + + content_type = "application/xml; charset=utf-8" + + +class BrowserFriendlyAtom1Feed(feedgenerator.Atom1Feed): + """Atom 1.0 feed generator with an explicit browser-friendly content type.""" + + content_type = "application/xml; charset=utf-8" + + +class TTVDropsBaseFeed(Feed): + """Base feed class that keeps XML feeds browser-friendly. + + By default, Django's syndication feed framework serves feeds with a + content type of "application/rss+xml", which causes browsers to + download the feed as a file instead of displaying it. By overriding + the __call__ method to set the Content-Disposition header to "inline", + we can make browsers display the feed content directly, improving the + user experience when visiting feed URLs in a browser. + """ + + feed_type = BrowserFriendlyRss201rev2Feed + feed_copyright: str = "CC0; Information wants to be free." + stylesheets: list[str] = RSS_STYLESHEETS + ttl: int = 1 + _request: HttpRequest | None = None + + def _absolute_url(self, url: str) -> str: + """Build an absolute URL for feed identifiers when request context exists. + + Args: + url (str): Relative or absolute URL to normalize for feed metadata. + + Returns: + str: Absolute URL when request context exists, otherwise the original URL. + """ + if self._request is None: + return url + return self._request.build_absolute_uri(url) + + def _absolute_stylesheet_urls(self, request: HttpRequest) -> list[str]: + """Return stylesheet URLs as absolute HTTP URLs for browser/file compatibility.""" + return [ + href + if href.startswith(("http://", "https://")) + else request.build_absolute_uri(href) + for href in self.stylesheets + ] + + def _inject_atom_stylesheets(self, response: HttpResponse) -> None: + """Inject xml-stylesheet processing instructions for Atom feeds. + + Django emits stylesheet processing instructions for RSS feeds, but not for + Atom feeds. Browsers then show Atom summaries as escaped HTML text. By + injecting stylesheet PIs into Atom XML responses, we can transform Atom in + the browser with the same XSLT used for RSS. + """ + if not self.stylesheets: + return + + encoding: str = response.charset or "utf-8" + content: str = response.content.decode(encoding) + + # Detect Atom payload by XML structure/namespace so this still works even + # when served as application/xml for browser-friendliness. + if "' + for href in self.stylesheets + ) + + if content.startswith("") + if xml_decl_end != -1: + content = ( + f"{content[: xml_decl_end + 2]}{stylesheet_pis}" + f"{content[xml_decl_end + 2 :]}" + ) + else: + content = f"{stylesheet_pis}{content}" + else: + content = f"{stylesheet_pis}{content}" + + response.content = content.encode(encoding) + + def __call__( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> HttpResponse: + """Return feed response with inline content disposition for browser display.""" + original_stylesheets: list[str] = self.stylesheets + self.stylesheets = self._absolute_stylesheet_urls(request) + self._request = request + try: + response: HttpResponse = super().__call__(request, *args, **kwargs) + self._inject_atom_stylesheets(response) + finally: + self.stylesheets = original_stylesheets + self._request = None + response["Content-Disposition"] = "inline" + return response + + +class TTVDropsAtomBaseFeed(TTVDropsBaseFeed): + """Base class for Atom feeds with shared browser-friendly behavior.""" + + feed_type = BrowserFriendlyAtom1Feed def _with_campaign_related(queryset: QuerySet[DropCampaign]) -> QuerySet[DropCampaign]: @@ -241,7 +362,7 @@ def generate_drops_summary_html(item: DropCampaign) -> list[SafeString]: if drops_data: parts.append( format_html( - "

{}

", + "{}", _construct_drops_summary(drops_data, channel_name=channel_name), ), ) @@ -500,14 +621,15 @@ def _construct_drops_summary( # MARK: /rss/organizations/ -class OrganizationRSSFeed(Feed): +class OrganizationRSSFeed(TTVDropsBaseFeed): """RSS feed for latest organizations.""" - feed_type = feedgenerator.Rss201rev2Feed + feed_type = BrowserFriendlyRss201rev2Feed + title: str = "TTVDrops Twitch Organizations" link: str = "/organizations/" description: str = "Latest organizations on TTVDrops" - feed_copyright: str = "Information wants to be free." + _limit: int | None = None def __call__( @@ -572,15 +694,19 @@ class OrganizationRSSFeed(Feed): """Return the author name for the organization.""" return getattr(item, "name", "Twitch") + def feed_url(self) -> str: + """Return the absolute URL for this feed.""" + return reverse("twitch:organization_feed") + # MARK: /rss/games/ -class GameFeed(Feed): +class GameFeed(TTVDropsBaseFeed): """RSS feed for newly added games.""" - title: str = "Games - TTVDrops" + title: str = "TTVDrops Twitch Games" link: str = "/games/" description: str = "Newly added games on TTVDrops" - feed_copyright: str = "Information wants to be free." + _limit: int | None = None def __call__( @@ -676,9 +802,9 @@ class GameFeed(Feed): return timezone.now() def item_guid(self, item: Game) -> str: - """Return a unique identifier for each game.""" + """Return a unique identifier for each game. Use the URL to the game detail page as the GUID.""" twitch_id: str = getattr(item, "twitch_id", "unknown") - return twitch_id + "@ttvdrops.com" + return self._absolute_url(reverse("twitch:game_detail", args=[twitch_id])) def item_author_name(self, item: Game) -> str: """Return the author name for the game, typically the owner organization name.""" @@ -695,38 +821,36 @@ class GameFeed(Feed): return box_art return "" - def item_enclosure_length(self, item: Game) -> int: - """Returns the length of the enclosure. + def item_enclosures(self, item: Game) -> list[feedgenerator.Enclosure]: + """Return a list of enclosures for the game, including the box art if available.""" + image_url: str = getattr(item, "box_art_best_url", "") + if image_url: + try: + size: int | None = getattr(item, "box_art_size_bytes", None) + length: int = int(size) if size is not None else 0 + except TypeError, ValueError: + length = 0 - Prefer the newly-added ``box_art_size_bytes`` field so that the RSS - feed can include an accurate ``length`` attribute. Fall back to 0 if - the value is missing or ``None``. - """ - try: - size = getattr(item, "box_art_size_bytes", None) - return int(size) if size is not None else 0 - except TypeError, ValueError: - return 0 + mime: str = getattr(item, "box_art_mime_type", "") + mime_type: str = mime or "image/jpeg" - def item_enclosure_mime_type(self, item: Game) -> str: - """Returns the MIME type of the enclosure. + return [feedgenerator.Enclosure(image_url, str(length), mime_type)] + return [] - Use the ``box_art_mime_type`` field when available, otherwise fall back - to a generic JPEG string (as was previously hard-coded). - """ - mime: str = getattr(item, "box_art_mime_type", "") - return mime or "image/jpeg" + def feed_url(self) -> str: + """Return the URL to the RSS feed itself.""" + return reverse("twitch:game_feed") # MARK: /rss/campaigns/ -class DropCampaignFeed(Feed): +class DropCampaignFeed(TTVDropsBaseFeed): """RSS feed for latest drop campaigns.""" title: str = "Twitch Drop Campaigns" link: str = "/campaigns/" description: str = "Latest Twitch drop campaigns on TTVDrops" - feed_url: str = "/rss/campaigns/" - feed_copyright: str = "Information wants to be free." + item_guid_is_permalink = True + _limit: int | None = None def __call__( @@ -762,7 +886,8 @@ class DropCampaignFeed(Feed): def item_title(self, item: DropCampaign) -> SafeText: """Return the campaign name as the item title (SafeText for RSS).""" - return SafeText(item.get_feed_title()) + game_name: str = item.game.display_name if item.game else "" + return SafeText(f"{game_name}: {item.clean_name}") def item_description(self, item: DropCampaign) -> SafeText: """Return a description of the campaign.""" @@ -779,14 +904,22 @@ class DropCampaignFeed(Feed): def item_link(self, item: DropCampaign) -> str: """Return the link to the campaign detail.""" - return item.get_feed_link() + return reverse("twitch:campaign_detail", args=[item.twitch_id]) + + def item_guid(self, item: DropCampaign) -> str: + """Return a unique identifier for each campaign. Use the URL to the campaign detail page as the GUID.""" + return self._absolute_url( + reverse("twitch:campaign_detail", args=[item.twitch_id]), + ) def item_pubdate(self, item: DropCampaign) -> datetime.datetime: """Returns the publication date to the feed item. Fallback to updated_at or now if missing. """ - return item.get_feed_pubdate() + if item.added_at: + return item.added_at + return timezone.now() def item_updateddate(self, item: DropCampaign) -> datetime.datetime: """Returns the campaign's last update time.""" @@ -794,39 +927,60 @@ class DropCampaignFeed(Feed): def item_categories(self, item: DropCampaign) -> tuple[str, ...]: """Returns the associated game's name as a category.""" - return item.get_feed_categories() + categories: list[str] = ["twitch", "drops"] - def item_guid(self, item: DropCampaign) -> str: - """Return a unique identifier for each campaign.""" - return item.get_feed_guid() + game: Game | None = item.game + if game: + categories.append(game.get_game_name) + + # Prefer direct game owners, which can be prefetched in feed querysets. + categories.extend(org.name for org in game.owners.all() if org.name) + + return tuple(categories) def item_author_name(self, item: DropCampaign) -> str: """Return the author name for the campaign, typically the game name.""" - return item.get_feed_author_name() + game: Game | None = item.game + if game and game.display_name: + return game.display_name - def item_enclosure_url(self, item: DropCampaign) -> str: - """Returns the URL of the campaign image for enclosure.""" - return item.get_feed_enclosure_url() + return "Twitch" - def item_enclosure_length(self, item: DropCampaign) -> int: - """Returns the length of the enclosure.""" - try: - size: int | None = getattr(item, "image_size_bytes", None) - return int(size) if size is not None else 0 - except TypeError, ValueError: - return 0 + # Enclose + def item_enclosures(self, item: DropCampaign) -> list[feedgenerator.Enclosure]: + """Return a list of enclosures for the drop campaign, if available. - def item_enclosure_mime_type(self, item: DropCampaign) -> str: - """Returns the MIME type of the enclosure.""" - mime: str = getattr(item, "image_mime_type", "") - return mime or "image/jpeg" + Args: + item (DropCampaign): The drop campaign item. + + Returns: + list[feedgenerator.Enclosure]: A list of Enclosure objects if an image URL is + available, otherwise an empty list. + """ + image_url: str = getattr(item, "image_best_url", "") + if image_url: + try: + size: int | None = getattr(item, "image_size_bytes", None) + length: int = int(size) if size is not None else 0 + except TypeError, ValueError: + length = 0 + + mime: str = getattr(item, "image_mime_type", "") + mime_type: str = mime or "image/jpeg" + + return [feedgenerator.Enclosure(image_url, str(length), mime_type)] + return [] + + def feed_url(self) -> str: + """Return the URL to the RSS feed itself.""" + return reverse("twitch:campaign_feed") # MARK: /rss/games//campaigns/ -class GameCampaignFeed(Feed): +class GameCampaignFeed(TTVDropsBaseFeed): """RSS feed for the latest drop campaigns of a specific game.""" - feed_copyright: str = "Information wants to be free." + item_guid_is_permalink = True _limit: int | None = None def __call__( @@ -876,10 +1030,6 @@ class GameCampaignFeed(Feed): """Return a description for the feed.""" return f"Latest drop campaigns for {obj.display_name}" - def feed_url(self, obj: Game) -> str: - """Return the URL to the RSS feed itself.""" - return reverse("twitch:game_campaign_feed", args=[obj.twitch_id]) - def items(self, obj: Game) -> list[DropCampaign]: """Return the latest drop campaigns for this game, ordered by most recent start date (default 200, or limited by ?limit query param).""" limit: int = self._limit if self._limit is not None else 200 @@ -892,7 +1042,8 @@ class GameCampaignFeed(Feed): def item_title(self, item: DropCampaign) -> SafeText: """Return the campaign name as the item title (SafeText for RSS).""" - return SafeText(item.get_feed_title()) + game_name: str = item.game.display_name if item.game else "" + return SafeText(f"{game_name}: {item.clean_name}") def item_description(self, item: DropCampaign) -> SafeText: """Return a description of the campaign.""" @@ -924,43 +1075,82 @@ class GameCampaignFeed(Feed): def item_categories(self, item: DropCampaign) -> tuple[str, ...]: """Returns the associated game's name as a category.""" - return item.get_feed_categories() + categories: list[str] = ["twitch", "drops"] + + game: Game | None = item.game + if game: + categories.append(game.get_game_name) + + # Prefer direct game owners, which can be prefetched in feed querysets. + categories.extend(org.name for org in game.owners.all() if org.name) + + return tuple(categories) def item_guid(self, item: DropCampaign) -> str: """Return a unique identifier for each campaign.""" - return item.get_feed_guid() + return self._absolute_url( + reverse("twitch:campaign_detail", args=[item.twitch_id]), + ) def item_author_name(self, item: DropCampaign) -> str: """Return the author name for the campaign, typically the game name.""" - return item.get_feed_author_name() + game: Game | None = item.game + if game and game.display_name: + return game.display_name - def item_enclosure_url(self, item: DropCampaign) -> str: - """Returns the URL of the campaign image for enclosure.""" - return item.get_feed_enclosure_url() + return "Twitch" - def item_enclosure_length(self, item: DropCampaign) -> int: - """Returns the length of the enclosure.""" - try: - size: int | None = getattr(item, "image_size_bytes", None) - return int(size) if size is not None else 0 - except TypeError, ValueError: - return 0 + def author_name(self, obj: Game) -> str: + """Return the author name for the game, typically the owner organization name.""" + owners_cache: list[Organization] | None = getattr( + obj, + "_prefetched_objects_cache", + {}, + ).get("owners") + if owners_cache: + owner: Organization = owners_cache[0] + if owner.name: + return owner.name - def item_enclosure_mime_type(self, item: DropCampaign) -> str: - """Returns the MIME type of the enclosure.""" - mime: str = getattr(item, "image_mime_type", "") - return mime or "image/jpeg" + return "Twitch" + + def item_enclosures(self, item: DropCampaign) -> list[feedgenerator.Enclosure]: + """Return a list of enclosures for the drop campaign, if available. + + Args: + item (DropCampaign): The drop campaign item. + + Returns: + list[feedgenerator.Enclosure]: A list of Enclosure objects if an image URL is available, otherwise an empty list. + """ + # Use image_best_url as enclosure if available + image_url: str = getattr(item, "image_best_url", "") + if image_url: + try: + size: int | None = getattr(item, "image_size_bytes", None) + length: int = int(size) if size is not None else 0 + except TypeError, ValueError: + length = 0 + + mime: str = getattr(item, "image_mime_type", "") + mime_type: str = mime or "image/jpeg" + + return [feedgenerator.Enclosure(image_url, str(length), mime_type)] + return [] + + def feed_url(self, obj: Game) -> str: + """Return the URL to the RSS feed itself.""" + return reverse("twitch:game_campaign_feed", args=[obj.twitch_id]) # MARK: /rss/reward-campaigns/ -class RewardCampaignFeed(Feed): - """RSS feed for latest reward campaigns (Quest rewards).""" +class RewardCampaignFeed(TTVDropsBaseFeed): + """RSS feed for latest reward campaigns.""" - title: str = "Twitch Reward Campaigns (Quest Rewards)" + title: str = "Twitch Reward Campaigns" link: str = "/campaigns/" - description: str = "Latest Twitch reward campaigns (Quest rewards) on TTVDrops" - feed_url: str = "/rss/reward-campaigns/" - feed_copyright: str = "Information wants to be free." + description: str = "Latest Twitch reward campaigns on TTVDrops" + _limit: int | None = None def __call__( @@ -998,22 +1188,83 @@ class RewardCampaignFeed(Feed): def item_title(self, item: RewardCampaign) -> SafeText: """Return the reward campaign name as the item title.""" - return SafeText(item.get_feed_title()) + if item.brand: + return SafeText(f"{item.brand}: {item.name}") + return SafeText(item.name) def item_description(self, item: RewardCampaign) -> SafeText: """Return a description of the reward campaign.""" - return SafeText(item.get_feed_description()) + parts: list = [] + + if item.summary: + parts.append(format_html("

{}

", item.summary)) + + if item.starts_at or item.ends_at: + start_part = ( + format_html( + "Starts: {} ({})", + item.starts_at.strftime("%Y-%m-%d %H:%M %Z"), + naturaltime(item.starts_at), + ) + if item.starts_at + else "" + ) + end_part = ( + format_html( + "Ends: {} ({})", + item.ends_at.strftime("%Y-%m-%d %H:%M %Z"), + naturaltime(item.ends_at), + ) + if item.ends_at + else "" + ) + if start_part and end_part: + parts.append(format_html("

{}
{}

", start_part, end_part)) + elif start_part: + parts.append(format_html("

{}

", start_part)) + elif end_part: + parts.append(format_html("

{}

", end_part)) + + if item.is_sitewide: + parts.append( + SafeText("

This is a sitewide reward campaign

"), + ) + elif item.game: + parts.append( + format_html( + "

Game: {}

", + item.game.display_name or item.game.name, + ), + ) + + if item.about_url: + parts.append( + format_html('

Learn more

', item.about_url), + ) + + if item.external_url: + parts.append( + format_html('

Redeem reward

', item.external_url), + ) + + return SafeText("".join(str(p) for p in parts)) def item_link(self, item: RewardCampaign) -> str: """Return the link to the reward campaign (external URL or dashboard).""" - return item.get_feed_link() + if item.external_url: + return item.external_url + return reverse("twitch:dashboard") def item_pubdate(self, item: RewardCampaign) -> datetime.datetime: """Returns the publication date to the feed item. Uses starts_at (when the reward starts). Fallback to added_at or now if missing. """ - return item.get_feed_pubdate() + if item.starts_at: + return item.starts_at + if item.added_at: + return item.added_at + return timezone.now() def item_updateddate(self, item: RewardCampaign) -> datetime.datetime: """Returns the reward campaign's last update time.""" @@ -1021,43 +1272,105 @@ class RewardCampaignFeed(Feed): def item_categories(self, item: RewardCampaign) -> tuple[str, ...]: """Returns the associated game's name and brand as categories.""" - return item.get_feed_categories() + categories: list[str] = ["twitch", "rewards", "quests"] + + if item.brand: + categories.append(item.brand) + + if item.game: + categories.append(item.game.get_game_name) + + return tuple(categories) def item_guid(self, item: RewardCampaign) -> str: """Return a unique identifier for each reward campaign.""" - return item.get_feed_guid() + return self._absolute_url( + reverse("twitch:reward_campaign_detail", args=[item.twitch_id]), + ) def item_author_name(self, item: RewardCampaign) -> str: """Return the author name for the reward campaign.""" - return item.get_feed_author_name() + if item.brand: + return item.brand + + if item.game and item.game.display_name: + return item.game.display_name + + return "Twitch" + + def item_enclosures(self, item: RewardCampaign) -> list[feedgenerator.Enclosure]: + """Return a list of enclosures for the reward campaign, if available. + + Args: + item (RewardCampaign): The reward campaign item. + + Returns: + list[feedgenerator.Enclosure]: A list of Enclosure objects if an image URL is available, otherwise an empty list. + """ + # Use image_url as enclosure if available + image_url: str = getattr(item, "image_url", "") + if image_url: + try: + size: int | None = getattr(item, "image_size_bytes", None) + length: int = int(size) if size is not None else 0 + except TypeError, ValueError: + length = 0 + + mime: str = getattr(item, "image_mime_type", "") + mime_type: str = mime or "image/jpeg" + + return [feedgenerator.Enclosure(image_url, str(length), mime_type)] + return [] + + def feed_url(self) -> str: + """Return the URL to the RSS feed itself.""" + return reverse("twitch:reward_campaign_feed") # Atom feed variants: reuse existing logic but switch the feed generator to Atom -class OrganizationAtomFeed(OrganizationRSSFeed): +class OrganizationAtomFeed(TTVDropsAtomBaseFeed, OrganizationRSSFeed): """Atom feed for latest organizations (reuses OrganizationRSSFeed).""" - feed_type = feedgenerator.Atom1Feed + subtitle: str = OrganizationRSSFeed.description + + def feed_url(self) -> str: + """Return the URL to the Atom feed itself.""" + return reverse("twitch:organization_feed_atom") -class GameAtomFeed(GameFeed): +class GameAtomFeed(TTVDropsAtomBaseFeed, GameFeed): """Atom feed for newly added games (reuses GameFeed).""" - feed_type = feedgenerator.Atom1Feed + subtitle: str = GameFeed.description + + def feed_url(self) -> str: + """Return the URL to the Atom feed itself.""" + return reverse("twitch:game_feed_atom") -class DropCampaignAtomFeed(DropCampaignFeed): +class DropCampaignAtomFeed(TTVDropsAtomBaseFeed, DropCampaignFeed): """Atom feed for latest drop campaigns (reuses DropCampaignFeed).""" - feed_type = feedgenerator.Atom1Feed + subtitle: str = DropCampaignFeed.description + + def feed_url(self) -> str: + """Return the URL to the Atom feed itself.""" + return reverse("twitch:campaign_feed_atom") -class GameCampaignAtomFeed(GameCampaignFeed): +class GameCampaignAtomFeed(TTVDropsAtomBaseFeed, GameCampaignFeed): """Atom feed for latest drop campaigns for a specific game (reuses GameCampaignFeed).""" - feed_type = feedgenerator.Atom1Feed + def feed_url(self, obj: Game) -> str: + """Return the URL to the Atom feed itself.""" + return reverse("twitch:game_campaign_feed_atom", args=[obj.twitch_id]) -class RewardCampaignAtomFeed(RewardCampaignFeed): +class RewardCampaignAtomFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed): """Atom feed for latest reward campaigns (reuses RewardCampaignFeed).""" - feed_type = feedgenerator.Atom1Feed + subtitle: str = RewardCampaignFeed.description + + def feed_url(self) -> str: + """Return the URL to the Atom feed itself.""" + return reverse("twitch:reward_campaign_feed_atom") diff --git a/twitch/models.py b/twitch/models.py index 8d8a1d9..0c2030e 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -2,13 +2,11 @@ import logging from typing import TYPE_CHECKING import auto_prefetch -from django.contrib.humanize.templatetags.humanize import naturaltime from django.contrib.postgres.indexes import GinIndex from django.db import models from django.urls import reverse from django.utils import timezone from django.utils.html import format_html -from django.utils.safestring import SafeText from twitch.utils import normalize_twitch_box_art_url @@ -209,6 +207,11 @@ class Game(auto_prefetch.Model): logger.debug("Failed to resolve Game.box_art_file url: %s", exc) return normalize_twitch_box_art_url(self.box_art or "") + @property + def image_best_url(self) -> str: + """Alias for box_art_best_url to provide a common interface with benefits.""" + return self.box_art_best_url + # MARK: TwitchGame class TwitchGameData(auto_prefetch.Model): @@ -554,51 +557,6 @@ class DropCampaign(auto_prefetch.Model): """Determine if the campaign is subscription only based on its benefits.""" return any(drop.required_subs > 0 for drop in self.time_based_drops.all()) # pyright: ignore[reportAttributeAccessIssue] - def get_feed_title(self) -> str: - """Return the campaign title for RSS feeds.""" - game_name: str = self.game.display_name if self.game else "" - return f"{game_name}: {self.clean_name}" - - def get_feed_link(self) -> str: - """Return the link to the campaign detail.""" - return reverse("twitch:campaign_detail", args=[self.twitch_id]) - - def get_feed_pubdate(self) -> datetime.datetime: - """Return the publication date for the feed item.""" - if self.added_at: - return self.added_at - return timezone.now() - - def get_feed_categories(self) -> tuple[str, ...]: - """Return category tags for the feed item.""" - categories: list[str] = ["twitch", "drops"] - - game: Game | None = self.game - if game: - categories.append(game.get_game_name) - # Add first owner if available - first_owner: Organization | None = game.owners.first() - if first_owner: - categories.extend((str(first_owner.name), str(first_owner.twitch_id))) - - return tuple(categories) - - def get_feed_guid(self) -> str: - """Return a unique identifier for the feed item.""" - return f"{self.twitch_id}@ttvdrops.com" - - def get_feed_author_name(self) -> str: - """Return the author name for the feed item.""" - game: Game | None = self.game - if game and game.display_name: - return game.display_name - - return "Twitch" - - def get_feed_enclosure_url(self) -> str: - """Return the campaign image URL for RSS enclosures.""" - return self.image_best_url - @property def sorted_benefits(self) -> list[DropBenefit]: """Return a sorted list of benefits for the campaign.""" @@ -976,112 +934,6 @@ class RewardCampaign(auto_prefetch.Model): logger.debug("Failed to resolve RewardCampaign.image_file url: %s", exc) return self.image_url or "" - def get_feed_title(self) -> str: - """Return the reward campaign name as the feed item title.""" - if self.brand: - return f"{self.brand}: {self.name}" - return self.name - - def get_feed_description(self) -> str: - """Return HTML description of the reward campaign for RSS feeds.""" - parts: list = [] - - if self.summary: - parts.append(format_html("

{}

", self.summary)) - - if self.starts_at or self.ends_at: - start_part = ( - format_html( - "Starts: {} ({})", - self.starts_at.strftime("%Y-%m-%d %H:%M %Z"), - naturaltime(self.starts_at), - ) - if self.starts_at - else "" - ) - end_part = ( - format_html( - "Ends: {} ({})", - self.ends_at.strftime("%Y-%m-%d %H:%M %Z"), - naturaltime(self.ends_at), - ) - if self.ends_at - else "" - ) - if start_part and end_part: - parts.append(format_html("

{}
{}

", start_part, end_part)) - elif start_part: - parts.append(format_html("

{}

", start_part)) - elif end_part: - parts.append(format_html("

{}

", end_part)) - - if self.is_sitewide: - parts.append( - SafeText("

This is a sitewide reward campaign

"), - ) - elif self.game: - parts.append( - format_html( - "

Game: {}

", - self.game.display_name or self.game.name, - ), - ) - - if self.about_url: - parts.append( - format_html('

Learn more

', self.about_url), - ) - - if self.external_url: - parts.append( - format_html('

Redeem reward

', self.external_url), - ) - - return "".join(str(p) for p in parts) - - def get_feed_link(self) -> str: - """Return the link to the reward campaign (external URL or dashboard).""" - if self.external_url: - return self.external_url - return reverse("twitch:dashboard") - - def get_feed_pubdate(self) -> datetime.datetime: - """Return the publication date for the feed item. - - Uses starts_at (when the reward starts). Fallback to added_at or now if missing. - """ - if self.starts_at: - return self.starts_at - if self.added_at: - return self.added_at - return timezone.now() - - def get_feed_categories(self) -> tuple[str, ...]: - """Return category tags for the feed item.""" - categories: list[str] = ["twitch", "rewards", "quests"] - - if self.brand: - categories.append(self.brand) - - if self.game: - categories.append(self.game.get_game_name) - - return tuple(categories) - - def get_feed_guid(self) -> str: - """Return a unique identifier for the feed item.""" - return f"{self.twitch_id}@ttvdrops.com" - - def get_feed_author_name(self) -> str: - """Return the author name for the feed item.""" - if self.brand: - return self.brand - - if self.game and self.game.display_name: - return self.game.display_name - - return "Twitch" - # MARK: ChatBadgeSet class ChatBadgeSet(auto_prefetch.Model): diff --git a/twitch/tests/test_feeds.py b/twitch/tests/test_feeds.py index 44d02cf..03016ff 100644 --- a/twitch/tests/test_feeds.py +++ b/twitch/tests/test_feeds.py @@ -4,6 +4,7 @@ import logging from collections.abc import Callable from contextlib import AbstractContextManager from datetime import timedelta +from pathlib import Path from typing import TYPE_CHECKING import pytest @@ -13,9 +14,13 @@ from django.urls import reverse from django.utils import timezone from hypothesis.extra.django import TestCase +from twitch.feeds import RSS_STYLESHEETS from twitch.feeds import DropCampaignFeed from twitch.feeds import GameCampaignFeed from twitch.feeds import GameFeed +from twitch.feeds import OrganizationRSSFeed +from twitch.feeds import RewardCampaignFeed +from twitch.feeds import TTVDropsBaseFeed from twitch.models import Channel from twitch.models import ChatBadge from twitch.models import ChatBadgeSet @@ -27,11 +32,15 @@ from twitch.models import RewardCampaign from twitch.models import TimeBasedDrop logger: logging.Logger = logging.getLogger(__name__) +STYLESHEET_PATH: Path = ( + Path(__file__).resolve().parents[2] / "static" / "rss_styles.xslt" +) if TYPE_CHECKING: import datetime from django.test.client import _MonkeyPatchedWSGIResponse + from django.utils.feedgenerator import Enclosure from twitch.tests.test_badge_views import Client @@ -78,19 +87,36 @@ class RSSFeedTestCase(TestCase): self.campaign.image_url = "https://example.com/campaign.png" self.campaign.save() + self.reward_campaign: RewardCampaign = RewardCampaign.objects.create( + twitch_id="test-reward-123", + name="Test Reward Campaign", + brand="Test Brand", + starts_at=timezone.now() - timedelta(days=1), + ends_at=timezone.now() + timedelta(days=7), + status="ACTIVE", + summary="Test reward summary", + instructions="Watch and complete objectives", + external_url="https://example.com/reward", + about_url="https://example.com/about", + is_sitewide=False, + game=self.game, + ) + def test_organization_feed(self) -> None: """Test organization feed returns 200.""" url: str = reverse("twitch:organization_feed") response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 - assert response["Content-Type"] == "application/rss+xml; charset=utf-8" + assert response["Content-Type"] == "application/xml; charset=utf-8" + assert response["Content-Disposition"] == "inline" def test_game_feed(self) -> None: """Test game feed returns 200.""" url: str = reverse("twitch:game_feed") response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 - assert response["Content-Type"] == "application/rss+xml; charset=utf-8" + assert response["Content-Type"] == "application/xml; charset=utf-8" + assert response["Content-Disposition"] == "inline" content: str = response.content.decode("utf-8") assert "Owned by Test Organization." in content @@ -101,25 +127,32 @@ class RSSFeedTestCase(TestCase): assert expected_rss_link in content # enclosure metadata from our new fields should be present - assert 'length="42"' in content - assert 'type="image/png"' in content + msg: str = f"Expected enclosure length from image_size_bytes, got: {content}" + assert 'length="42"' in content, msg + + msg = f"Expected enclosure type from image_mime_type, got: {content}" + assert 'type="image/png"' in content, msg 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" + + msg: str = f"Expected 200 OK, got {response.status_code} with content: {response.content.decode('utf-8')}" + assert response.status_code == 200, msg + assert response["Content-Type"] == "application/xml; charset=utf-8" content: str = response.content.decode("utf-8") - assert " 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" + assert response["Content-Type"] == "application/xml; charset=utf-8" content: str = response.content.decode("utf-8") assert "Owned by Test Organization." in content expected_atom_link: str = reverse( @@ -130,24 +163,222 @@ class RSSFeedTestCase(TestCase): # Atom should include box art URL somewhere in content assert "https://example.com/box.png" in content + def test_campaign_atom_feed_uses_url_ids_and_correct_self_link(self) -> None: + """Atom campaign feed should use URL ids and a matching self link.""" + url: str = reverse("twitch:campaign_feed_atom") + response: _MonkeyPatchedWSGIResponse = self.client.get(url) + + assert response.status_code == 200 + content: str = response.content.decode("utf-8") + + msg: str = f"Expected self link in Atom feed, got: {content}" + assert 'rel="self"' in content, msg + + msg: str = f"Expected self link to point to campaign feed URL, got: {content}" + assert 'href="http://testserver/atom/campaigns/"' in content, msg + + msg: str = f"Expected entry ID to be the campaign URL, got: {content}" + assert "http://testserver/campaigns/test-campaign-123/" in content, msg + + def test_all_atom_feeds_use_url_ids_and_correct_self_links(self) -> None: + """All Atom feeds should use absolute URL entry IDs and matching self links.""" + atom_feed_cases: list[tuple[str, dict[str, str], str]] = [ + ( + "twitch:campaign_feed_atom", + {}, + f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", + ), + ( + "twitch:game_feed_atom", + {}, + f"http://testserver{reverse('twitch:game_detail', args=[self.game.twitch_id])}", + ), + ( + "twitch:game_campaign_feed_atom", + {"twitch_id": self.game.twitch_id}, + f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", + ), + ( + "twitch:organization_feed_atom", + {}, + f"http://testserver{reverse('twitch:organization_detail', args=[self.org.twitch_id])}", + ), + ( + "twitch:reward_campaign_feed_atom", + {}, + f"http://testserver{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}", + ), + ] + + for url_name, kwargs, expected_entry_id in atom_feed_cases: + url: str = reverse(url_name, kwargs=kwargs) + response: _MonkeyPatchedWSGIResponse = self.client.get(url) + + assert response.status_code == 200 + content: str = response.content.decode("utf-8") + + expected_self_link: str = f'href="http://testserver{url}"' + msg: str = f"Expected self link in Atom feed {url_name}, got: {content}" + assert 'rel="self"' in content, msg + + msg = f"Expected self link to match feed URL for {url_name}, got: {content}" + assert expected_self_link in content, msg + + msg = f"Expected entry ID to be absolute URL for {url_name}, got: {content}" + assert f"{expected_entry_id}" in content, msg + + def test_campaign_atom_feed_summary_does_not_wrap_list_in_paragraph(self) -> None: + """Atom summary HTML should not include invalid

    nesting.""" + drop: TimeBasedDrop = TimeBasedDrop.objects.create( + twitch_id="atom-drop-1", + name="Atom Drop", + campaign=self.campaign, + required_minutes_watched=15, + start_at=timezone.now(), + end_at=timezone.now() + timedelta(hours=1), + ) + benefit: DropBenefit = DropBenefit.objects.create( + twitch_id="atom-benefit-1", + name="Atom Benefit", + distribution_type="ITEM", + ) + drop.benefits.add(benefit) + + url: str = reverse("twitch:campaign_feed_atom") + response: _MonkeyPatchedWSGIResponse = self.client.get(url) + + assert response.status_code == 200 + content: str = response.content.decode("utf-8") + assert "<ul>" in content + assert "<p><ul>" not in content + + def test_atom_feeds_include_stylesheet_processing_instruction(self) -> None: + """Atom feeds should include an xml-stylesheet processing instruction.""" + feed_urls: list[str] = [ + reverse("twitch:campaign_feed_atom"), + reverse("twitch:game_feed_atom"), + reverse("twitch:game_campaign_feed_atom", args=[self.game.twitch_id]), + reverse("twitch:organization_feed_atom"), + reverse("twitch:reward_campaign_feed_atom"), + ] + + for url in feed_urls: + response: _MonkeyPatchedWSGIResponse = self.client.get(url) + assert response.status_code == 200 + + content: str = response.content.decode("utf-8") + assert " None: """Helper methods should return values from model fields.""" feed = GameFeed() - assert feed.item_enclosure_length(self.game) == 42 - assert feed.item_enclosure_mime_type(self.game) == "image/png" + feed_item_enclosures: list[Enclosure] = feed.item_enclosures(self.game) + + assert feed_item_enclosures, ( + "Expected at least one enclosure for game feed item, got none" + ) + + msg: str = ( + f"Expected one enclosure for game feed item, got: {feed_item_enclosures}" + ) + assert len(feed_item_enclosures) == 1, msg + enclosure: Enclosure = feed_item_enclosures[0] + + msg = f"Expected enclosure URL from box_art, got: {enclosure.url}" + assert enclosure.url == "https://example.com/box.png", msg + + msg = ( + f"Expected enclosure length from image_size_bytes, got: {enclosure.length}" + ) + assert enclosure.length == str(42), msg def test_campaign_feed(self) -> None: """Test campaign feed returns 200.""" url: str = reverse("twitch:campaign_feed") response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 - assert response["Content-Type"] == "application/rss+xml; charset=utf-8" + assert response["Content-Type"] == "application/xml; charset=utf-8" + assert response["Content-Disposition"] == "inline" content: str = response.content.decode("utf-8") # verify enclosure meta assert 'length="314"' in content assert 'type="image/gif"' in content + def test_rss_feeds_include_stylesheet_processing_instruction(self) -> None: + """RSS feeds should include an xml-stylesheet processing instruction.""" + feed_urls: list[str] = [ + reverse("twitch:campaign_feed"), + reverse("twitch:game_feed"), + reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]), + reverse("twitch:organization_feed"), + reverse("twitch:reward_campaign_feed"), + ] + + for url in feed_urls: + response: _MonkeyPatchedWSGIResponse = self.client.get(url) + assert response.status_code == 200 + + content: str = response.content.decode("utf-8") + assert " None: + """RSS feed classes should inherit shared defaults from TTVDropsBaseFeed.""" + msg: str = f"Expected TTVDropsBaseFeed.feed_copyright to be 'CC0; Information wants to be free.', got: {TTVDropsBaseFeed.feed_copyright}" + assert ( + TTVDropsBaseFeed.feed_copyright == "CC0; Information wants to be free." + ), msg + + msg = f"Expected TTVDropsBaseFeed.stylesheets to be {RSS_STYLESHEETS}, got: {TTVDropsBaseFeed.stylesheets}" + assert TTVDropsBaseFeed.stylesheets == RSS_STYLESHEETS, msg + + msg = f"Expected TTVDropsBaseFeed.ttl to be 1, got: {TTVDropsBaseFeed.ttl}" + assert TTVDropsBaseFeed.ttl == 1, msg + + for feed_class in ( + OrganizationRSSFeed, + GameFeed, + DropCampaignFeed, + GameCampaignFeed, + RewardCampaignFeed, + ): + feed: ( + DropCampaignFeed + | GameCampaignFeed + | GameFeed + | OrganizationRSSFeed + | RewardCampaignFeed + ) = feed_class() + assert feed.feed_copyright == "CC0; Information wants to be free." + assert feed.stylesheets == RSS_STYLESHEETS + assert feed.ttl == 1 + + def test_rss_feeds_include_shared_metadata_fields(self) -> None: + """RSS output should contain base feed metadata fields.""" + feed_urls: list[str] = [ + reverse("twitch:campaign_feed"), + reverse("twitch:game_feed"), + reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]), + reverse("twitch:organization_feed"), + reverse("twitch:reward_campaign_feed"), + ] + + for url in feed_urls: + response: _MonkeyPatchedWSGIResponse = self.client.get(url) + assert response.status_code == 200 + + content: str = response.content.decode("utf-8") + assert ( + "CC0; Information wants to be free." in content + ) + assert "1" in content + def test_campaign_feed_only_includes_active_campaigns(self) -> None: """Campaign feed should exclude past and upcoming campaigns.""" now: datetime.datetime = timezone.now() @@ -180,8 +411,25 @@ class RSSFeedTestCase(TestCase): def test_campaign_feed_enclosure_helpers(self) -> None: """Helper methods for campaigns should respect new fields.""" feed = DropCampaignFeed() - assert feed.item_enclosure_length(self.campaign) == 314 - assert feed.item_enclosure_mime_type(self.campaign) == "image/gif" + + item_enclosures: list[Enclosure] = feed.item_enclosures(self.campaign) + assert item_enclosures, ( + "Expected at least one enclosure for campaign feed item, got none" + ) + + msg: str = ( + f"Expected one enclosure for campaign feed item, got: {item_enclosures}" + ) + assert len(item_enclosures) == 1, msg + + msg = f"Expected enclosure URL from campaign image_url, got: {item_enclosures[0].url}" + assert item_enclosures[0].url == "https://example.com/campaign.png", msg + + msg = f"Expected enclosure length from image_size_bytes, got: {item_enclosures[0].length}" + assert item_enclosures[0].length == str(314), msg + + msg = f"Expected enclosure type from image_mime_type, got: {item_enclosures[0].mime_type}" + assert item_enclosures[0].mime_type == "image/gif", msg def test_campaign_feed_includes_badge_description(self) -> None: """Badge benefit descriptions should be visible in the RSS drop summary.""" @@ -221,7 +469,8 @@ class RSSFeedTestCase(TestCase): url: str = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 - assert response["Content-Type"] == "application/rss+xml; charset=utf-8" + assert response["Content-Type"] == "application/xml; charset=utf-8" + assert response["Content-Disposition"] == "inline" # Verify the game name is in the feed content: str = response.content.decode("utf-8") assert "Test Game" in content @@ -346,8 +595,25 @@ class RSSFeedTestCase(TestCase): def test_game_campaign_feed_enclosure_helpers(self) -> None: """GameCampaignFeed helper methods should pull from the model fields.""" feed = GameCampaignFeed() - assert feed.item_enclosure_length(self.campaign) == 314 - assert feed.item_enclosure_mime_type(self.campaign) == "image/gif" + + item_enclosures: list[Enclosure] = feed.item_enclosures(self.campaign) + assert item_enclosures, ( + "Expected at least one enclosure for campaign feed item, got none" + ) + + msg: str = ( + f"Expected one enclosure for campaign feed item, got: {item_enclosures}" + ) + assert len(item_enclosures) == 1, msg + + msg = f"Expected enclosure URL from campaign image_url, got: {item_enclosures[0].url}" + assert item_enclosures[0].url == "https://example.com/campaign.png", msg + + msg = f"Expected enclosure length from image_size_bytes, got: {item_enclosures[0].length}" + assert item_enclosures[0].length == str(314), msg + + msg = f"Expected enclosure type from image_mime_type, got: {item_enclosures[0].mime_type}" + assert item_enclosures[0].mime_type == "image/gif", msg def test_backfill_command_sets_metadata(self) -> None: """Running the backfill command should populate size and mime fields. @@ -596,7 +862,6 @@ def test_game_campaign_feed_queries_bounded( url: str = reverse("twitch:game_campaign_feed", args=[game.twitch_id]) - # TODO(TheLovinator): 15 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: TD003 with django_assert_num_queries(6, exact=False): response: _MonkeyPatchedWSGIResponse = client.get(url)