Add site-wide rewards page with RSS/Atom/Discord feeds and tests
All checks were successful
Deploy to Server / deploy (push) Successful in 26s

This commit is contained in:
Joakim Hellsén 2026-06-14 22:42:10 +02:00
commit 9de0f52af8
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
8 changed files with 615 additions and 3 deletions

View file

@ -26,6 +26,9 @@ from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignDiscordFeed from twitch.feeds import RewardCampaignDiscordFeed
from twitch.feeds import RewardCampaignFeed from twitch.feeds import RewardCampaignFeed
from twitch.feeds import SitewideRewardCampaignAtomFeed
from twitch.feeds import SitewideRewardCampaignDiscordFeed
from twitch.feeds import SitewideRewardCampaignFeed
if TYPE_CHECKING: if TYPE_CHECKING:
from django.urls.resolvers import URLPattern from django.urls.resolvers import URLPattern
@ -108,6 +111,12 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=RewardCampaignFeed(), view=RewardCampaignFeed(),
name="reward_campaign_feed", name="reward_campaign_feed",
), ),
# /rss/sitewide/rewards/ - site-wide reward campaigns
path(
route="rss/sitewide/rewards/",
view=SitewideRewardCampaignFeed(),
name="sitewide_reward_feed",
),
# Atom feeds (added alongside RSS to preserve backward compatibility) # Atom feeds (added alongside RSS to preserve backward compatibility)
path( path(
route="atom/campaigns/", route="atom/campaigns/",
@ -139,6 +148,18 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=RewardCampaignAtomFeed(), view=RewardCampaignAtomFeed(),
name="reward_campaign_feed_atom", name="reward_campaign_feed_atom",
), ),
# /atom/sitewide/rewards/ - site-wide reward campaigns (Atom)
path(
route="atom/sitewide/rewards/",
view=SitewideRewardCampaignAtomFeed(),
name="sitewide_reward_feed_atom",
),
# /discord/sitewide/rewards/ - site-wide reward campaigns (Discord)
path(
route="discord/sitewide/rewards/",
view=SitewideRewardCampaignDiscordFeed(),
name="sitewide_reward_feed_discord",
),
# Discord feeds (Atom feeds with Discord relative timestamps) # Discord feeds (Atom feeds with Discord relative timestamps)
path( path(
route="discord/campaigns/", route="discord/campaigns/",

View file

@ -85,6 +85,18 @@
<a href="https://ttvdrops.lovinator.space/discord/reward-campaigns/">https://ttvdrops.lovinator.space/discord/reward-campaigns/</a> <a href="https://ttvdrops.lovinator.space/discord/reward-campaigns/">https://ttvdrops.lovinator.space/discord/reward-campaigns/</a>
</td> </td>
</tr> </tr>
<tr>
<td>Twitch site-wide rewards</td>
<td>
<a href="https://ttvdrops.lovinator.space{% url 'core:sitewide_reward_feed' %}">https://ttvdrops.lovinator.space/rss/sitewide/rewards/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space{% url 'core:sitewide_reward_feed_atom' %}">https://ttvdrops.lovinator.space/atom/sitewide/rewards/</a>
</td>
<td>
<a href="https://ttvdrops.lovinator.space{% url 'core:sitewide_reward_feed_discord' %}">https://ttvdrops.lovinator.space/discord/sitewide/rewards/</a>
</td>
</tr>
<tr> <tr>
<td>Latest Kick drops</td> <td>Latest Kick drops</td>
<td> <td>

View file

@ -39,6 +39,8 @@
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:twitch-api-v1:list_reward_campaigns' %}" <a href="{% url 'twitch:twitch-api-v1:list_reward_campaigns' %}"
title="Twitch reward campaigns API">[api]</a> title="Twitch reward campaigns API">[api]</a>
<a href="{% url 'twitch:sitewide_rewards' %}"
title="View site-wide reward campaigns">[sitewide]</a>
</div> </div>
{% if reward_campaigns %} {% if reward_campaigns %}
{% with active_campaigns=reward_campaigns|dictsortreversed:"ends_at" %} {% with active_campaigns=reward_campaigns|dictsortreversed:"ends_at" %}
@ -65,7 +67,7 @@
{% if campaign.game %} {% if campaign.game %}
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a> <a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
{% elif campaign.is_sitewide %} {% elif campaign.is_sitewide %}
Site-wide <a href="{% url 'twitch:sitewide_rewards' %}">Site-wide</a>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@ -99,7 +101,7 @@
{% if campaign.game %} {% if campaign.game %}
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a> <a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
{% elif campaign.is_sitewide %} {% elif campaign.is_sitewide %}
Site-wide <a href="{% url 'twitch:sitewide_rewards' %}">Site-wide</a>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@ -134,7 +136,7 @@
{% if campaign.game %} {% if campaign.game %}
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a> <a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
{% elif campaign.is_sitewide %} {% elif campaign.is_sitewide %}
Site-wide <a href="{% url 'twitch:sitewide_rewards' %}">Site-wide</a>
{% endif %} {% endif %}
</td> </td>
<td> <td>

View file

@ -0,0 +1,172 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
Site-wide Rewards
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
type="application/rss+xml"
title="Site-wide rewards (RSS)"
href="{% url 'core:sitewide_reward_feed' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Site-wide rewards (Atom)"
href="{% url 'core:sitewide_reward_feed_atom' %}" />
<link rel="alternate"
type="application/atom+xml"
title="Site-wide rewards (Discord)"
href="{% url 'core:sitewide_reward_feed_discord' %}" />
{% endblock extra_head %}
{% block content %}
<main>
<header>
<h1>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; Site-wide
</div>
<p>
Reward campaigns available site-wide (not tied to a specific game). Data is scraped from <a href="https://github.com/SunkwiBOT/twitch-drops-api"
target="_blank"
rel="noopener">SunkwiBOT/twitch-drops-api</a> every 15 minutes.
</p>
<!-- Feeds and links -->
<div>
<a href="{% url 'core:sitewide_reward_feed' %}"
title="RSS feed for site-wide rewards">[rss]</a>
<a href="{% url 'core:sitewide_reward_feed_atom' %}"
title="Atom feed for site-wide rewards">[atom]</a>
<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>
</div>
</header>
{% if active_campaigns %}
<section>
<h2>Active Rewards</h2>
<table>
<thead>
<tr>
<th>Campaign</th>
<th>Brand</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>
<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>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>
<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>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>
<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 Site-wide Rewards</h2>
<p style="margin: 0; font-size: 1.1rem;">No site-wide reward campaigns are currently available.</p>
</section>
{% endif %}
</main>
{% endblock content %}

View file

@ -1987,3 +1987,239 @@ class GameRewardCampaignDiscordFeed(TTVDropsAtomBaseFeed, GameRewardCampaignFeed
def feed_url(self, obj: Game) -> str: def feed_url(self, obj: Game) -> str:
"""Return the URL to the Discord feed itself.""" """Return the URL to the Discord feed itself."""
return reverse("core:game_reward_feed_discord", args=[obj.twitch_id]) return reverse("core:game_reward_feed_discord", args=[obj.twitch_id])
# MARK: /rss/sitewide/rewards/
class SitewideRewardCampaignFeed(TTVDropsBaseFeed):
"""RSS feed for site-wide reward campaigns (no specific game)."""
title: str = "TTVDrops: Site-wide Rewards"
link: str = "/twitch/sitewide/"
description: str = "Twitch reward campaigns available site-wide"
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 (HttpRequest): 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 after processing the request.
"""
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 site-wide reward campaigns (default 200, or limited by ?limit query param)."""
limit: int = self._limit if self._limit is not None else 200
queryset: QuerySet[RewardCampaign] = _active_reward_campaigns(
RewardCampaign.objects
.filter(is_sitewide=True)
.select_related("game")
.order_by("-starts_at"),
)
return list(queryset[: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))
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", "sitewide"]
if item.brand:
categories.append(item.brand)
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:sitewide_reward_feed")
class SitewideRewardCampaignAtomFeed(TTVDropsAtomBaseFeed, SitewideRewardCampaignFeed):
"""Atom feed for site-wide reward campaigns."""
def feed_url(self) -> str:
"""Return the URL to the Atom feed itself."""
return reverse("core:sitewide_reward_feed_atom")
class SitewideRewardCampaignDiscordFeed(
TTVDropsAtomBaseFeed, SitewideRewardCampaignFeed
):
"""Discord feed for site-wide reward campaigns with Discord relative timestamps."""
def item_description(self, item: RewardCampaign) -> SafeText:
"""Return a description of the reward campaign 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))
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:sitewide_reward_feed_discord")

View file

@ -2756,6 +2756,91 @@ class TestRewardCampaignViews:
f"baseline={baseline}, scaled={scaled}" f"baseline={baseline}, scaled={scaled}"
) )
def test_sitewide_rewards_returns_200(self, client: Client) -> None:
"""Site-wide rewards page renders successfully with only sitewide campaigns."""
game: Game = self._create_game("sitewide-test-game", "Sitewide Test Game")
# Site-wide reward (game=None, is_sitewide=True)
sitewide = self._create_reward_campaign(
"sitewide-active",
brand="Sitewide Brand",
name="Sitewide Active",
game=None,
starts_delta=-timedelta(days=1),
ends_delta=timedelta(days=1),
)
# Game-specific reward (is_sitewide=False) — should NOT appear
self._create_reward_campaign(
"sitewide-game-specific",
brand="Game Brand",
name="Game Specific",
game=game,
starts_delta=-timedelta(days=1),
ends_delta=timedelta(days=1),
)
response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:sitewide_rewards"),
)
assert response.status_code == 200
content: str = response.content.decode()
# Site-wide campaign should be visible
assert "Sitewide Brand: Sitewide Active" in content
assert (
reverse("twitch:reward_campaign_detail", args=[sitewide.twitch_id])
in content
)
# Game-specific campaign should NOT appear
assert "Game Brand: Game Specific" not in content
# Feed links should be present
assert reverse("core:sitewide_reward_feed") in content
assert reverse("core:sitewide_reward_feed_atom") in content
assert reverse("core:sitewide_reward_feed_discord") in content
def test_sitewide_rewards_context_splits_by_status(
self,
client: Client,
) -> None:
"""Site-wide rewards page splits campaigns into active/upcoming/expired."""
self._create_reward_campaign(
"sitewide-active-ctx",
brand="Active Brand",
name="Active Reward",
game=None,
starts_delta=-timedelta(days=1),
ends_delta=timedelta(days=1),
)
self._create_reward_campaign(
"sitewide-upcoming-ctx",
brand="Upcoming Brand",
name="Upcoming Reward",
game=None,
starts_delta=timedelta(days=1),
ends_delta=timedelta(days=3),
)
self._create_reward_campaign(
"sitewide-expired-ctx",
brand="Expired Brand",
name="Expired Reward",
game=None,
starts_delta=-timedelta(days=4),
ends_delta=-timedelta(days=1),
)
response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:sitewide_rewards"),
)
assert response.status_code == 200
assert len(response.context["active_campaigns"]) == 1
assert len(response.context["upcoming_campaigns"]) == 1
assert len(response.context["expired_campaigns"]) == 1
@pytest.mark.django_db @pytest.mark.django_db
class TestSEOHelperFunctions: class TestSEOHelperFunctions:

View file

@ -65,6 +65,12 @@ urlpatterns: list[URLPattern | URLResolver] = [
views.reward_campaign_detail_view, views.reward_campaign_detail_view,
name="reward_campaign_detail", name="reward_campaign_detail",
), ),
# /twitch/sitewide/
path(
"sitewide/",
views.sitewide_rewards_view,
name="sitewide_rewards",
),
# /twitch/export/campaigns/csv/ # /twitch/export/campaigns/csv/
path( path(
"export/campaigns/csv/", "export/campaigns/csv/",

View file

@ -1650,3 +1650,81 @@ def export_organizations_json(request: HttpRequest) -> HttpResponse:
response["Content-Disposition"] = "attachment; filename=organizations.json" response["Content-Disposition"] = "attachment; filename=organizations.json"
return response return response
# MARK: /sitewide/
def sitewide_rewards_view(request: HttpRequest) -> HttpResponse:
"""View to display all site-wide reward campaigns.
Site-wide reward campaigns have is_sitewide=True and no specific game.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered site-wide rewards page.
"""
now: datetime.datetime = timezone.now()
# Fetch all reward campaigns that are site-wide
sitewide_campaigns: QuerySet[RewardCampaign] = (
RewardCampaign.objects
.filter(is_sitewide=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 sitewide_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 = "Site-wide Rewards"
description: str = "Twitch reward campaigns available site-wide."
url: str = build_absolute_uri(reverse("twitch:sitewide_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/sitewide_rewards.html", context)