diff --git a/core/urls.py b/core/urls.py
index 2045611..5ab5d86 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -26,6 +26,9 @@ from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignDiscordFeed
from twitch.feeds import RewardCampaignFeed
+from twitch.feeds import SitewideRewardCampaignAtomFeed
+from twitch.feeds import SitewideRewardCampaignDiscordFeed
+from twitch.feeds import SitewideRewardCampaignFeed
if TYPE_CHECKING:
from django.urls.resolvers import URLPattern
@@ -108,6 +111,12 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=RewardCampaignFeed(),
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)
path(
route="atom/campaigns/",
@@ -139,6 +148,18 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=RewardCampaignAtomFeed(),
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)
path(
route="discord/campaigns/",
diff --git a/templates/core/docs_rss.html b/templates/core/docs_rss.html
index 92737be..e06b920 100644
--- a/templates/core/docs_rss.html
+++ b/templates/core/docs_rss.html
@@ -85,6 +85,18 @@
https://ttvdrops.lovinator.space/discord/reward-campaigns/
+
| Latest Kick drops |
diff --git a/templates/twitch/reward_campaign_list.html b/templates/twitch/reward_campaign_list.html
index 3022915..6d8701a 100644
--- a/templates/twitch/reward_campaign_list.html
+++ b/templates/twitch/reward_campaign_list.html
@@ -39,6 +39,8 @@
[explain]
[api]
+ [sitewide]
{% if reward_campaigns %}
{% with active_campaigns=reward_campaigns|dictsortreversed:"ends_at" %}
@@ -65,7 +67,7 @@
{% if campaign.game %}
{{ campaign.game.display_name }}
{% elif campaign.is_sitewide %}
- Site-wide
+ Site-wide
{% endif %}
|
@@ -99,7 +101,7 @@
{% if campaign.game %}
{{ campaign.game.display_name }}
{% elif campaign.is_sitewide %}
- Site-wide
+ Site-wide
{% endif %}
|
@@ -134,7 +136,7 @@
{% if campaign.game %}
{{ campaign.game.display_name }}
{% elif campaign.is_sitewide %}
- Site-wide
+ Site-wide
{% endif %}
|
diff --git a/templates/twitch/sitewide_rewards.html b/templates/twitch/sitewide_rewards.html
new file mode 100644
index 0000000..1f574ce
--- /dev/null
+++ b/templates/twitch/sitewide_rewards.html
@@ -0,0 +1,172 @@
+{% extends "base.html" %}
+{% load static %}
+{% block title %}
+ Site-wide Rewards
+{% endblock title %}
+{% block extra_head %}
+
+
+
+{% endblock extra_head %}
+{% block content %}
+
+
+ {% if active_campaigns %}
+
+ {% endif %}
+ {% if upcoming_campaigns %}
+
+ {% endif %}
+ {% if expired_campaigns %}
+
+ {% endif %}
+ {% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %}
+
+ 🌐
+ No Site-wide Rewards
+ No site-wide reward campaigns are currently available.
+
+ {% endif %}
+
+{% endblock content %}
diff --git a/twitch/feeds.py b/twitch/feeds.py
index c66d7d5..b1087a9 100644
--- a/twitch/feeds.py
+++ b/twitch/feeds.py
@@ -1987,3 +1987,239 @@ class GameRewardCampaignDiscordFeed(TTVDropsAtomBaseFeed, GameRewardCampaignFeed
def feed_url(self, obj: Game) -> str:
"""Return the URL to the Discord feed itself."""
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("{} ", 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("{} {} ", start_part, end_part))
+ elif start_part:
+ parts.append(format_html("{} ", start_part))
+ elif end_part:
+ parts.append(format_html("{} ", end_part))
+
+ parts.append(
+ SafeText("Site-wide reward campaign "),
+ )
+
+ if item.about_url:
+ parts.append(
+ format_html('Learn more ', item.about_url),
+ )
+
+ if item.external_url:
+ parts.append(
+ format_html(
+ 'Redeem reward ',
+ 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("{} ", 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("{} {} ", start_part, end_part))
+ elif start_part:
+ parts.append(format_html("{} ", start_part))
+ elif end_part:
+ parts.append(format_html("{} ", end_part))
+
+ parts.append(
+ SafeText("Site-wide reward campaign "),
+ )
+
+ if item.about_url:
+ parts.append(
+ format_html('Learn more ', item.about_url),
+ )
+
+ if item.external_url:
+ parts.append(
+ format_html(
+ 'Redeem reward ',
+ 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")
diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py
index b0f34f3..7e15e41 100644
--- a/twitch/tests/test_views.py
+++ b/twitch/tests/test_views.py
@@ -2756,6 +2756,91 @@ class TestRewardCampaignViews:
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
class TestSEOHelperFunctions:
diff --git a/twitch/urls.py b/twitch/urls.py
index 8acb346..8fd6147 100644
--- a/twitch/urls.py
+++ b/twitch/urls.py
@@ -65,6 +65,12 @@ urlpatterns: list[URLPattern | URLResolver] = [
views.reward_campaign_detail_view,
name="reward_campaign_detail",
),
+ # /twitch/sitewide/
+ path(
+ "sitewide/",
+ views.sitewide_rewards_view,
+ name="sitewide_rewards",
+ ),
# /twitch/export/campaigns/csv/
path(
"export/campaigns/csv/",
diff --git a/twitch/views.py b/twitch/views.py
index 0fbaddf..5af0eea 100644
--- a/twitch/views.py
+++ b/twitch/views.py
@@ -1650,3 +1650,81 @@ def export_organizations_json(request: HttpRequest) -> HttpResponse:
response["Content-Disposition"] = "attachment; filename=organizations.json"
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)
|