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/ + + Twitch site-wide rewards + + https://ttvdrops.lovinator.space/rss/sitewide/rewards/ + + + https://ttvdrops.lovinator.space/atom/sitewide/rewards/ + + + https://ttvdrops.lovinator.space/discord/sitewide/rewards/ + + 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 %} +
+
+

Site-wide Rewards

+ +
+ Twitch > Reward Campaigns > Site-wide +
+

+ Reward campaigns available site-wide (not tied to a specific game). Data is scraped from SunkwiBOT/twitch-drops-api every 15 minutes. +

+ +
+ [rss] + [atom] + [discord] + [explain] +
+
+ {% if active_campaigns %} +
+

Active Rewards

+ + + + + + + + + + {% for campaign in active_campaigns %} + + + + + + {% endfor %} + +
CampaignBrandEnds
+ + {% if campaign.brand %} + {{ campaign.brand }}: {{ campaign.name }} + {% else %} + {{ campaign.name }} + {% endif %} + + {% if campaign.summary %} +
+ {{ campaign.summary }} + {% endif %} +
{{ campaign.brand|default:"—" }} + +
+
+ {% endif %} + {% if upcoming_campaigns %} +
+

Upcoming Rewards

+ + + + + + + + + + {% for campaign in upcoming_campaigns %} + + + + + + {% endfor %} + +
CampaignBrandStarts
+ + {% if campaign.brand %} + {{ campaign.brand }}: {{ campaign.name }} + {% else %} + {{ campaign.name }} + {% endif %} + + {% if campaign.summary %} +
+ {{ campaign.summary }} + {% endif %} +
{{ campaign.brand|default:"—" }} + +
+
+ {% endif %} + {% if expired_campaigns %} +
+

Past Rewards

+ + + + + + + + + + {% for campaign in expired_campaigns %} + + + + + + {% endfor %} + +
CampaignBrandEnded
+ + {% if campaign.brand %} + {{ campaign.brand }}: {{ campaign.name }} + {% else %} + {{ campaign.name }} + {% endif %} + + {% if campaign.summary %} +
+ {{ campaign.summary }} + {% endif %} +
{{ campaign.brand|default:"—" }} + +
+
+ {% 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)