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>
|
||||||
493
twitch/feeds.py
493
twitch/feeds.py
|
|
@ -7,6 +7,8 @@ from django.contrib.humanize.templatetags.humanize import naturaltime
|
||||||
from django.contrib.syndication.views import Feed
|
from django.contrib.syndication.views import Feed
|
||||||
from django.db.models import Prefetch
|
from django.db.models import Prefetch
|
||||||
from django.db.models.query import QuerySet
|
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.urls import reverse
|
||||||
from django.utils import feedgenerator
|
from django.utils import feedgenerator
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
@ -34,6 +36,125 @@ if TYPE_CHECKING:
|
||||||
from twitch.models import DropBenefit
|
from twitch.models import DropBenefit
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("ttvdrops")
|
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]:
|
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:
|
if drops_data:
|
||||||
parts.append(
|
parts.append(
|
||||||
format_html(
|
format_html(
|
||||||
"<p>{}</p>",
|
"{}",
|
||||||
_construct_drops_summary(drops_data, channel_name=channel_name),
|
_construct_drops_summary(drops_data, channel_name=channel_name),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -500,14 +621,15 @@ def _construct_drops_summary(
|
||||||
|
|
||||||
|
|
||||||
# MARK: /rss/organizations/
|
# MARK: /rss/organizations/
|
||||||
class OrganizationRSSFeed(Feed):
|
class OrganizationRSSFeed(TTVDropsBaseFeed):
|
||||||
"""RSS feed for latest organizations."""
|
"""RSS feed for latest organizations."""
|
||||||
|
|
||||||
feed_type = feedgenerator.Rss201rev2Feed
|
feed_type = BrowserFriendlyRss201rev2Feed
|
||||||
|
|
||||||
title: str = "TTVDrops Twitch Organizations"
|
title: str = "TTVDrops Twitch Organizations"
|
||||||
link: str = "/organizations/"
|
link: str = "/organizations/"
|
||||||
description: str = "Latest organizations on TTVDrops"
|
description: str = "Latest organizations on TTVDrops"
|
||||||
feed_copyright: str = "Information wants to be free."
|
|
||||||
_limit: int | None = None
|
_limit: int | None = None
|
||||||
|
|
||||||
def __call__(
|
def __call__(
|
||||||
|
|
@ -572,15 +694,19 @@ class OrganizationRSSFeed(Feed):
|
||||||
"""Return the author name for the organization."""
|
"""Return the author name for the organization."""
|
||||||
return getattr(item, "name", "Twitch")
|
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/
|
# MARK: /rss/games/
|
||||||
class GameFeed(Feed):
|
class GameFeed(TTVDropsBaseFeed):
|
||||||
"""RSS feed for newly added games."""
|
"""RSS feed for newly added games."""
|
||||||
|
|
||||||
title: str = "Games - TTVDrops"
|
title: str = "TTVDrops Twitch Games"
|
||||||
link: str = "/games/"
|
link: str = "/games/"
|
||||||
description: str = "Newly added games on TTVDrops"
|
description: str = "Newly added games on TTVDrops"
|
||||||
feed_copyright: str = "Information wants to be free."
|
|
||||||
_limit: int | None = None
|
_limit: int | None = None
|
||||||
|
|
||||||
def __call__(
|
def __call__(
|
||||||
|
|
@ -676,9 +802,9 @@ class GameFeed(Feed):
|
||||||
return timezone.now()
|
return timezone.now()
|
||||||
|
|
||||||
def item_guid(self, item: Game) -> str:
|
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")
|
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:
|
def item_author_name(self, item: Game) -> str:
|
||||||
"""Return the author name for the game, typically the owner organization name."""
|
"""Return the author name for the game, typically the owner organization name."""
|
||||||
|
|
@ -695,38 +821,36 @@ class GameFeed(Feed):
|
||||||
return box_art
|
return box_art
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def item_enclosure_length(self, item: Game) -> int:
|
def item_enclosures(self, item: Game) -> list[feedgenerator.Enclosure]:
|
||||||
"""Returns the length of the enclosure.
|
"""Return a list of enclosures for the game, including the box art if available."""
|
||||||
|
image_url: str = getattr(item, "box_art_best_url", "")
|
||||||
Prefer the newly-added ``box_art_size_bytes`` field so that the RSS
|
if image_url:
|
||||||
feed can include an accurate ``length`` attribute. Fall back to 0 if
|
|
||||||
the value is missing or ``None``.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
size = getattr(item, "box_art_size_bytes", None)
|
size: int | None = getattr(item, "box_art_size_bytes", None)
|
||||||
return int(size) if size is not None else 0
|
length: int = int(size) if size is not None else 0
|
||||||
except TypeError, ValueError:
|
except TypeError, ValueError:
|
||||||
return 0
|
length = 0
|
||||||
|
|
||||||
def item_enclosure_mime_type(self, item: Game) -> str:
|
|
||||||
"""Returns the MIME type of the enclosure.
|
|
||||||
|
|
||||||
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", "")
|
mime: str = getattr(item, "box_art_mime_type", "")
|
||||||
return mime or "image/jpeg"
|
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:game_feed")
|
||||||
|
|
||||||
|
|
||||||
# MARK: /rss/campaigns/
|
# MARK: /rss/campaigns/
|
||||||
class DropCampaignFeed(Feed):
|
class DropCampaignFeed(TTVDropsBaseFeed):
|
||||||
"""RSS feed for latest drop campaigns."""
|
"""RSS feed for latest drop campaigns."""
|
||||||
|
|
||||||
title: str = "Twitch Drop Campaigns"
|
title: str = "Twitch Drop Campaigns"
|
||||||
link: str = "/campaigns/"
|
link: str = "/campaigns/"
|
||||||
description: str = "Latest Twitch drop campaigns on TTVDrops"
|
description: str = "Latest Twitch drop campaigns on TTVDrops"
|
||||||
feed_url: str = "/rss/campaigns/"
|
item_guid_is_permalink = True
|
||||||
feed_copyright: str = "Information wants to be free."
|
|
||||||
_limit: int | None = None
|
_limit: int | None = None
|
||||||
|
|
||||||
def __call__(
|
def __call__(
|
||||||
|
|
@ -762,7 +886,8 @@ class DropCampaignFeed(Feed):
|
||||||
|
|
||||||
def item_title(self, item: DropCampaign) -> SafeText:
|
def item_title(self, item: DropCampaign) -> SafeText:
|
||||||
"""Return the campaign name as the item title (SafeText for RSS)."""
|
"""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:
|
def item_description(self, item: DropCampaign) -> SafeText:
|
||||||
"""Return a description of the campaign."""
|
"""Return a description of the campaign."""
|
||||||
|
|
@ -779,14 +904,22 @@ class DropCampaignFeed(Feed):
|
||||||
|
|
||||||
def item_link(self, item: DropCampaign) -> str:
|
def item_link(self, item: DropCampaign) -> str:
|
||||||
"""Return the link to the campaign detail."""
|
"""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:
|
def item_pubdate(self, item: DropCampaign) -> datetime.datetime:
|
||||||
"""Returns the publication date to the feed item.
|
"""Returns the publication date to the feed item.
|
||||||
|
|
||||||
Fallback to updated_at or now if missing.
|
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:
|
def item_updateddate(self, item: DropCampaign) -> datetime.datetime:
|
||||||
"""Returns the campaign's last update time."""
|
"""Returns the campaign's last update time."""
|
||||||
|
|
@ -794,39 +927,60 @@ class DropCampaignFeed(Feed):
|
||||||
|
|
||||||
def item_categories(self, item: DropCampaign) -> tuple[str, ...]:
|
def item_categories(self, item: DropCampaign) -> tuple[str, ...]:
|
||||||
"""Returns the associated game's name as a category."""
|
"""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:
|
game: Game | None = item.game
|
||||||
"""Return a unique identifier for each campaign."""
|
if game:
|
||||||
return item.get_feed_guid()
|
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:
|
def item_author_name(self, item: DropCampaign) -> str:
|
||||||
"""Return the author name for the campaign, typically the game name."""
|
"""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:
|
return "Twitch"
|
||||||
"""Returns the URL of the campaign image for enclosure."""
|
|
||||||
return item.get_feed_enclosure_url()
|
|
||||||
|
|
||||||
def item_enclosure_length(self, item: DropCampaign) -> int:
|
# Enclose
|
||||||
"""Returns the length of the enclosure."""
|
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.
|
||||||
|
"""
|
||||||
|
image_url: str = getattr(item, "image_best_url", "")
|
||||||
|
if image_url:
|
||||||
try:
|
try:
|
||||||
size: int | None = getattr(item, "image_size_bytes", None)
|
size: int | None = getattr(item, "image_size_bytes", None)
|
||||||
return int(size) if size is not None else 0
|
length: int = int(size) if size is not None else 0
|
||||||
except TypeError, ValueError:
|
except TypeError, ValueError:
|
||||||
return 0
|
length = 0
|
||||||
|
|
||||||
def item_enclosure_mime_type(self, item: DropCampaign) -> str:
|
|
||||||
"""Returns the MIME type of the enclosure."""
|
|
||||||
mime: str = getattr(item, "image_mime_type", "")
|
mime: str = getattr(item, "image_mime_type", "")
|
||||||
return mime or "image/jpeg"
|
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/
|
# MARK: /rss/games/<twitch_id>/campaigns/
|
||||||
class GameCampaignFeed(Feed):
|
class GameCampaignFeed(TTVDropsBaseFeed):
|
||||||
"""RSS feed for the latest drop campaigns of a specific game."""
|
"""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
|
_limit: int | None = None
|
||||||
|
|
||||||
def __call__(
|
def __call__(
|
||||||
|
|
@ -876,10 +1030,6 @@ class GameCampaignFeed(Feed):
|
||||||
"""Return a description for the feed."""
|
"""Return a description for the feed."""
|
||||||
return f"Latest drop campaigns for {obj.display_name}"
|
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]:
|
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)."""
|
"""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
|
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:
|
def item_title(self, item: DropCampaign) -> SafeText:
|
||||||
"""Return the campaign name as the item title (SafeText for RSS)."""
|
"""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:
|
def item_description(self, item: DropCampaign) -> SafeText:
|
||||||
"""Return a description of the campaign."""
|
"""Return a description of the campaign."""
|
||||||
|
|
@ -924,43 +1075,82 @@ class GameCampaignFeed(Feed):
|
||||||
|
|
||||||
def item_categories(self, item: DropCampaign) -> tuple[str, ...]:
|
def item_categories(self, item: DropCampaign) -> tuple[str, ...]:
|
||||||
"""Returns the associated game's name as a category."""
|
"""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:
|
def item_guid(self, item: DropCampaign) -> str:
|
||||||
"""Return a unique identifier for each campaign."""
|
"""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:
|
def item_author_name(self, item: DropCampaign) -> str:
|
||||||
"""Return the author name for the campaign, typically the game name."""
|
"""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:
|
return "Twitch"
|
||||||
"""Returns the URL of the campaign image for enclosure."""
|
|
||||||
return item.get_feed_enclosure_url()
|
|
||||||
|
|
||||||
def item_enclosure_length(self, item: DropCampaign) -> int:
|
def author_name(self, obj: Game) -> str:
|
||||||
"""Returns the length of the enclosure."""
|
"""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
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
size: int | None = getattr(item, "image_size_bytes", None)
|
size: int | None = getattr(item, "image_size_bytes", None)
|
||||||
return int(size) if size is not None else 0
|
length: int = int(size) if size is not None else 0
|
||||||
except TypeError, ValueError:
|
except TypeError, ValueError:
|
||||||
return 0
|
length = 0
|
||||||
|
|
||||||
def item_enclosure_mime_type(self, item: DropCampaign) -> str:
|
|
||||||
"""Returns the MIME type of the enclosure."""
|
|
||||||
mime: str = getattr(item, "image_mime_type", "")
|
mime: str = getattr(item, "image_mime_type", "")
|
||||||
return mime or "image/jpeg"
|
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/
|
# MARK: /rss/reward-campaigns/
|
||||||
class RewardCampaignFeed(Feed):
|
class RewardCampaignFeed(TTVDropsBaseFeed):
|
||||||
"""RSS feed for latest reward campaigns (Quest rewards)."""
|
"""RSS feed for latest reward campaigns."""
|
||||||
|
|
||||||
title: str = "Twitch Reward Campaigns (Quest Rewards)"
|
title: str = "Twitch Reward Campaigns"
|
||||||
link: str = "/campaigns/"
|
link: str = "/campaigns/"
|
||||||
description: str = "Latest Twitch reward campaigns (Quest rewards) on TTVDrops"
|
description: str = "Latest Twitch reward campaigns on TTVDrops"
|
||||||
feed_url: str = "/rss/reward-campaigns/"
|
|
||||||
feed_copyright: str = "Information wants to be free."
|
|
||||||
_limit: int | None = None
|
_limit: int | None = None
|
||||||
|
|
||||||
def __call__(
|
def __call__(
|
||||||
|
|
@ -998,22 +1188,83 @@ class RewardCampaignFeed(Feed):
|
||||||
|
|
||||||
def item_title(self, item: RewardCampaign) -> SafeText:
|
def item_title(self, item: RewardCampaign) -> SafeText:
|
||||||
"""Return the reward campaign name as the item title."""
|
"""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:
|
def item_description(self, item: RewardCampaign) -> SafeText:
|
||||||
"""Return a description of the reward campaign."""
|
"""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:
|
def item_link(self, item: RewardCampaign) -> str:
|
||||||
"""Return the link to the reward campaign (external URL or dashboard)."""
|
"""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:
|
def item_pubdate(self, item: RewardCampaign) -> datetime.datetime:
|
||||||
"""Returns the publication date to the feed item.
|
"""Returns the publication date to the feed item.
|
||||||
|
|
||||||
Uses starts_at (when the reward starts). Fallback to added_at or now if missing.
|
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:
|
def item_updateddate(self, item: RewardCampaign) -> datetime.datetime:
|
||||||
"""Returns the reward campaign's last update time."""
|
"""Returns the reward campaign's last update time."""
|
||||||
|
|
@ -1021,43 +1272,105 @@ class RewardCampaignFeed(Feed):
|
||||||
|
|
||||||
def item_categories(self, item: RewardCampaign) -> tuple[str, ...]:
|
def item_categories(self, item: RewardCampaign) -> tuple[str, ...]:
|
||||||
"""Returns the associated game's name and brand as categories."""
|
"""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:
|
def item_guid(self, item: RewardCampaign) -> str:
|
||||||
"""Return a unique identifier for each reward campaign."""
|
"""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:
|
def item_author_name(self, item: RewardCampaign) -> str:
|
||||||
"""Return the author name for the reward campaign."""
|
"""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
|
# 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)."""
|
"""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)."""
|
"""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)."""
|
"""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)."""
|
"""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)."""
|
"""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
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import auto_prefetch
|
import auto_prefetch
|
||||||
from django.contrib.humanize.templatetags.humanize import naturaltime
|
|
||||||
from django.contrib.postgres.indexes import GinIndex
|
from django.contrib.postgres.indexes import GinIndex
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.safestring import SafeText
|
|
||||||
|
|
||||||
from twitch.utils import normalize_twitch_box_art_url
|
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)
|
logger.debug("Failed to resolve Game.box_art_file url: %s", exc)
|
||||||
return normalize_twitch_box_art_url(self.box_art or "")
|
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
|
# MARK: TwitchGame
|
||||||
class TwitchGameData(auto_prefetch.Model):
|
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."""
|
"""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]
|
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
|
@property
|
||||||
def sorted_benefits(self) -> list[DropBenefit]:
|
def sorted_benefits(self) -> list[DropBenefit]:
|
||||||
"""Return a sorted list of benefits for the campaign."""
|
"""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)
|
logger.debug("Failed to resolve RewardCampaign.image_file url: %s", exc)
|
||||||
return self.image_url or ""
|
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
|
# MARK: ChatBadgeSet
|
||||||
class ChatBadgeSet(auto_prefetch.Model):
|
class ChatBadgeSet(auto_prefetch.Model):
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -13,9 +14,13 @@ from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from hypothesis.extra.django import TestCase
|
from hypothesis.extra.django import TestCase
|
||||||
|
|
||||||
|
from twitch.feeds import RSS_STYLESHEETS
|
||||||
from twitch.feeds import DropCampaignFeed
|
from twitch.feeds import DropCampaignFeed
|
||||||
from twitch.feeds import GameCampaignFeed
|
from twitch.feeds import GameCampaignFeed
|
||||||
from twitch.feeds import GameFeed
|
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 Channel
|
||||||
from twitch.models import ChatBadge
|
from twitch.models import ChatBadge
|
||||||
from twitch.models import ChatBadgeSet
|
from twitch.models import ChatBadgeSet
|
||||||
|
|
@ -27,11 +32,15 @@ from twitch.models import RewardCampaign
|
||||||
from twitch.models import TimeBasedDrop
|
from twitch.models import TimeBasedDrop
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger(__name__)
|
logger: logging.Logger = logging.getLogger(__name__)
|
||||||
|
STYLESHEET_PATH: Path = (
|
||||||
|
Path(__file__).resolve().parents[2] / "static" / "rss_styles.xslt"
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django.test.client import _MonkeyPatchedWSGIResponse
|
from django.test.client import _MonkeyPatchedWSGIResponse
|
||||||
|
from django.utils.feedgenerator import Enclosure
|
||||||
|
|
||||||
from twitch.tests.test_badge_views import Client
|
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.image_url = "https://example.com/campaign.png"
|
||||||
self.campaign.save()
|
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:
|
def test_organization_feed(self) -> None:
|
||||||
"""Test organization feed returns 200."""
|
"""Test organization feed returns 200."""
|
||||||
url: str = reverse("twitch:organization_feed")
|
url: str = reverse("twitch:organization_feed")
|
||||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||||
assert response.status_code == 200
|
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:
|
def test_game_feed(self) -> None:
|
||||||
"""Test game feed returns 200."""
|
"""Test game feed returns 200."""
|
||||||
url: str = reverse("twitch:game_feed")
|
url: str = reverse("twitch:game_feed")
|
||||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||||
assert response.status_code == 200
|
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")
|
content: str = response.content.decode("utf-8")
|
||||||
assert "Owned by Test Organization." in content
|
assert "Owned by Test Organization." in content
|
||||||
|
|
||||||
|
|
@ -101,25 +127,32 @@ class RSSFeedTestCase(TestCase):
|
||||||
assert expected_rss_link in content
|
assert expected_rss_link in content
|
||||||
|
|
||||||
# enclosure metadata from our new fields should be present
|
# enclosure metadata from our new fields should be present
|
||||||
assert 'length="42"' in content
|
msg: str = f"Expected enclosure length from image_size_bytes, got: {content}"
|
||||||
assert 'type="image/png"' in 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:
|
def test_organization_atom_feed(self) -> None:
|
||||||
"""Test organization Atom feed returns 200 and Atom XML."""
|
"""Test organization Atom feed returns 200 and Atom XML."""
|
||||||
url: str = reverse("twitch:organization_feed_atom")
|
url: str = reverse("twitch:organization_feed_atom")
|
||||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
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")
|
content: str = response.content.decode("utf-8")
|
||||||
assert "<feed" in content
|
assert "<feed" in content, f"Expected Atom feed XML, got: {content}"
|
||||||
assert "<entry" in content or "<entry" in 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:
|
def test_game_atom_feed(self) -> None:
|
||||||
"""Test game Atom feed returns 200 and contains expected content."""
|
"""Test game Atom feed returns 200 and contains expected content."""
|
||||||
url: str = reverse("twitch:game_feed_atom")
|
url: str = reverse("twitch:game_feed_atom")
|
||||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||||
assert response.status_code == 200
|
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")
|
content: str = response.content.decode("utf-8")
|
||||||
assert "Owned by Test Organization." in content
|
assert "Owned by Test Organization." in content
|
||||||
expected_atom_link: str = reverse(
|
expected_atom_link: str = reverse(
|
||||||
|
|
@ -130,24 +163,222 @@ class RSSFeedTestCase(TestCase):
|
||||||
# Atom should include box art URL somewhere in content
|
# Atom should include box art URL somewhere in content
|
||||||
assert "https://example.com/box.png" 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:
|
def test_game_feed_enclosure_helpers(self) -> None:
|
||||||
"""Helper methods should return values from model fields."""
|
"""Helper methods should return values from model fields."""
|
||||||
feed = GameFeed()
|
feed = GameFeed()
|
||||||
assert feed.item_enclosure_length(self.game) == 42
|
feed_item_enclosures: list[Enclosure] = feed.item_enclosures(self.game)
|
||||||
assert feed.item_enclosure_mime_type(self.game) == "image/png"
|
|
||||||
|
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:
|
def test_campaign_feed(self) -> None:
|
||||||
"""Test campaign feed returns 200."""
|
"""Test campaign feed returns 200."""
|
||||||
url: str = reverse("twitch:campaign_feed")
|
url: str = reverse("twitch:campaign_feed")
|
||||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||||
assert response.status_code == 200
|
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")
|
content: str = response.content.decode("utf-8")
|
||||||
# verify enclosure meta
|
# verify enclosure meta
|
||||||
assert 'length="314"' in content
|
assert 'length="314"' in content
|
||||||
assert 'type="image/gif"' 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:
|
def test_campaign_feed_only_includes_active_campaigns(self) -> None:
|
||||||
"""Campaign feed should exclude past and upcoming campaigns."""
|
"""Campaign feed should exclude past and upcoming campaigns."""
|
||||||
now: datetime.datetime = timezone.now()
|
now: datetime.datetime = timezone.now()
|
||||||
|
|
@ -180,8 +411,25 @@ class RSSFeedTestCase(TestCase):
|
||||||
def test_campaign_feed_enclosure_helpers(self) -> None:
|
def test_campaign_feed_enclosure_helpers(self) -> None:
|
||||||
"""Helper methods for campaigns should respect new fields."""
|
"""Helper methods for campaigns should respect new fields."""
|
||||||
feed = DropCampaignFeed()
|
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:
|
def test_campaign_feed_includes_badge_description(self) -> None:
|
||||||
"""Badge benefit descriptions should be visible in the RSS drop summary."""
|
"""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])
|
url: str = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id])
|
||||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||||
assert response.status_code == 200
|
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
|
# Verify the game name is in the feed
|
||||||
content: str = response.content.decode("utf-8")
|
content: str = response.content.decode("utf-8")
|
||||||
assert "Test Game" in content
|
assert "Test Game" in content
|
||||||
|
|
@ -346,8 +595,25 @@ class RSSFeedTestCase(TestCase):
|
||||||
def test_game_campaign_feed_enclosure_helpers(self) -> None:
|
def test_game_campaign_feed_enclosure_helpers(self) -> None:
|
||||||
"""GameCampaignFeed helper methods should pull from the model fields."""
|
"""GameCampaignFeed helper methods should pull from the model fields."""
|
||||||
feed = GameCampaignFeed()
|
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:
|
def test_backfill_command_sets_metadata(self) -> None:
|
||||||
"""Running the backfill command should populate size and mime fields.
|
"""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])
|
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):
|
with django_assert_num_queries(6, exact=False):
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue