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

@ -17,6 +17,9 @@ from twitch.feeds import GameCampaignDiscordFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameDiscordFeed
from twitch.feeds import GameFeed
from twitch.feeds import GameLessRewardCampaignAtomFeed
from twitch.feeds import GameLessRewardCampaignDiscordFeed
from twitch.feeds import GameLessRewardCampaignFeed
from twitch.feeds import GameRewardCampaignAtomFeed
from twitch.feeds import GameRewardCampaignDiscordFeed
from twitch.feeds import GameRewardCampaignFeed
@ -160,6 +163,24 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=SitewideRewardCampaignDiscordFeed(),
name="sitewide_reward_feed_discord",
),
# /rss/rewards/no-game/ - reward campaigns with no game linked (brand + sitewide)
path(
route="rss/rewards/no-game/",
view=GameLessRewardCampaignFeed(),
name="game_less_reward_feed",
),
# /atom/rewards/no-game/ - same as RSS but Atom
path(
route="atom/rewards/no-game/",
view=GameLessRewardCampaignAtomFeed(),
name="game_less_reward_feed_atom",
),
# /discord/rewards/no-game/ - same as RSS but Discord timestamps
path(
route="discord/rewards/no-game/",
view=GameLessRewardCampaignDiscordFeed(),
name="game_less_reward_feed_discord",
),
# Discord feeds (Atom feeds with Discord relative timestamps)
path(
route="discord/campaigns/",

View file

@ -97,6 +97,18 @@
<a href="https://ttvdrops.lovinator.space{% url 'core:sitewide_reward_feed_discord' %}">https://ttvdrops.lovinator.space/discord/sitewide/rewards/</a>
</td>
</tr>
<tr>
<td>Twitch rewards with no game linked (brand + sitewide)</td>
<td>
<a href="https://ttvdrops.lovinator.space{% url 'core:game_less_reward_feed' %}">https://ttvdrops.lovinator.space/rss/rewards/no-game/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space{% url 'core:game_less_reward_feed_atom' %}">https://ttvdrops.lovinator.space/atom/rewards/no-game/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space{% url 'core:game_less_reward_feed_discord' %}">https://ttvdrops.lovinator.space/discord/rewards/no-game/</a>
</td>
</tr>
<tr>
<td>Latest Kick drops</td>
<td>

View file

@ -0,0 +1,198 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
Brand & Site-wide Rewards
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="All game-less rewards (RSS)"
href="{% url 'core:game_less_reward_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All game-less rewards (Atom)"
href="{% url 'core:game_less_reward_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All game-less rewards (Discord)"
href="{% url 'core:game_less_reward_feed_discord' %}" />
{% endblock extra_head %}
{% block content %}
<main>
<header>
<h1>Brand & Site-wide Rewards</h1>
<!-- Breadcrumbs -->
<div>
<a href="{% url 'twitch:dashboard' %}">Twitch</a> &gt; <a href="{% url 'twitch:reward_campaign_list' %}">Reward Campaigns</a> &gt; Brand &amp; Site-wide
</div>
<p>
Reward campaigns not tied to a specific Twitch game. These include
<strong>brand-specific</strong> campaigns (e.g., Minecraft, DOORS) and
truly <strong>site-wide</strong> Twitch quests.
</p>
<!-- Feeds and links -->
<div>
<a href="{% url 'core:game_less_reward_feed' %}"
title="RSS feed for all game-less rewards">[rss]</a>
<a href="{% url 'core:game_less_reward_feed_atom' %}"
title="Atom feed for all game-less rewards">[atom]</a>
<a href="{% url 'core:game_less_reward_feed_discord' %}"
title="Discord feed for all game-less rewards">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:sitewide_rewards' %}"
title="View only site-wide rewards">[sitewide only]</a>
</div>
</header>
{% if active_campaigns %}
<section>
<h2>Active Rewards</h2>
<table>
<thead>
<tr>
<th>Campaign</th>
<th>Brand</th>
<th>Type</th>
<th>Ends</th>
</tr>
</thead>
<tbody>
{% for campaign in active_campaigns %}
<tr>
<td>
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
{% if campaign.brand %}
{{ campaign.brand }}: {{ campaign.name }}
{% else %}
{{ campaign.name }}
{% endif %}
</a>
{% if campaign.summary %}
<br />
<small>{{ campaign.summary }}</small>
{% endif %}
</td>
<td>{{ campaign.brand|default:"—" }}</td>
<td>
{% if campaign.is_sitewide %}
<strong>Site-wide</strong>
{% else %}
Brand
{% endif %}
</td>
<td>
<time datetime="{{ campaign.ends_at|date:'c' }}"
title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}">
Ends in {{ campaign.ends_at|timeuntil }}
</time>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
{% if upcoming_campaigns %}
<section>
<h2>Upcoming Rewards</h2>
<table>
<thead>
<tr>
<th>Campaign</th>
<th>Brand</th>
<th>Type</th>
<th>Starts</th>
</tr>
</thead>
<tbody>
{% for campaign in upcoming_campaigns %}
<tr>
<td>
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
{% if campaign.brand %}
{{ campaign.brand }}: {{ campaign.name }}
{% else %}
{{ campaign.name }}
{% endif %}
</a>
{% if campaign.summary %}
<br />
<small>{{ campaign.summary }}</small>
{% endif %}
</td>
<td>{{ campaign.brand|default:"—" }}</td>
<td>
{% if campaign.is_sitewide %}
<strong>Site-wide</strong>
{% else %}
Brand
{% endif %}
</td>
<td>
<time datetime="{{ campaign.starts_at|date:'c' }}"
title="{{ campaign.starts_at|date:'DATETIME_FORMAT' }}">
Starts in {{ campaign.starts_at|timeuntil }}
</time>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
{% if expired_campaigns %}
<section>
<h2>Past Rewards</h2>
<table>
<thead>
<tr>
<th>Campaign</th>
<th>Brand</th>
<th>Type</th>
<th>Ended</th>
</tr>
</thead>
<tbody>
{% for campaign in expired_campaigns %}
<tr>
<td>
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
{% if campaign.brand %}
{{ campaign.brand }}: {{ campaign.name }}
{% else %}
{{ campaign.name }}
{% endif %}
</a>
{% if campaign.summary %}
<br />
<small>{{ campaign.summary }}</small>
{% endif %}
</td>
<td>{{ campaign.brand|default:"—" }}</td>
<td>
{% if campaign.is_sitewide %}
<strong>Site-wide</strong>
{% else %}
Brand
{% endif %}
</td>
<td>
<time datetime="{{ campaign.ends_at|date:'c' }}"
title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}">
{{ campaign.ends_at|timesince }} ago
</time>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
{% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %}
<section style="text-align: center; padding: 3rem 1rem;">
<div style="font-size: 4rem; margin-bottom: 1rem;">🏷️</div>
<h2 style="margin: 0 0 1rem 0;">No Rewards Found</h2>
<p style="margin: 0; font-size: 1.1rem;">No brand or site-wide reward campaigns are currently available.</p>
</section>
{% endif %}
</main>
{% endblock content %}

