Add rewards to more pages as we have support for them now
All checks were successful
Deploy to Server / deploy (push) Successful in 26s

This commit is contained in:
Joakim Hellsén 2026-06-14 20:48:46 +02:00
commit 37c1390100
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
16 changed files with 966 additions and 216 deletions

View file

@ -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 GameRewardCampaignAtomFeed
from twitch.feeds import GameRewardCampaignDiscordFeed
from twitch.feeds import GameRewardCampaignFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationDiscordFeed
from twitch.feeds import OrganizationRSSFeed
@ -93,6 +96,12 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=OrganizationRSSFeed(),
name="organization_feed",
),
# /rss/games/<twitch_id>/rewards/ - active reward campaigns for a specific game
path(
route="rss/games/<str:twitch_id>/rewards/",
view=GameRewardCampaignFeed(),
name="game_reward_feed",
),
# /rss/reward-campaigns/ - all active reward campaigns
path(
route="rss/reward-campaigns/",
@ -120,6 +129,11 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=OrganizationAtomFeed(),
name="organization_feed_atom",
),
path(
route="atom/games/<str:twitch_id>/rewards/",
view=GameRewardCampaignAtomFeed(),
name="game_reward_feed_atom",
),
path(
route="atom/reward-campaigns/",
view=RewardCampaignAtomFeed(),
@ -146,6 +160,11 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=OrganizationDiscordFeed(),
name="organization_feed_discord",
),
path(
route="discord/games/<str:twitch_id>/rewards/",
view=GameRewardCampaignDiscordFeed(),
name="game_reward_feed_discord",
),
path(
route="discord/reward-campaigns/",
view=RewardCampaignDiscordFeed(),

View file

@ -263,7 +263,8 @@
<br />
CC0; Information wants to be free.
<br />
Data retrival possible because of <a href="https://github.com/DevilXD/TwitchDropsMiner">DevilXD/TwitchDropsMiner</a>.
Data retrival possible because of <a href="https://github.com/DevilXD/TwitchDropsMiner">DevilXD/TwitchDropsMiner</a>
and <a href="https://github.com/SunkwiBOT/twitch-drops-api">SunkwiBOT/twitch-drops-api</a>.
<br />
Data fetched at :01, :16, :31, and :46.
</div>

View file

@ -38,72 +38,81 @@
</div>
<!-- Campaign description -->
<p>{{ campaign.description|linebreaksbr }}</p>
<div>
Published
{% if campaign.scraped_at %}
<time datetime="{{ campaign.scraped_at|date:'c' }}"
title="{{ campaign.scraped_at|date:'DATETIME_FORMAT' }}">{{ campaign.scraped_at|date:"M d, Y H:i" }}</time> ({{ campaign.scraped_at|timesince }} ago)
{% elif campaign.start_date %}
<time datetime="{{ campaign.start_date|date:'c' }}"
title="{{ campaign.start_date|date:'DATETIME_FORMAT' }}">{{ campaign.start_date|date:"M d, Y H:i" }}</time> ({{ campaign.start_date|timesince }} ago)
{% else %}
unknown
{% endif %}
</div>
<div>
Last updated
{% if campaign.scraped_at %}
<time datetime="{{ campaign.scraped_at|date:'c' }}"
title="{{ campaign.scraped_at|date:'DATETIME_FORMAT' }}">{{ campaign.scraped_at|date:"M d, Y H:i" }}</time> ({{ campaign.scraped_at|timesince }} ago)
{% elif campaign.updated_at %}
<time datetime="{{ campaign.updated_at|date:'c' }}"
title="{{ campaign.updated_at|date:'DATETIME_FORMAT' }}">{{ campaign.updated_at|date:"M d, Y H:i" }}</time> ({{ campaign.updated_at|timesince }} ago)
{% else %}
unknown
{% endif %}
</div>
<!-- Campaign end times -->
<div>
{% if campaign.end_date %}
{% if campaign.end_date < now %}
Ended
<time datetime="{{ campaign.end_date|date:'c' }}"
title="{{ campaign.end_date|date:'DATETIME_FORMAT' }}">{{ campaign.end_date|date:"M d, Y H:i" }}</time> ({{ campaign.end_date|timesince }} ago)
{% else %}
Ends in
<time datetime="{{ campaign.end_date|date:'c' }}"
title="{{ campaign.end_date|date:'DATETIME_FORMAT' }}">{{ campaign.end_date|date:"M d, Y H:i" }}</time> (in {{ campaign.end_date|timeuntil }})
{% endif %}
{% else %}
Ends unknown
{% endif %}
</div>
<!-- Campaign start times -->
<div>
{% if campaign.start_date %}
{% if campaign.start_date > now %}
Starts in
<time datetime="{{ campaign.start_date|date:'c' }}"
title="{{ campaign.start_date|date:'DATETIME_FORMAT' }}">{{ campaign.start_date|date:"M d, Y H:i" }}</time> (in {{ campaign.start_date|timeuntil }})
{% else %}
Started
<dl>
<dt>Published</dt>
<dd>
{% if campaign.scraped_at %}
<time datetime="{{ campaign.scraped_at|date:'c' }}"
title="{{ campaign.scraped_at|date:'DATETIME_FORMAT' }}">{{ campaign.scraped_at|date:"M d, Y H:i" }}</time> ({{ campaign.scraped_at|timesince }} ago)
{% elif campaign.start_date %}
<time datetime="{{ campaign.start_date|date:'c' }}"
title="{{ campaign.start_date|date:'DATETIME_FORMAT' }}">{{ campaign.start_date|date:"M d, Y H:i" }}</time> ({{ campaign.start_date|timesince }} ago)
{% else %}
unknown
{% endif %}
{% else %}
Starts unknown
{% endif %}
</div>
<!-- Campaign duration -->
<div>
Duration is
{% if campaign.start_date and campaign.end_date %}
<time datetime="P{{ campaign.end_date|date:'Y' }}-{{ campaign.end_date|date:'m' }}-{{ campaign.end_date|date:'d' }}T{{ campaign.end_date|date:'H' }}:00:00"
title="{{ campaign.start_date|date:'DATETIME_FORMAT' }} to {{ campaign.end_date|date:'DATETIME_FORMAT' }}">{{ campaign.end_date|timeuntil:campaign.start_date }}</time>
{% else %}
unknown
{% endif %}
</div>
</dd>
<dt>Last updated</dt>
<dd>
{% if campaign.scraped_at %}
<time datetime="{{ campaign.scraped_at|date:'c' }}"
title="{{ campaign.scraped_at|date:'DATETIME_FORMAT' }}">{{ campaign.scraped_at|date:"M d, Y H:i" }}</time> ({{ campaign.scraped_at|timesince }} ago)
{% elif campaign.updated_at %}
<time datetime="{{ campaign.updated_at|date:'c' }}"
title="{{ campaign.updated_at|date:'DATETIME_FORMAT' }}">{{ campaign.updated_at|date:"M d, Y H:i" }}</time> ({{ campaign.updated_at|timesince }} ago)
{% else %}
unknown
{% endif %}
</dd>
<dt>
{% if campaign.end_date and campaign.end_date < now %}
Ended
{% else %}
Ends
{% endif %}
</dt>
<dd>
{% if campaign.end_date %}
<time datetime="{{ campaign.end_date|date:'c' }}"
title="{{ campaign.end_date|date:'DATETIME_FORMAT' }}">{{ campaign.end_date|date:"M d, Y H:i" }}</time>
{% if campaign.end_date < now %}
({{ campaign.end_date|timesince }} ago)
{% else %}
(in {{ campaign.end_date|timeuntil }})
{% endif %}
{% else %}
unknown
{% endif %}
</dd>
<dt>
{% if campaign.start_date and campaign.start_date > now %}
Starts
{% else %}
Started
{% endif %}
</dt>
<dd>
{% if campaign.start_date %}
<time datetime="{{ campaign.start_date|date:'c' }}"
title="{{ campaign.start_date|date:'DATETIME_FORMAT' }}">{{ campaign.start_date|date:"M d, Y H:i" }}</time>
{% if campaign.start_date > now %}
(in {{ campaign.start_date|timeuntil }})
{% else %}
({{ campaign.start_date|timesince }} ago)
{% endif %}
{% else %}
unknown
{% endif %}
</dd>
<dt>Duration</dt>
<dd>
{% if campaign.start_date and campaign.end_date %}
<time datetime="P{{ campaign.end_date|date:'Y' }}-{{ campaign.end_date|date:'m' }}-{{ campaign.end_date|date:'d' }}T{{ campaign.end_date|date:'H' }}:00:00"
title="{{ campaign.start_date|date:'DATETIME_FORMAT' }} to {{ campaign.end_date|date:'DATETIME_FORMAT' }}">{{ campaign.end_date|timeuntil:campaign.start_date }}</time>
{% else %}
unknown
{% endif %}
</dd>
</dl>
<!-- Buttons -->
<div>
{% if campaign.pc_link_url %}<a href="{{ campaign.pc_link_url }}" target="_blank">[details]</a>{% endif %}

View file

@ -31,11 +31,10 @@
{% endblock extra_head %}
{% block content %}
<main>
<h1>Active Drops Dashboard</h1>
<h1>Open Drop Campaigns</h1>
<p>
A combined overview of currently active Twitch and Kick drops campaigns.
<br />
Click any campaign to open details.
Some Drops campaigns may not be available in your region.
<a href="https://help.twitch.tv/s/article/mission-based-drops">Learn more about Drops</a>.
</p>
<hr />
<section id="twitch-campaigns-section">
@ -173,32 +172,39 @@
padding-top: 1rem">
<header style="margin-bottom: 1rem;">
<h2 style="margin: 0 0 0.5rem 0;">
<a href="{% url 'twitch:reward_campaign_list' %}">Twitch Reward Campaigns (Quest Rewards)</a>
<a href="{% url 'twitch:reward_campaign_list' %}">Open Reward Campaigns</a>
</h2>
<p>
Rewards are limited-time offers from top brands that you can unlock by
supporting your favorite Twitch creators. Visit <a href="https://www.twitch.tv/drops/inventory">your inventory</a>
to access unlocked rewards.
</p>
</header>
<div style="display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
<ul>
{% for campaign in active_reward_campaigns %}
<article id="reward-campaign-{{ campaign.twitch_id }}"
style="border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem">
<h3 style="margin: 0 0 0.5rem 0;">
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
{% if campaign.brand %}
{{ campaign.brand }}: {{ campaign.name }}
{% else %}
{{ campaign.name }}
{% endif %}
</a>
</h3>
{% if campaign.summary %}
<p style="font-size: 0.9rem; color: #555; margin: 0.5rem 0;">{{ campaign.summary }}</p>
{% endif %}
<div style="font-size: 0.85rem; color: #666;">
<li id="reward-campaign-{{ campaign.twitch_id }}"
style="display: flex;
gap: 1rem;
margin-bottom: 1rem;
align-items: flex-start">
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}"
style="flex-shrink: 0;
display: block;
line-height: 0">
{% picture campaign.image_best_url alt="Image for "|add:campaign.name width=120 %}
</a>
<div>
<h3 style="margin: 0;">
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
{% if campaign.brand %}
{{ campaign.brand }}: {{ campaign.name }}
{% else %}
{{ campaign.name }}
{% endif %}
</a>
</h3>
{% if campaign.ends_at %}
<p style="margin: 0.25rem 0;">
<p>
<strong>Ends:</strong>
<time datetime="{{ campaign.ends_at|date:'c' }}"
title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}">
@ -206,20 +212,25 @@
</time>
</p>
{% endif %}
{% if campaign.summary %}
<p>{{ campaign.summary }}</p>
{% endif %}
</div>
<div>
{% if campaign.game %}
<p style="margin: 0.25rem 0;">
<p>
<strong>Game:</strong>
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
</p>
{% elif campaign.is_sitewide %}
<p style="margin: 0.25rem 0;">
<p>
<strong>Type:</strong> Site-wide reward campaign
</p>
{% endif %}
</div>
</article>
</li>
{% endfor %}
</div>
</ul>
</section>
{% endif %}
<section id="kick-campaigns-section"

View file

@ -14,6 +14,14 @@
upstream content and cannot guarantee upstream accuracy or permanence.
</p>
<p>Note that some drops has missing or incomplete data due to Twitch API limitations.</p>
<p>
Reward campaign data is sourced from
<a href="https://github.com/SunkwiBOT/twitch-drops-api">SunkwiBOT/twitch-drops-api</a>.
Raw JSON exports are available in the repository:
<a href="https://github.com/SunkwiBOT/twitch-drops-api/blob/main/drops.json">drops.json</a>
and
<a href="https://github.com/SunkwiBOT/twitch-drops-api/blob/main/rewards.json">rewards.json</a>.
</p>
<p>
Need a special format for your workflow or research pipeline?
<a href="https://github.com/TheLovinator1/ttvdrops/issues">Contact me via GitHub issues</a>

View file

@ -127,11 +127,15 @@
{% if game %}
<section>
<h2>Filtered RSS Feeds</h2>
<p>You can subscribe to RSS feeds scoped to a specific game.</p>
<p>
You can subscribe to RSS feeds scoped to a specific game.
The Rewards feed will be empty if the game has no active reward campaigns.
</p>
<table>
<thead>
<tr>
<th>Game</th>
<th>Feed Type</th>
<th>RSS</th>
<th>Atom</th>
<th>Discord</th>
@ -139,20 +143,39 @@
</thead>
<tbody>
<tr>
<td>{{ game.display_name }}</td>
<td rowspan="2">{{ game.display_name }}</td>
<td>Campaigns</td>
<td>
<a href="{% url 'core:game_campaign_feed' game.twitch_id %}">
https://ttvdrops.lovinator.space/rss/games/{{ game.twitch_id }}/
https://ttvdrops.lovinator.space/rss/games/{{ game.twitch_id }}/campaigns/
</a>
</td>
<td>
<a href="{% url 'core:game_campaign_feed_atom' game.twitch_id %}">
https://ttvdrops.lovinator.space/atom/games/{{ game.twitch_id }}/
https://ttvdrops.lovinator.space/atom/games/{{ game.twitch_id }}/campaigns/
</a>
</td>
<td>
<a href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}">
https://ttvdrops.lovinator.space/discord/games/{{ game.twitch_id }}/
https://ttvdrops.lovinator.space/discord/games/{{ game.twitch_id }}/campaigns/
</a>
</td>
</tr>
<tr>
<td>Rewards</td>
<td>
<a href="{% url 'core:game_reward_feed' game.twitch_id %}">
https://ttvdrops.lovinator.space/rss/games/{{ game.twitch_id }}/rewards/
</a>
</td>
<td>
<a href="{% url 'core:game_reward_feed_atom' game.twitch_id %}">
https://ttvdrops.lovinator.space/atom/games/{{ game.twitch_id }}/rewards/
</a>
</td>
<td>
<a href="{% url 'core:game_reward_feed_discord' game.twitch_id %}">
https://ttvdrops.lovinator.space/discord/games/{{ game.twitch_id }}/rewards/
</a>
</td>
</tr>

View file

@ -51,61 +51,74 @@
<a href="{% url 'kick:campaign_list' %}">Campaigns</a> >
{{ campaign.name }}
</div>
<div>
<dl>
{% if campaign.category %}
Category:
<a href="{% url 'kick:game_detail' campaign.category.kick_id %}">{{ campaign.category.name }}</a>
<dt>Category</dt>
<dd>
<a href="{% url 'kick:game_detail' campaign.category.kick_id %}">{{ campaign.category.name }}</a>
</dd>
{% endif %}
</div>
<div>
{% if campaign.organization %}
Organization:
<a href="{% url 'kick:organization_detail' campaign.organization.kick_id %}">{{ campaign.organization.name }}</a>
<dt>Organization</dt>
<dd>
<a href="{% url 'kick:organization_detail' campaign.organization.kick_id %}">{{ campaign.organization.name }}</a>
</dd>
{% endif %}
</div>
{% if campaign.ends_at %}
<div>
{% if campaign.ends_at > now %}
Ends
{% else %}
Ended at
{% endif %}
<time datetime="{{ campaign.ends_at|date:'c' }}"
title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}">{{ campaign.ends_at|date:"M d, Y H:i" }}</time>
{% if campaign.ends_at < now %}
(ended {{ campaign.ends_at|timesince }} ago)
{% else %}
(in {{ campaign.ends_at|timeuntil }})
{% endif %}
</div>
{% endif %}
{% if campaign.starts_at %}
<div>
{% if campaign.starts_at > now %}
Starts
{% else %}
Started at
{% endif %}
<time datetime="{{ campaign.starts_at|date:'c' }}"
title="{{ campaign.starts_at|date:'DATETIME_FORMAT' }}">{{ campaign.starts_at|date:"M d, Y H:i" }}</time>
{% if campaign.starts_at < now %}
(started {{ campaign.starts_at|timesince }} ago)
{% else %}
(in {{ campaign.starts_at|timeuntil }})
{% endif %}
</div>
{% endif %}
{% if campaign.duration %}<div>Duration: {{ campaign.duration }}</div>{% endif %}
<div>
Added <time datetime="{{ campaign.added_at|date:'c' }}"
title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}">{{ campaign.added_at|date:"M d, Y" }}</time>
({{ campaign.added_at|timesince }} ago)
</div>
<div>
Updated <time datetime="{{ campaign.updated_at|date:'c' }}"
title="{{ campaign.updated_at|date:'DATETIME_FORMAT' }}">{{ campaign.updated_at|date:"M d, Y" }}</time>
({{ campaign.updated_at|timesince }} ago)
</div>
{% if campaign.ends_at %}
<dt>
{% if campaign.ends_at > now %}
Ends
{% else %}
Ended at
{% endif %}
</dt>
<dd>
<time datetime="{{ campaign.ends_at|date:'c' }}"
title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}">{{ campaign.ends_at|date:"M d, Y H:i" }}</time>
{% if campaign.ends_at < now %}
(ended {{ campaign.ends_at|timesince }} ago)
{% else %}
(in {{ campaign.ends_at|timeuntil }})
{% endif %}
</dd>
{% endif %}
{% if campaign.starts_at %}
<dt>
{% if campaign.starts_at > now %}
Starts
{% else %}
Started at
{% endif %}
</dt>
<dd>
<time datetime="{{ campaign.starts_at|date:'c' }}"
title="{{ campaign.starts_at|date:'DATETIME_FORMAT' }}">{{ campaign.starts_at|date:"M d, Y H:i" }}</time>
{% if campaign.starts_at < now %}
(started {{ campaign.starts_at|timesince }} ago)
{% else %}
(in {{ campaign.starts_at|timeuntil }})
{% endif %}
</dd>
{% endif %}
{% if campaign.duration %}
<dt>Duration</dt>
<dd>
{{ campaign.duration }}
</dd>
{% endif %}
<dt>Added</dt>
<dd>
<time datetime="{{ campaign.added_at|date:'c' }}"
title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}">{{ campaign.added_at|date:"M d, Y" }}</time>
({{ campaign.added_at|timesince }} ago)
</dd>
<dt>Updated</dt>
<dd>
<time datetime="{{ campaign.updated_at|date:'c' }}"
title="{{ campaign.updated_at|date:'DATETIME_FORMAT' }}">{{ campaign.updated_at|date:"M d, Y" }}</time>
({{ campaign.updated_at|timesince }} ago)
</dd>
</dl>
<div>
{% if campaign.channels.all %}
Participating channels:

View file

@ -63,20 +63,36 @@
<a href="{{ category.kick_url }}">{{ category.kick_url }}</a>
</p>
{% endif %}
<p style="margin: 0.25rem 0; font-size: 0.85rem; color: #666;">
ID: {{ category.kick_id }}
- Added: <time datetime="{{ category.added_at|date:'c' }}"
title="{{ category.added_at|date:'DATETIME_FORMAT' }}">{{ category.added_at|date:"M d, Y" }}</time>
({{ category.added_at|timesince }} ago)
- Updated: <time datetime="{{ category.updated_at|date:'c' }}"
title="{{ category.updated_at|date:'DATETIME_FORMAT' }}">{{ category.updated_at|date:"M d, Y" }}</time>
({{ category.updated_at|timesince }} ago)
</p>
<p style="margin: 0.25rem 0; color: #666;">
Active: {{ active_campaigns|length }}
- Upcoming: {{ upcoming_campaigns|length }}
- Expired: {{ expired_campaigns|length }}
</p>
<dl>
<dt>ID</dt>
<dd>
{{ category.kick_id }}
</dd>
<dt>Added</dt>
<dd>
<time datetime="{{ category.added_at|date:'c' }}"
title="{{ category.added_at|date:'DATETIME_FORMAT' }}">{{ category.added_at|date:"M d, Y" }}</time>
({{ category.added_at|timesince }} ago)
</dd>
<dt>Updated</dt>
<dd>
<time datetime="{{ category.updated_at|date:'c' }}"
title="{{ category.updated_at|date:'DATETIME_FORMAT' }}">{{ category.updated_at|date:"M d, Y" }}</time>
({{ category.updated_at|timesince }} ago)
</dd>
<dt>Active</dt>
<dd>
{{ active_campaigns|length }}
</dd>
<dt>Upcoming</dt>
<dd>
{{ upcoming_campaigns|length }}
</dd>
<dt>Expired</dt>
<dd>
{{ expired_campaigns|length }}
</dd>
</dl>
</div>
</div>
<hr />

View file

@ -31,15 +31,24 @@
<a href="{{ org.url }}">{{ org.url }}</a>
</p>
{% endif %}
<p style="margin: 0.25rem 0; font-size: 0.85rem; color: #666;">
ID: {{ org.kick_id }}
- Added: <time datetime="{{ org.added_at|date:'c' }}"
title="{{ org.added_at|date:'DATETIME_FORMAT' }}">{{ org.added_at|date:"M d, Y" }}</time>
({{ org.added_at|timesince }} ago)
- Updated: <time datetime="{{ org.updated_at|date:'c' }}"
title="{{ org.updated_at|date:'DATETIME_FORMAT' }}">{{ org.updated_at|date:"M d, Y" }}</time>
({{ org.updated_at|timesince }} ago)
</p>
<dl>
<dt>ID</dt>
<dd>
{{ org.kick_id }}
</dd>
<dt>Added</dt>
<dd>
<time datetime="{{ org.added_at|date:'c' }}"
title="{{ org.added_at|date:'DATETIME_FORMAT' }}">{{ org.added_at|date:"M d, Y" }}</time>
({{ org.added_at|timesince }} ago)
</dd>
<dt>Updated</dt>
<dd>
<time datetime="{{ org.updated_at|date:'c' }}"
title="{{ org.updated_at|date:'DATETIME_FORMAT' }}">{{ org.updated_at|date:"M d, Y" }}</time>
({{ org.updated_at|timesince }} ago)
</dd>
</dl>
</div>
</div>
<hr />

View file

@ -9,23 +9,29 @@
<div>
<a href="{% url 'twitch:dashboard' %}">Twitch</a> > <a href="{% url 'twitch:channel_list' %}">Channels</a> > {{ channel.display_name }}
</div>
{% if channel.display_name != channel.name %}
<div>
Username: <code>{{ channel.name }}</code>
</div>
{% endif %}
<!-- Channel Info -->
<div>Channel ID: {{ channel.twitch_id }}</div>
<div>
Published
<time datetime="{{ channel.added_at|date:'c' }}"
title="{{ channel.added_at|date:'DATETIME_FORMAT' }}">{{ channel.added_at|date:"M d, Y H:i" }}</time> ({{ channel.added_at|timesince }} ago)
</div>
<div>
Last updated
<time datetime="{{ channel.updated_at|date:'c' }}"
title="{{ channel.updated_at|date:'DATETIME_FORMAT' }}">{{ channel.updated_at|date:"M d, Y H:i" }}</time> ({{ channel.updated_at|timesince }} ago)
</div>
<!-- Channel metadata -->
<dl>
{% if channel.display_name != channel.name %}
<dt>Username</dt>
<dd>
<code>{{ channel.name }}</code>
</dd>
{% endif %}
<dt>Channel ID</dt>
<dd>
{{ channel.twitch_id }}
</dd>
<dt>Published</dt>
<dd>
<time datetime="{{ channel.added_at|date:'c' }}"
title="{{ channel.added_at|date:'DATETIME_FORMAT' }}">{{ channel.added_at|date:"M d, Y H:i" }}</time> ({{ channel.added_at|timesince }} ago)
</dd>
<dt>Last updated</dt>
<dd>
<time datetime="{{ channel.updated_at|date:'c' }}"
title="{{ channel.updated_at|date:'DATETIME_FORMAT' }}">{{ channel.updated_at|date:"M d, Y H:i" }}</time> ({{ channel.updated_at|timesince }} ago)
</dd>
</dl>
<!-- Twitch Stream Embed -->
<iframe src="https://player.twitch.tv/?channel={{ channel.name }}&parent={{ request.get_host }}&muted=false"
height="480"

View file

@ -17,6 +17,18 @@
type="application/atom+xml"
title="{{ game.display_name }} campaigns (Discord)"
href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}" />
<link rel="alternate"
type="application/rss+xml"
title="{{ game.display_name }} rewards (RSS)"
href="{% url 'core:game_reward_feed' game.twitch_id %}" />
<link rel="alternate"
type="application/atom+xml"
title="{{ game.display_name }} rewards (Atom)"
href="{% url 'core:game_reward_feed_atom' game.twitch_id %}" />
<link rel="alternate"
type="application/atom+xml"
title="{{ game.display_name }} rewards (Discord)"
href="{% url 'core:game_reward_feed_discord' game.twitch_id %}" />
{% endif %}
{% endblock extra_head %}
{% block content %}
@ -37,34 +49,48 @@
<div>
<a href="{% url 'twitch:dashboard' %}">Twitch</a> > <a href="{% url 'twitch:games_grid' %}">Games</a> > {{ game.display_name }}
</div>
<!-- Game owner -->
{% if owners %}
<div style="font-size: 0.9em;">
Owned by
{% for owner in owners %}
<a href="{% url 'twitch:organization_detail' owner.twitch_id %}">{{ owner.name }}</a>
{% if not forloop.last %}-{% endif %}
{% endfor %}
</div>
{% endif %}
<div>
Published
<time datetime="{{ game.added_at|date:'c' }}"
title="{{ game.added_at|date:'DATETIME_FORMAT' }}">{{ game.added_at|date:"M d, Y H:i" }}</time> ({{ game.added_at|timesince }} ago)
</div>
<div>
Last updated
<time datetime="{{ game.updated_at|date:'c' }}"
title="{{ game.updated_at|date:'DATETIME_FORMAT' }}">{{ game.updated_at|date:"M d, Y H:i" }}</time> ({{ game.updated_at|timesince }} ago)
</div>
<!-- RSS Feeds -->
<!-- Game metadata -->
<dl>
{% if owners %}
<dt>Owned by</dt>
<dd>
{% for owner in owners %}
<a href="{% url 'twitch:organization_detail' owner.twitch_id %}">{{ owner.name }}</a>
{% if not forloop.last %}-{% endif %}
{% endfor %}
</dd>
{% endif %}
<dt>Published</dt>
<dd>
<time datetime="{{ game.added_at|date:'c' }}"
title="{{ game.added_at|date:'DATETIME_FORMAT' }}">{{ game.added_at|date:"M d, Y H:i" }}</time> ({{ game.added_at|timesince }} ago)
</dd>
<dt>Last updated</dt>
<dd>
<time datetime="{{ game.updated_at|date:'c' }}"
title="{{ game.updated_at|date:'DATETIME_FORMAT' }}">{{ game.updated_at|date:"M d, Y H:i" }}</time> ({{ game.updated_at|timesince }} ago)
</dd>
</dl>
<!-- Campaign Feeds -->
<div>
Campaigns:
<a href="{% url 'core:game_campaign_feed' game.twitch_id %}"
title="RSS feed for {{ game.display_name }} campaigns">[rss]</a>
<a href="{% url 'core:game_campaign_feed_atom' game.twitch_id %}"
title="Atom feed for {{ game.display_name }} campaigns">[atom]</a>
<a href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}"
title="Discord feed for {{ game.display_name }} campaigns">[discord]</a>
</div>
<div>
Rewards:
<a href="{% url 'core:game_reward_feed' game.twitch_id %}"
title="RSS feed for {{ game.display_name }} rewards">[rss]</a>
<a href="{% url 'core:game_reward_feed_atom' game.twitch_id %}"
title="Atom feed for {{ game.display_name }} rewards">[atom]</a>
<a href="{% url 'core:game_reward_feed_discord' game.twitch_id %}"
title="Discord feed for {{ game.display_name }} rewards">[discord]</a>
</div>
<div>
<a href="{% url 'twitch:twitch-api-v1:get_game' game.twitch_id %}"
title="Twitch game API">[api]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
@ -163,4 +189,120 @@
{% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %}
<p id="no-campaigns-message">No campaigns found for this game.</p>
{% endif %}
{% if active_rewards %}
<h5 id="active-rewards-header">Active Rewards</h5>
<table id="active-rewards-table">
<tbody>
{% for reward in active_rewards %}
<tr id="reward-row-{{ reward.twitch_id }}">
<td style="width: 130px; vertical-align: top;">
<a href="{% url 'twitch:reward_campaign_detail' reward.twitch_id %}">
{% picture reward.image_best_url alt="Image for "|add:reward.name width=120 %}
</a>
</td>
<td style="vertical-align: top;">
<a href="{% url 'twitch:reward_campaign_detail' reward.twitch_id %}">
{% if reward.brand %}{{ reward.brand }}:{% endif %}
{{ reward.name }}
</a>
{% if reward.is_sitewide %}
<span title="Sitewide reward">[Sitewide]</span>
{% endif %}
{% if reward.summary %}
<br />
<small>{{ reward.summary|truncatewords:30|linebreaksbr }}</small>
{% endif %}
</td>
<td style="vertical-align: top;">
<span title="{{ reward.ends_at|date:'M d, Y H:i' }}">Ends in {{ reward.ends_at|timeuntil }}</span>
</td>
<td style="vertical-align: top; white-space: nowrap;">
{% if reward.about_url and reward.external_url and reward.about_url == reward.external_url %}
<a href="{{ reward.about_url }}">About / Redeem</a>
{% else %}
{% if reward.about_url %}<a href="{{ reward.about_url }}">About</a>{% endif %}
{% if reward.external_url %}· <a href="{{ reward.external_url }}">Redeem</a>{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if upcoming_rewards %}
<h5 id="upcoming-rewards-header">Upcoming Rewards</h5>
<table id="upcoming-rewards-table">
<tbody>
{% for reward in upcoming_rewards %}
<tr id="reward-row-{{ reward.twitch_id }}">
<td style="width: 130px; vertical-align: top;">
<a href="{% url 'twitch:reward_campaign_detail' reward.twitch_id %}">
{% picture reward.image_best_url alt="Image for "|add:reward.name width=120 %}
</a>
</td>
<td style="vertical-align: top;">
<a href="{% url 'twitch:reward_campaign_detail' reward.twitch_id %}">
{% if reward.brand %}{{ reward.brand }}:{% endif %}
{{ reward.name }}
</a>
{% if reward.is_sitewide %}<span title="Sitewide reward">[Sitewide]</span>{% endif %}
{% if reward.summary %}
<br />
<small>{{ reward.summary|truncatewords:30|linebreaksbr }}</small>
{% endif %}
</td>
<td style="vertical-align: top;">
<span title="Starts on {{ reward.starts_at|date:'M d, Y H:i' }}">Starts in {{ reward.starts_at|timeuntil }}</span>
</td>
<td style="vertical-align: top; white-space: nowrap;">
{% if reward.about_url and reward.external_url and reward.about_url == reward.external_url %}
<a href="{{ reward.about_url }}">About / Redeem</a>
{% else %}
{% if reward.about_url %}<a href="{{ reward.about_url }}">About</a>{% endif %}
{% if reward.external_url %} · <a href="{{ reward.external_url }}">Redeem</a>{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if expired_rewards %}
<h5 id="expired-rewards-header">Past Rewards</h5>
<table id="expired-rewards-table">
<tbody>
{% for reward in expired_rewards %}
<tr id="reward-row-{{ reward.twitch_id }}">
<td style="width: 130px; vertical-align: top;">
<a href="{% url 'twitch:reward_campaign_detail' reward.twitch_id %}">
{% picture reward.image_best_url alt="Image for "|add:reward.name width=120 %}
</a>
</td>
<td style="vertical-align: top;">
<a href="{% url 'twitch:reward_campaign_detail' reward.twitch_id %}">
{% if reward.brand %}{{ reward.brand }}:{% endif %}
{{ reward.name }}
</a>
{% if reward.is_sitewide %}<span title="Sitewide reward">[Sitewide]</span>{% endif %}
{% if reward.summary %}
<br />
<small>{{ reward.summary|truncatewords:30|linebreaksbr }}</small>
{% endif %}
</td>
<td style="vertical-align: top;">
<span title="Ended on {{ reward.ends_at|date:'M d, Y H:i' }}">{{ reward.ends_at|timesince }} ago</span>
</td>
<td style="vertical-align: top; white-space: nowrap;">
{% if reward.about_url and reward.external_url and reward.about_url == reward.external_url %}
<a href="{{ reward.about_url }}">About / Redeem</a>
{% else %}
{% if reward.about_url %}<a href="{{ reward.about_url }}">About</a>{% endif %}
{% if reward.external_url %} · <a href="{{ reward.external_url }}">Redeem</a>{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock content %}

View file

@ -26,16 +26,18 @@
<div>
<a href="{% url 'core:dashboard' %}">Dashboard</a> > <a href="{% url 'twitch:org_list' %}">Organizations</a> > {{ organization.name }}
</div>
<div>
Published
<time datetime="{{ organization.added_at|date:'c' }}"
title="{{ organization.added_at|date:'DATETIME_FORMAT' }}">{{ organization.added_at|date:"M d, Y H:i" }}</time> ({{ organization.added_at|timesince }} ago)
</div>
<div>
Last updated
<time datetime="{{ organization.updated_at|date:'c' }}"
title="{{ organization.updated_at|date:'DATETIME_FORMAT' }}">{{ organization.updated_at|date:"M d, Y H:i" }}</time> ({{ organization.updated_at|timesince }} ago)
</div>
<dl>
<dt>Published</dt>
<dd>
<time datetime="{{ organization.added_at|date:'c' }}"
title="{{ organization.added_at|date:'DATETIME_FORMAT' }}">{{ organization.added_at|date:"M d, Y H:i" }}</time> ({{ organization.added_at|timesince }} ago)
</dd>
<dt>Last updated</dt>
<dd>
<time datetime="{{ organization.updated_at|date:'c' }}"
title="{{ organization.updated_at|date:'DATETIME_FORMAT' }}">{{ organization.updated_at|date:"M d, Y H:i" }}</time> ({{ organization.updated_at|timesince }} ago)
</dd>
</dl>
<header>
<h2>Games by {{ organization.name }}</h2>
</header>

View file

@ -164,6 +164,7 @@ class V1GameDetailSchema(V1GameSchema):
"""Twitch game detail response."""
campaigns: list[V1DropCampaignSummarySchema]
reward_campaigns: list[V1RewardCampaignSchema]
class V1ChannelDetailSchema(V1ChannelSchema):
@ -642,6 +643,13 @@ def get_game(request: HttpRequest, twitch_id: str) -> V1GameDetailSchema:
DropCampaign.objects.filter(game=game),
).order_by("-end_at")
now = timezone.now()
reward_campaigns = list(
RewardCampaign.objects
.filter(game=game)
.select_related("game")
.prefetch_related("game__owners")
.order_by("-starts_at"),
)
return V1GameDetailSchema(
twitch_id=game.twitch_id,
slug=game.slug,
@ -659,6 +667,9 @@ def get_game(request: HttpRequest, twitch_id: str) -> V1GameDetailSchema:
campaigns=[
_serialize_campaign_summary(campaign, now) for campaign in campaigns
],
reward_campaigns=[
_serialize_reward_campaign(rc, now) for rc in reward_campaigns
],
)

View file

@ -1695,3 +1695,295 @@ class RewardCampaignDiscordFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
def feed_url(self) -> str:
"""Return the URL to the Discord feed itself."""
return reverse("core:reward_campaign_feed_discord")
# MARK: /rss/games/<twitch_id>/rewards/
class GameRewardCampaignFeed(TTVDropsBaseFeed):
"""RSS feed for the latest reward campaigns of a specific game."""
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 get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002
"""Retrieve the Game instance for the given Twitch ID.
Returns:
Game: The corresponding Game object.
"""
return Game.objects.get(twitch_id=twitch_id)
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 title(self, obj: Game) -> str:
"""Return the feed title for the game reward campaigns."""
return f"TTVDrops: {obj.display_name} Rewards"
def link(self, obj: Game) -> str:
"""Return the absolute URL to the game detail page."""
return reverse("twitch:game_detail", args=[obj.twitch_id])
def description(self, obj: Game) -> str:
"""Return a description for the feed."""
return f"Latest reward campaigns for {obj.display_name}"
def items(self, obj: Game) -> list[RewardCampaign]:
"""Return latest active reward campaigns for this game."""
limit: int = self._limit if self._limit is not None else 200
queryset: QuerySet[RewardCampaign] = _active_reward_campaigns(
RewardCampaign.objects
.filter(game=obj)
.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))
if item.is_sitewide:
parts.append(
SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"),
)
elif item.game:
parts.append(
format_html(
"<p>Game: {}</p>",
item.game.display_name or item.game.name,
),
)
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_pubdate(self, item: RewardCampaign) -> datetime.datetime:
"""Returns the publication date to the feed item.
Uses starts_at (when the reward starts). Fallback to added_at or now if missing.
"""
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 the associated game's name and brand as categories."""
categories: list[str] = ["twitch", "rewards", "quests"]
if item.brand:
categories.append(item.brand)
if item.game:
categories.append(item.game.get_game_name)
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."""
if item.brand:
return item.brand
if item.game and item.game.display_name:
return item.game.display_name
return "Twitch"
def author_name(self, obj: Game) -> str:
"""Return the author name for the game, typically the owner organization name."""
return obj.display_name or "Twitch"
def item_enclosures(self, item: RewardCampaign) -> list[feedgenerator.Enclosure]:
"""Return a list of enclosures for the reward campaign, if available.
Args:
item (RewardCampaign): The reward campaign item.
Returns:
list[feedgenerator.Enclosure]: A list of Enclosure objects if an image URL is
available, otherwise an empty list.
"""
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, obj: Game) -> str:
"""Return the URL to the RSS feed itself."""
return reverse("core:game_reward_feed", args=[obj.twitch_id])
class GameRewardCampaignAtomFeed(TTVDropsAtomBaseFeed, GameRewardCampaignFeed):
"""Atom feed for latest reward campaigns for a specific game (reuses GameRewardCampaignFeed)."""
def feed_url(self, obj: Game) -> str:
"""Return the URL to the Atom feed itself."""
return reverse("core:game_reward_feed_atom", args=[obj.twitch_id])
class GameRewardCampaignDiscordFeed(TTVDropsAtomBaseFeed, GameRewardCampaignFeed):
"""Discord feed for latest reward campaigns for a specific game 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))
if item.is_sitewide:
parts.append(
SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"),
)
elif item.game:
parts.append(
format_html(
"<p>Game: {}</p>",
item.game.display_name or item.game.name,
),
)
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, obj: Game) -> str:
"""Return the URL to the Discord feed itself."""
return reverse("core:game_reward_feed_discord", args=[obj.twitch_id])

View file

@ -21,6 +21,7 @@ from twitch.feeds import RSS_STYLESHEETS
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameFeed
from twitch.feeds import GameRewardCampaignFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignFeed
from twitch.feeds import TTVDropsBaseFeed
@ -297,6 +298,11 @@ class RSSFeedTestCase(TestCase):
{},
f"https://ttvdrops.lovinator.space{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
),
(
"core:game_reward_feed_atom",
{"twitch_id": self.game.twitch_id},
f"https://ttvdrops.lovinator.space{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
),
]
for url_name, kwargs, expected_entry_id in atom_feed_cases:
@ -347,6 +353,7 @@ class RSSFeedTestCase(TestCase):
reverse("core:campaign_feed_atom"),
reverse("core:game_feed_atom"),
reverse("core:game_campaign_feed_atom", args=[self.game.twitch_id]),
reverse("core:game_reward_feed_atom", args=[self.game.twitch_id]),
reverse("core:organization_feed_atom"),
reverse("core:reward_campaign_feed_atom"),
]
@ -425,10 +432,12 @@ class RSSFeedTestCase(TestCase):
reverse("core:game_feed"),
reverse("core:campaign_feed"),
reverse("core:game_campaign_feed", args=[self.game.twitch_id]),
reverse("core:game_reward_feed", args=[self.game.twitch_id]),
reverse("core:reward_campaign_feed"),
reverse("core:game_feed_atom"),
reverse("core:campaign_feed_atom"),
reverse("core:game_campaign_feed_atom", args=[self.game.twitch_id]),
reverse("core:game_reward_feed_atom", args=[self.game.twitch_id]),
reverse("core:reward_campaign_feed_atom"),
]
@ -484,6 +493,7 @@ class RSSFeedTestCase(TestCase):
reverse("core:campaign_feed"),
reverse("core:game_feed"),
reverse("core:game_campaign_feed", args=[self.game.twitch_id]),
reverse("core:game_reward_feed", args=[self.game.twitch_id]),
reverse("core:organization_feed"),
reverse("core:reward_campaign_feed"),
]
@ -516,12 +526,14 @@ class RSSFeedTestCase(TestCase):
GameFeed,
DropCampaignFeed,
GameCampaignFeed,
GameRewardCampaignFeed,
RewardCampaignFeed,
):
feed: (
DropCampaignFeed
| GameCampaignFeed
| GameFeed
| GameRewardCampaignFeed
| OrganizationRSSFeed
| RewardCampaignFeed
) = feed_class()
@ -535,6 +547,7 @@ class RSSFeedTestCase(TestCase):
reverse("core:campaign_feed"),
reverse("core:game_feed"),
reverse("core:game_campaign_feed", args=[self.game.twitch_id]),
reverse("core:game_reward_feed", args=[self.game.twitch_id]),
reverse("core:organization_feed"),
reverse("core:reward_campaign_feed"),
]
@ -799,6 +812,89 @@ class RSSFeedTestCase(TestCase):
assert "Past Reward Campaign" not in content
assert "Upcoming Reward Campaign" not in content
def test_game_reward_campaign_feed(self) -> None:
"""Test game-specific reward campaign feed returns 200."""
url: str = reverse("core:game_reward_feed", args=[self.game.twitch_id])
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8"
assert response["Content-Disposition"] == "inline"
content: str = response.content.decode("utf-8")
assert "Test Game" in content
assert "Test Brand: Test Reward Campaign" in content
def test_game_reward_campaign_feed_filters_correctly(self) -> None:
"""Test game reward feed only shows rewards for that game."""
other_game: Game = Game.objects.create(
twitch_id="other-reward-game-123",
slug="other-reward-game",
name="Other Game Rewards",
display_name="Other Game Rewards",
)
other_game.owners.add(self.org)
RewardCampaign.objects.create(
twitch_id="other-reward-123",
name="Other Reward Campaign",
brand="Other Brand",
starts_at=timezone.now() - timedelta(days=1),
ends_at=timezone.now() + timedelta(days=7),
status="ACTIVE",
summary="Other reward summary",
instructions="Do other things",
external_url="https://example.com/other-reward",
about_url="https://example.com/about-other-reward",
is_sitewide=False,
game=other_game,
)
url: str = reverse("core:game_reward_feed", args=[self.game.twitch_id])
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
content: str = response.content.decode("utf-8")
assert "Test Brand: Test Reward Campaign" in content
assert "Other Brand: Other Reward Campaign" not in content
def test_game_reward_campaign_feed_only_includes_active_campaigns(self) -> None:
"""Game reward campaign feed should exclude old and upcoming campaigns."""
now: datetime.datetime = timezone.now()
RewardCampaign.objects.create(
twitch_id="game-reward-past-123",
name="Game Past Reward",
brand="Test Brand",
starts_at=now - timedelta(days=10),
ends_at=now - timedelta(days=1),
status="EXPIRED",
summary="Past reward",
instructions="Was active",
external_url="https://example.com/past-reward",
about_url="https://example.com/about-past-reward",
is_sitewide=False,
game=self.game,
)
RewardCampaign.objects.create(
twitch_id="game-reward-upcoming-123",
name="Game Upcoming Reward",
brand="Test Brand",
starts_at=now + timedelta(days=1),
ends_at=now + timedelta(days=10),
status="UPCOMING",
summary="Upcoming reward",
instructions="Wait",
external_url="https://example.com/upcoming-reward",
about_url="https://example.com/about-upcoming-reward",
is_sitewide=False,
game=self.game,
)
url: str = reverse("core:game_reward_feed", args=[self.game.twitch_id])
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
content: str = response.content.decode("utf-8")
assert "Test Reward Campaign" in content
assert "Game Past Reward" not in content
assert "Game Upcoming Reward" not in content
def test_game_campaign_feed_enclosure_helpers(self) -> None:
"""GameCampaignFeed helper methods should pull from the model fields."""
feed = GameCampaignFeed()
@ -1106,6 +1202,35 @@ def test_game_campaign_feed_queries_do_not_scale_with_items(
assert response.status_code == 200
@pytest.mark.django_db
def test_game_reward_campaign_feed_queries_bounded(
client: Client,
django_assert_num_queries: QueryAsserter,
) -> None:
"""Game reward campaign feed should stay within a modest query budget."""
org: Organization = Organization.objects.create(
twitch_id="test-org-game-reward-queries",
name="Query Org Game Rewards",
)
game: Game = Game.objects.create(
twitch_id="test-game-reward-queries",
slug="query-game-reward",
name="Query Game Rewards",
display_name="Query Game Rewards",
)
game.owners.add(org)
for i in range(3):
_build_reward_campaign(game, i)
url: str = reverse("core:game_reward_feed", args=[game.twitch_id])
with django_assert_num_queries(2, exact=True):
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_organization_feed_queries_bounded(
client: Client,
@ -1235,6 +1360,7 @@ URL_NAMES: list[tuple[str, dict[str, str]]] = [
("core:campaign_feed", {}),
("core:game_feed", {}),
("core:game_campaign_feed", {"twitch_id": "test-game-123"}),
("core:game_reward_feed", {"twitch_id": "test-game-123"}),
("core:organization_feed", {}),
("core:reward_campaign_feed", {}),
]
@ -1465,6 +1591,11 @@ class DiscordFeedTestCase(TestCase):
{},
f"https://ttvdrops.lovinator.space{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
),
(
"core:game_reward_feed_discord",
{"twitch_id": self.game.twitch_id},
f"https://ttvdrops.lovinator.space{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
),
]
for url_name, kwargs, expected_entry_id in discord_feed_cases:
@ -1490,6 +1621,7 @@ class DiscordFeedTestCase(TestCase):
reverse("core:campaign_feed_discord"),
reverse("core:game_feed_discord"),
reverse("core:game_campaign_feed_discord", args=[self.game.twitch_id]),
reverse("core:game_reward_feed_discord", args=[self.game.twitch_id]),
reverse("core:organization_feed_discord"),
reverse("core:reward_campaign_feed_discord"),
]
@ -1518,6 +1650,19 @@ class DiscordFeedTestCase(TestCase):
)
assert "()" not in content
def test_game_reward_campaign_discord_feed(self) -> None:
"""Test game-specific reward campaign Discord feed returns 200."""
url: str = reverse(
"core:game_reward_feed_discord",
args=[self.game.twitch_id],
)
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/xml; charset=utf-8"
content: str = response.content.decode("utf-8")
assert "<feed" in content
assert "Test Game Discord" in content
def test_discord_reward_campaign_feed_contains_discord_timestamps(self) -> None:
"""Discord reward campaign feed should contain Discord relative timestamps."""
url: str = reverse("core:reward_campaign_feed_discord")
@ -1532,6 +1677,25 @@ class DiscordFeedTestCase(TestCase):
)
assert "()" not in content
def test_discord_game_reward_campaign_feed_contains_discord_timestamps(
self,
) -> None:
"""Discord game reward campaign feed should contain Discord relative timestamps."""
url: str = reverse(
"core:game_reward_feed_discord",
args=[self.game.twitch_id],
)
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
content: str = response.content.decode("utf-8")
# Should contain Discord timestamp format (double-escaped in XML payload)
discord_pattern: re.Pattern[str] = re.compile(r"&amp;lt;t:\d+:R&amp;gt;")
assert discord_pattern.search(content), (
f"Expected Discord timestamp format &amp;lt;t:UNIX_TIMESTAMP:R&amp;gt; in content, got: {content}"
)
assert "()" not in content
def test_feed_links_return_200(self) -> None:
"""Test that all links in the feeds return 200 OK."""
feed_urls: list[str] = [

View file

@ -721,6 +721,27 @@ class GameDetailView(DetailView):
active_campaigns, upcoming_campaigns, expired_campaigns = (
DropCampaign.split_for_channel_detail(campaigns_list, now)
)
# Fetch reward campaigns for this game
all_reward_campaigns: list[RewardCampaign] = list(
RewardCampaign.objects
.filter(game=game)
.select_related("game")
.order_by("-starts_at"),
)
active_rewards: list[RewardCampaign] = []
upcoming_rewards: list[RewardCampaign] = []
expired_rewards: list[RewardCampaign] = []
for rc in all_reward_campaigns:
if rc.starts_at is None or rc.ends_at is None:
continue
if rc.starts_at <= now <= rc.ends_at:
active_rewards.append(rc)
elif rc.starts_at > now:
upcoming_rewards.append(rc)
elif rc.ends_at < now:
expired_rewards.append(rc)
owners: list[Organization] = list(getattr(game, "owners_for_detail", []))
game_name: str = game.get_game_name
@ -802,6 +823,9 @@ class GameDetailView(DetailView):
"active_campaigns": active_campaigns,
"upcoming_campaigns": upcoming_campaigns,
"expired_campaigns": expired_campaigns,
"active_rewards": active_rewards,
"upcoming_rewards": upcoming_rewards,
"expired_rewards": expired_rewards,
"owner": owners[0] if owners else None,
"owners": owners,
"now": now,