Add page for game-less reward campaigns with RSS/Atom/Discord feeds
All checks were successful
Deploy to Server / deploy (push) Successful in 26s

This commit is contained in:
Joakim Hellsén 2026-06-15 03:28:36 +02:00
commit dbc36db1e0
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
8 changed files with 602 additions and 11 deletions

View file

@ -2223,3 +2223,254 @@ class SitewideRewardCampaignDiscordFeed(
def feed_url(self) -> str:
"""Return the URL to the Discord feed itself."""
return reverse("core:sitewide_reward_feed_discord")
# MARK: /rss/rewards/no-game/
class GameLessRewardCampaignFeed(TTVDropsBaseFeed):
"""RSS feed for reward campaigns with no game linked (brand-specific + sitewide)."""
title: str = "TTVDrops: Game-less Rewards"
link: str = "/twitch/reward-campaigns/"
description: str = (
"Twitch reward campaigns without a linked game (brand-specific and sitewide)"
)
item_guid_is_permalink = True
_limit: int | None = None
def __call__(
self,
request: HttpRequest,
*args: str | int,
**kwargs: str | int,
) -> HttpResponse:
"""Override to capture limit parameter from request.
Args:
request: The incoming HTTP request, potentially containing a 'limit' query parameter.
*args: Additional positional arguments.
**kwargs: Additional keyword arguments.
Returns:
HttpResponse: The HTTP response generated by the parent Feed class.
"""
if request.GET.get("limit"):
try:
self._limit = int(request.GET.get("limit", 200))
except ValueError, TypeError:
self._limit = None
return super().__call__(request, *args, **kwargs)
def items(self) -> list[RewardCampaign]:
"""Return reward campaigns with no game linked, ordered by newest."""
limit: int = self._limit if self._limit is not None else 200
return list(
RewardCampaign.objects
.filter(game__isnull=True)
.select_related("game")
.order_by("-starts_at")[:limit],
)
def item_title(self, item: RewardCampaign) -> SafeText:
"""Return the reward campaign name as the item 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."""
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.brand:
parts.append(
format_html("<p>Brand: {}</p>", item.brand),
)
if item.is_sitewide:
parts.append(
SafeText("<p><strong>Site-wide reward campaign</strong></p>"),
)
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 detail."""
return reverse("twitch:reward_campaign_detail", args=[item.twitch_id])
def item_pubdate(self, item: RewardCampaign) -> datetime.datetime:
"""Returns the publication date to the feed item."""
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."""
return item.updated_at
def item_categories(self, item: RewardCampaign) -> tuple[str, ...]:
"""Returns categories for the feed item."""
categories: list[str] = ["twitch", "rewards", "quests"]
if item.brand:
categories.append(item.brand)
if item.is_sitewide:
categories.append("sitewide")
return tuple(categories)
def item_guid(self, item: RewardCampaign) -> str:
"""Return a unique identifier for each reward campaign."""
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.brand or "Twitch"
def item_enclosures(self, item: RewardCampaign) -> list[feedgenerator.Enclosure]:
"""Return a list of enclosures for the reward campaign, 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
if not length:
return []
mime: str = getattr(item, "image_mime_type", "")
mime_type: str = mime or "image/jpeg"
return [
feedgenerator.Enclosure(
self._absolute_url(image_url),
str(length),
mime_type,
),
]
return []
def feed_url(self) -> str:
"""Return the URL to the RSS feed itself."""
return reverse("core:game_less_reward_feed")
class GameLessRewardCampaignAtomFeed(TTVDropsAtomBaseFeed, GameLessRewardCampaignFeed):
"""Atom feed for reward campaigns with no game linked."""
def feed_url(self) -> str:
"""Return the URL to the Atom feed itself."""
return reverse("core:game_less_reward_feed_atom")
class GameLessRewardCampaignDiscordFeed(
TTVDropsAtomBaseFeed, GameLessRewardCampaignFeed
):
"""Discord feed for reward campaigns with no game linked, with Discord timestamps."""
def item_description(self, item: RewardCampaign) -> SafeText:
"""Return a description with Discord timestamps."""
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"),
discord_timestamp(item.starts_at),
)
if item.starts_at
else ""
)
end_part = (
format_html(
"Ends: {} ({})",
item.ends_at.strftime("%Y-%m-%d %H:%M %Z"),
discord_timestamp(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.brand:
parts.append(
format_html("<p>Brand: {}</p>", item.brand),
)
if item.is_sitewide:
parts.append(
SafeText("<p><strong>Site-wide reward campaign</strong></p>"),
)
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 feed_url(self) -> str:
"""Return the URL to the Discord feed itself."""
return reverse("core:game_less_reward_feed_discord")

View file

@ -71,6 +71,12 @@ urlpatterns: list[URLPattern | URLResolver] = [
views.sitewide_rewards_view,
name="sitewide_rewards",
),
# /twitch/rewards/no-game/
path(
"rewards/no-game/",
views.game_less_rewards_view,
name="game_less_rewards",
),
# /twitch/export/campaigns/csv/
path(
"export/campaigns/csv/",

View file

@ -1728,3 +1728,84 @@ def sitewide_rewards_view(request: HttpRequest) -> HttpResponse:
}
return render(request, "twitch/sitewide_rewards.html", context)
# MARK: /rewards/no-game/
def game_less_rewards_view(request: HttpRequest) -> HttpResponse:
"""View to display all reward campaigns with no game linked.
Includes both site-wide (is_sitewide=True) and brand-specific campaigns
that don't have a Twitch game associated with them.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered game-less rewards page.
"""
now: datetime.datetime = timezone.now()
# Fetch all reward campaigns without a game
campaigns: QuerySet[RewardCampaign] = (
RewardCampaign.objects
.filter(game__isnull=True)
.select_related("game")
.order_by("-starts_at")
)
# Split into active, upcoming, expired
active_campaigns: list[RewardCampaign] = []
upcoming_campaigns: list[RewardCampaign] = []
expired_campaigns: list[RewardCampaign] = []
for campaign in campaigns:
if campaign.starts_at is None or campaign.ends_at is None:
continue
if campaign.starts_at <= now <= campaign.ends_at:
active_campaigns.append(campaign)
elif campaign.starts_at > now:
upcoming_campaigns.append(campaign)
elif campaign.ends_at < now:
expired_campaigns.append(campaign)
title: str = "Brand & Site-wide Rewards"
description: str = (
"Twitch reward campaigns without a linked game (brand-specific and site-wide)."
)
url: str = build_absolute_uri(reverse("twitch:game_less_rewards"))
# CollectionPage schema
collection_schema: dict[str, str] = {
"@context": "https://schema.org",
"@type": "CollectionPage",
"name": title,
"description": description,
"url": url,
}
# Breadcrumb schema
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
{"name": "Home", "url": build_absolute_uri("/")},
{"name": "Reward Campaigns", "url": build_absolute_uri("/reward-campaigns/")},
{"name": title, "url": url},
])
seo_context: dict[str, Any] = _build_seo_context(
page_title=title,
page_description=description,
seo_meta={
"page_url": url,
"schema_data": collection_schema,
"breadcrumb_schema": breadcrumb_schema,
},
)
context: dict[str, Any] = {
"active_campaigns": active_campaigns,
"upcoming_campaigns": upcoming_campaigns,
"expired_campaigns": expired_campaigns,
"now": now,
**seo_context,
}
return render(request, "twitch/game_less_rewards.html", context)