Add stylesheet to RSS/Atom feeds
All checks were successful
Deploy to Server / deploy (push) Successful in 10s
All checks were successful
Deploy to Server / deploy (push) Successful in 10s
This commit is contained in:
parent
f3bb95cc4f
commit
a73fdc4e66
4 changed files with 846 additions and 270 deletions
146
static/rss_styles.xslt
Normal file
146
static/rss_styles.xslt
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsl:stylesheet version="1.0"
|
||||
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
<xsl:output method="html" encoding="UTF-8" indent="yes" />
|
||||
|
||||
<xsl:template match="/">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>
|
||||
<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>
|
||||
</title>
|
||||
<style>
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
line-height: 1.4;
|
||||
padding: 0;
|
||||
font-size: 115%;
|
||||
max-width: 75%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body {
|
||||
max-width: 95%;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 1rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
border: 1px solid color-mix(in srgb, Canvas 85%, CanvasText 15%);
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.item h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.pubdate {
|
||||
color: color-mix(in srgb, CanvasText 60%, Canvas 40%);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.item-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.item-content-frame {
|
||||
width: 100%;
|
||||
min-height: 24rem;
|
||||
height: 28rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>
|
||||
<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>
|
||||
</h1>
|
||||
<p class="meta">
|
||||
<xsl:choose>
|
||||
<xsl:when test="rss/channel/description">
|
||||
<xsl:value-of select="rss/channel/description" />
|
||||
</xsl:when>
|
||||
<xsl:otherwise>
|
||||
<xsl:value-of select="atom:feed/atom:subtitle" />
|
||||
</xsl:otherwise>
|
||||
</xsl:choose>
|
||||
</p>
|
||||
|
||||
<xsl:for-each select="rss/channel/item | atom:feed/atom:entry">
|
||||
<article class="item">
|
||||
<h2>
|
||||
<a href="{link | atom:link[@rel='alternate'][1]/@href | atom:link[not(@rel)][1]/@href}">
|
||||
<xsl:value-of select="title" />
|
||||
<xsl:value-of select="atom:title" />
|
||||
</a>
|
||||
</h2>
|
||||
<p class="pubdate">
|
||||
<xsl:value-of select="pubDate | atom:published | atom:updated" />
|
||||
</p>
|
||||
<div class="item-content">
|
||||
<iframe class="item-content-frame" loading="lazy" sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin">
|
||||
<xsl:attribute name="srcdoc">
|
||||
<xsl:text><!doctype html><html lang='en'><head><meta charset='utf-8' /><meta name='viewport' content='width=device-width, initial-scale=1' /><style>html{color-scheme:light dark;}body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";line-height:1.4;background:Canvas;color:CanvasText;}img{max-width:100%;height:auto;}</style></head><body></xsl:text>
|
||||
<xsl:choose>
|
||||
<xsl:when test="content:encoded">
|
||||
<xsl:value-of select="content:encoded" />
|
||||
</xsl:when>
|
||||
<xsl:when test="atom:content">
|
||||
<xsl:value-of select="atom:content" />
|
||||
</xsl:when>
|
||||
<xsl:when test="atom:summary">
|
||||
<xsl:value-of select="atom:summary" />
|
||||
</xsl:when>
|
||||
<xsl:otherwise>
|
||||
<xsl:value-of select="description" />
|
||||
</xsl:otherwise>
|
||||
</xsl:choose>
|
||||
<xsl:text></body></html></xsl:text>
|
||||
</xsl:attribute>
|
||||
</iframe>
|
||||
</div>
|
||||
</article>
|
||||
</xsl:for-each>
|
||||
</body>
|
||||
</html>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
511
twitch/feeds.py
511
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 "<feed" not in content or "http://www.w3.org/2005/Atom" not in content:
|
||||
return
|
||||
|
||||
if "<?xml-stylesheet" in content:
|
||||
return
|
||||
|
||||
stylesheet_pis: str = "".join(
|
||||
f'<?xml-stylesheet href="{href}" type="text/xsl" media="screen"?>'
|
||||
for href in self.stylesheets
|
||||
)
|
||||
|
||||
if content.startswith("<?xml"):
|
||||
xml_decl_end: int = content.find("?>")
|
||||
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(
|
||||
"<p>{}</p>",
|
||||
"{}",
|
||||
_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/<twitch_id>/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("<p>{}</p>", 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("<p>{}<br />{}</p>", start_part, end_part))
|
||||
elif start_part:
|
||||
parts.append(format_html("<p>{}</p>", start_part))
|
||||
elif end_part:
|
||||
parts.append(format_html("<p>{}</p>", end_part))
|
||||
|
||||
if item.is_sitewide:
|
||||
parts.append(
|
||||
SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"),
|
||||
)
|
||||
elif item.game:
|
||||
parts.append(
|
||||
format_html(
|
||||
"<p>Game: {}</p>",
|
||||
item.game.display_name or item.game.name,
|
||||
),
|
||||
)
|
||||
|
||||
if item.about_url:
|
||||
parts.append(
|
||||
format_html('<p><a href="{}">Learn more</a></p>', item.about_url),
|
||||
)
|
||||
|
||||
if item.external_url:
|
||||
parts.append(
|
||||
format_html('<p><a href="{}">Redeem reward</a></p>', 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")
|
||||
|
|
|
|||
158
twitch/models.py
158
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("<p>{}</p>", 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("<p>{}<br />{}</p>", start_part, end_part))
|
||||
elif start_part:
|
||||
parts.append(format_html("<p>{}</p>", start_part))
|
||||
elif end_part:
|
||||
parts.append(format_html("<p>{}</p>", end_part))
|
||||
|
||||
if self.is_sitewide:
|
||||
parts.append(
|
||||
SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"),
|
||||
)
|
||||
elif self.game:
|
||||
parts.append(
|
||||
format_html(
|
||||
"<p>Game: {}</p>",
|
||||
self.game.display_name or self.game.name,
|
||||
),
|
||||
)
|
||||
|
||||
if self.about_url:
|
||||
parts.append(
|
||||
format_html('<p><a href="{}">Learn more</a></p>', self.about_url),
|
||||
)
|
||||
|
||||
if self.external_url:
|
||||
parts.append(
|
||||
format_html('<p><a href="{}">Redeem reward</a></p>', 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):
|
||||
|
|
|
|||
|
|
@ -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 "<feed" in content
|
||||
assert "<entry" in content or "<entry" in content
|
||||
assert "<feed" in content, f"Expected Atom feed XML, got: {content}"
|
||||
|
||||
msg = f"Expected entry element in Atom feed, got: {content}"
|
||||
assert "<entry" in content or "<entry" in content, msg
|
||||
|
||||
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"
|
||||
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 "<id>http://testserver/campaigns/test-campaign-123/</id>" 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"<id>{expected_entry_id}</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 <p><ul> 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 "<?xml-stylesheet" in content
|
||||
assert "rss_styles.xslt" in content
|
||||
assert 'type="text/xsl"' in content
|
||||
assert 'media="screen"' in content
|
||||
|
||||
def test_game_feed_enclosure_helpers(self) -> 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 "<?xml-stylesheet" in content
|
||||
assert "rss_styles.xslt" in content
|
||||
assert 'type="text/xsl"' in content
|
||||
assert 'media="screen"' in content
|
||||
|
||||
def test_base_feed_default_metadata_is_inherited(self) -> 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 (
|
||||
"<copyright>CC0; Information wants to be free.</copyright>" in content
|
||||
)
|
||||
assert "<ttl>1</ttl>" 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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue