diff --git a/core/urls.py b/core/urls.py index 5ab5d86..9feaaf6 100644 --- a/core/urls.py +++ b/core/urls.py @@ -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/", diff --git a/templates/core/docs_rss.html b/templates/core/docs_rss.html index e06b920..6d3d163 100644 --- a/templates/core/docs_rss.html +++ b/templates/core/docs_rss.html @@ -97,6 +97,18 @@ https://ttvdrops.lovinator.space/discord/sitewide/rewards/ + + Twitch rewards with no game linked (brand + sitewide) + + https://ttvdrops.lovinator.space/rss/rewards/no-game/ + + + https://ttvdrops.lovinator.space/atom/rewards/no-game/ + + + https://ttvdrops.lovinator.space/discord/rewards/no-game/ + + Latest Kick drops diff --git a/templates/twitch/game_less_rewards.html b/templates/twitch/game_less_rewards.html new file mode 100644 index 0000000..963021d --- /dev/null +++ b/templates/twitch/game_less_rewards.html @@ -0,0 +1,198 @@ +{% extends "base.html" %} +{% load static %} +{% block title %} + Brand & Site-wide Rewards +{% endblock title %} +{% block extra_head %} + + + +{% endblock extra_head %} +{% block content %} +
+
+

Brand & Site-wide Rewards

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

+ Reward campaigns not tied to a specific Twitch game. These include + brand-specific campaigns (e.g., Minecraft, DOORS) and + truly site-wide Twitch quests. +

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

Active Rewards

+ + + + + + + + + + + {% for campaign in active_campaigns %} + + + + + + + {% endfor %} + +
CampaignBrandTypeEnds
+ + {% if campaign.brand %} + {{ campaign.brand }}: {{ campaign.name }} + {% else %} + {{ campaign.name }} + {% endif %} + + {% if campaign.summary %} +
+ {{ campaign.summary }} + {% endif %} +
{{ campaign.brand|default:"—" }} + {% if campaign.is_sitewide %} + Site-wide + {% else %} + Brand + {% endif %} + + +
+
+ {% endif %} + {% if upcoming_campaigns %} +
+

Upcoming Rewards

+ + + + + + + + + + + {% for campaign in upcoming_campaigns %} + + + + + + + {% endfor %} + +
CampaignBrandTypeStarts
+ + {% if campaign.brand %} + {{ campaign.brand }}: {{ campaign.name }} + {% else %} + {{ campaign.name }} + {% endif %} + + {% if campaign.summary %} +
+ {{ campaign.summary }} + {% endif %} +
{{ campaign.brand|default:"—" }} + {% if campaign.is_sitewide %} + Site-wide + {% else %} + Brand + {% endif %} + + +
+
+ {% endif %} + {% if expired_campaigns %} +
+

Past Rewards

+ + + + + + + + + + + {% for campaign in expired_campaigns %} + + + + + + + {% endfor %} + +
CampaignBrandTypeEnded
+ + {% if campaign.brand %} + {{ campaign.brand }}: {{ campaign.name }} + {% else %} + {{ campaign.name }} + {% endif %} + + {% if campaign.summary %} +
+ {{ campaign.summary }} + {% endif %} +
{{ campaign.brand|default:"—" }} + {% if campaign.is_sitewide %} + Site-wide + {% else %} + Brand + {% endif %} + + +
+
+ {% endif %} + {% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %} +
+
🏷️
+

No Rewards Found

+

No brand or site-wide reward campaigns are currently available.

+
+ {% endif %} +
+{% endblock content %} diff --git a/templates/twitch/reward_campaign_list.html b/templates/twitch/reward_campaign_list.html index 6d8701a..5ef6590 100644 --- a/templates/twitch/reward_campaign_list.html +++ b/templates/twitch/reward_campaign_list.html @@ -30,17 +30,19 @@

- [rss] - [atom] - [discord] - [explain] - [api] - [sitewide] +
+ [explain] + [api] +
+
+ [rss] + [atom] + [discord] +
{% if reward_campaigns %} {% with active_campaigns=reward_campaigns|dictsortreversed:"ends_at" %} @@ -68,6 +70,8 @@ {{ campaign.game.display_name }} {% elif campaign.is_sitewide %} Site-wide + {% else %} + [no game] {% endif %} @@ -102,6 +106,8 @@ {{ campaign.game.display_name }} {% elif campaign.is_sitewide %} Site-wide + {% else %} + [no game] {% endif %} @@ -137,6 +143,8 @@ {{ campaign.game.display_name }} {% elif campaign.is_sitewide %} Site-wide + {% else %} + [no game] {% endif %} diff --git a/templates/twitch/sitewide_rewards.html b/templates/twitch/sitewide_rewards.html index 1f574ce..3e6b2fa 100644 --- a/templates/twitch/sitewide_rewards.html +++ b/templates/twitch/sitewide_rewards.html @@ -16,6 +16,18 @@ type="application/atom+xml" title="Site-wide rewards (Discord)" href="{% url 'core:sitewide_reward_feed_discord' %}" /> + + + {% endblock extra_head %} {% block content %}
@@ -39,6 +51,8 @@ [discord] [explain] + [no game] {% if active_campaigns %} diff --git a/twitch/feeds.py b/twitch/feeds.py index b1087a9..3cc4ac3 100644 --- a/twitch/feeds.py +++ b/twitch/feeds.py @@ -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("

{}

", 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)) + + if item.brand: + parts.append( + format_html("

Brand: {}

", item.brand), + ) + + if item.is_sitewide: + 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"] + 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("

{}

", 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)) + + if item.brand: + parts.append( + format_html("

Brand: {}

", item.brand), + ) + + if item.is_sitewide: + 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:game_less_reward_feed_discord") diff --git a/twitch/urls.py b/twitch/urls.py index 8fd6147..b114c9e 100644 --- a/twitch/urls.py +++ b/twitch/urls.py @@ -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/", diff --git a/twitch/views.py b/twitch/views.py index 5af0eea..910a62c 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -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)