Add stylesheet to RSS/Atom feeds
All checks were successful
Deploy to Server / deploy (push) Successful in 10s

This commit is contained in:
Joakim Hellsén 2026-03-14 01:25:21 +01:00
commit a73fdc4e66
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
4 changed files with 846 additions and 270 deletions

146
static/rss_styles.xslt Normal file
View 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>&lt;!doctype html&gt;&lt;html lang='en'&gt;&lt;head&gt;&lt;meta charset='utf-8' /&gt;&lt;meta name='viewport' content='width=device-width, initial-scale=1' /&gt;&lt;style&gt;html{color-scheme:light dark;}body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;;line-height:1.4;background:Canvas;color:CanvasText;}img{max-width:100%;height:auto;}&lt;/style&gt;&lt;/head&gt;&lt;body&gt;</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>&lt;/body&gt;&lt;/html&gt;</xsl:text>
</xsl:attribute>
</iframe>
</div>
</article>
</xsl:for-each>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

View file

@ -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")

View file

@ -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):

View file

@ -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 "&lt;ul&gt;" in content
assert "&lt;p&gt;&lt;ul&gt;" 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)