View file

@ -30,17 +30,19 @@
</p>
<!-- RSS Feeds -->
<div>
<a href="{% url 'core:reward_campaign_feed' %}"
title="RSS feed for all reward campaigns">[rss]</a>
<a href="{% url 'core:reward_campaign_feed_atom' %}"
title="Atom feed for all reward campaigns">[atom]</a>
<a href="{% url 'core:reward_campaign_feed_discord' %}"
title="Discord feed for all reward campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:twitch-api-v1:list_reward_campaigns' %}"
title="Twitch reward campaigns API">[api]</a>
<a href="{% url 'twitch:sitewide_rewards' %}"
title="View site-wide reward campaigns">[sitewide]</a>
<div>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:twitch-api-v1:list_reward_campaigns' %}"
title="Twitch reward campaigns API">[api]</a>
</div>
<div>
<a href="{% url 'core:reward_campaign_feed' %}"
title="RSS feed for all reward campaigns">[rss]</a>
<a href="{% url 'core:reward_campaign_feed_atom' %}"
title="Atom feed for all reward campaigns">[atom]</a>
<a href="{% url 'core:reward_campaign_feed_discord' %}"
title="Discord feed for all reward campaigns">[discord]</a>
</div>
</div>
{% if reward_campaigns %}
{% with active_campaigns=reward_campaigns|dictsortreversed:"ends_at" %}
@ -68,6 +70,8 @@
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
{% elif campaign.is_sitewide %}
<a href="{% url 'twitch:sitewide_rewards' %}">Site-wide</a>
{% else %}
<a href="{% url 'twitch:game_less_rewards' %}">[no game]</a>
{% endif %}
</td>
<td>
@ -102,6 +106,8 @@
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
{% elif campaign.is_sitewide %}
<a href="{% url 'twitch:sitewide_rewards' %}">Site-wide</a>
{% else %}
<a href="{% url 'twitch:game_less_rewards' %}">[no game]</a>
{% endif %}
</td>
<td>
@ -137,6 +143,8 @@
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
{% elif campaign.is_sitewide %}
<a href="{% url 'twitch:sitewide_rewards' %}">Site-wide</a>
{% else %}
<a href="{% url 'twitch:game_less_rewards' %}">[no game]</a>
{% endif %}
</td>
<td>

View file

@ -16,6 +16,18 @@
type="application/atom+xml"
title="Site-wide rewards (Discord)"
href="{% url 'core:sitewide_reward_feed_discord' %}" />
<link rel="alternate"
type="application/rss+xml"
title="All game-less rewards (RSS)"
href="{% url 'core:game_less_reward_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All game-less rewards (Atom)"
href="{% url 'core:game_less_reward_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="All game-less rewards (Discord)"
href="{% url 'core:game_less_reward_feed_discord' %}" />
{% endblock extra_head %}
{% block content %}
<main>
@ -39,6 +51,8 @@
<a href="{% url 'core:sitewide_reward_feed_discord' %}"
title="Discord feed for site-wide rewards">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:game_less_rewards' %}"
title="View all rewards with no game linked">[no game]</a>
</div>
</header>
{% if active_campaigns %}

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)