Add site-wide rewards page with RSS/Atom/Discord feeds and tests
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
This commit is contained in:
parent
decc8ae5ab
commit
9de0f52af8
8 changed files with 615 additions and 3 deletions
21
core/urls.py
21
core/urls.py
|
|
@ -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/",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
172
templates/twitch/sitewide_rewards.html
Normal file
172
templates/twitch/sitewide_rewards.html
Normal 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> > <a href="{% url 'twitch:reward_campaign_list' %}">Reward Campaigns</a> > 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 %}
|
||||||
236
twitch/feeds.py
236
twitch/feeds.py
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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/",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue