Add Reward Campaigns
This commit is contained in:
parent
d63ede1a47
commit
1a71809460
14 changed files with 1188 additions and 20 deletions
|
|
@ -155,17 +155,20 @@
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<strong>Twitch:</strong>
|
||||
<a href="{% url 'twitch:dashboard' %}">Dashboard</a> |
|
||||
<a href="{% url 'twitch:campaign_list' %}">Campaigns</a> |
|
||||
<a href="{% url 'twitch:reward_campaign_list' %}">Rewards</a> |
|
||||
<a href="{% url 'twitch:game_list' %}">Games</a> |
|
||||
<a href="{% url 'twitch:org_list' %}">Organizations</a> |
|
||||
<a href="{% url 'twitch:org_list' %}">Orgs</a> |
|
||||
<a href="{% url 'twitch:channel_list' %}">Channels</a> |
|
||||
<a href="{% url 'twitch:docs_rss' %}">RSS</a> |
|
||||
<a href="{% url 'twitch:debug' %}">Debug</a> |
|
||||
<a href="{% url 'twitch:emote_gallery' %}">Emotes</a>
|
||||
<form action="{% url 'twitch:search' %}"
|
||||
<br />
|
||||
<a href="{% url 'twitch:docs_rss' %}">RSS</a> | <a href="{% url 'twitch:debug' %}">Debug</a>
|
||||
<form action="{% url 'twitch:search' }}"
|
||||
method="get"
|
||||
style="display: inline">
|
||||
style="display: inline;
|
||||
margin-left: 1rem">
|
||||
<input type="search"
|
||||
name="q"
|
||||
placeholder="Search..."
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ Hover over the end time to see the exact date and time.
|
|||
<div style="font-size: 0.9rem; color: #666;">
|
||||
Organizations:
|
||||
{% for org in game_data.owners %}
|
||||
<a href="{% url 'twitch:organization_detail' org.twitch_id %}">{{ org.name }}</a>{% if not forloop.last %}, {% endif %}
|
||||
<a href="{% url 'twitch:organization_detail' org.twitch_id %}">{{ org.name }}</a>
|
||||
{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -151,5 +152,96 @@ Hover over the end time to see the exact date and time.
|
|||
{% else %}
|
||||
<p>No active campaigns at the moment.</p>
|
||||
{% endif %}
|
||||
<!-- Reward Campaigns Section -->
|
||||
{% if active_reward_campaigns %}
|
||||
<section id="reward-campaigns-section"
|
||||
style="margin-top: 2rem;
|
||||
border-top: 2px solid #ddd;
|
||||
padding-top: 1rem">
|
||||
<header style="margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0 0 0.5rem 0;">
|
||||
<a href="{% url 'twitch:reward_campaign_list' %}">Reward Campaigns (Quest Rewards)</a>
|
||||
</h2>
|
||||
<p style="font-size: 0.9rem; color: #666; margin: 0.5rem 0 0 0;">Complete quests to earn rewards</p>
|
||||
</header>
|
||||
<div style="display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
|
||||
{% 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;">
|
||||
<p style="margin: 0.25rem 0;">
|
||||
<strong>Status:</strong>
|
||||
{% if campaign.is_active %}
|
||||
<span style="color: green;">Active</span>
|
||||
{% elif campaign.starts_at > now %}
|
||||
<span style="color: orange;">Upcoming</span>
|
||||
{% else %}
|
||||
<span style="color: red;">Expired</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if campaign.starts_at %}
|
||||
<p style="margin: 0.25rem 0;">
|
||||
<strong>Starts:</strong>
|
||||
<time datetime="{{ campaign.starts_at|date:'c' }}"
|
||||
title="{{ campaign.starts_at|date:'DATETIME_FORMAT' }}">
|
||||
{{ campaign.starts_at|date:"M d, Y H:i" }}
|
||||
</time>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if campaign.ends_at %}
|
||||
<p style="margin: 0.25rem 0;">
|
||||
<strong>Ends:</strong>
|
||||
<time datetime="{{ campaign.ends_at|date:'c' }}"
|
||||
title="{{ campaign.ends_at|date:'DATETIME_FORMAT' }}">
|
||||
{{ campaign.ends_at|date:"M d, Y H:i" }}
|
||||
</time>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if campaign.game %}
|
||||
<p style="margin: 0.25rem 0;">
|
||||
<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;">
|
||||
<strong>Type:</strong> Site-wide reward campaign
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if campaign.external_url %}
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<a href="{{ campaign.external_url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #9146ff;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem">Claim Reward</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,31 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Emotes{% endblock %}
|
||||
{% block title %}
|
||||
Emotes
|
||||
{% endblock title %}
|
||||
{% block content %}
|
||||
<h1>Emotes</h1>
|
||||
<div class="emote-gallery" style="display: flex; flex-wrap: wrap; gap: 1.5rem; justify-content: flex-start;">
|
||||
<h1>Emotes</h1>
|
||||
<div class="emote-gallery"
|
||||
style="display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
justify-content: flex-start">
|
||||
{% for emote in emotes %}
|
||||
<a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}" title="{{ emote.campaign.name }}" style="display: inline-block;">
|
||||
<img src="{{ emote.image_url }}" alt="Emote" style="max-width: 96px; max-height: 96px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); padding: 4px;" loading="lazy" />
|
||||
<a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}"
|
||||
title="{{ emote.campaign.name }}"
|
||||
style="display: inline-block">
|
||||
<img src="{{ emote.image_url }}"
|
||||
height="96"
|
||||
width="96"
|
||||
alt="Emote"
|
||||
style="max-width: 96px;
|
||||
max-height: 96px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||
padding: 4px"
|
||||
loading="lazy" />
|
||||
</a>
|
||||
{% empty %}
|
||||
<p>No drop campaigns with emotes found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
150
templates/twitch/reward_campaign_detail.html
Normal file
150
templates/twitch/reward_campaign_detail.html
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}
|
||||
{{ reward_campaign.name }}
|
||||
{% endblock title %}
|
||||
{% block content %}
|
||||
<!-- Campaign Title -->
|
||||
{% if reward_campaign.brand %}
|
||||
<h1 id="campaign-title">{{ reward_campaign.brand }}: {{ reward_campaign.name }}</h1>
|
||||
{% else %}
|
||||
<h1 id="campaign-title">{{ reward_campaign.name }}</h1>
|
||||
{% endif %}
|
||||
<!-- Back to list link -->
|
||||
<p>
|
||||
<a href="{% url 'twitch:reward_campaign_list' %}">← Back to Reward Campaigns</a>
|
||||
</p>
|
||||
<!-- RSS Feeds -->
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<a href="{% url 'twitch:reward_campaign_feed' %}"
|
||||
style="margin-right: 1rem"
|
||||
title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a>
|
||||
</div>
|
||||
<!-- Campaign Summary -->
|
||||
{% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %}
|
||||
<!-- Campaign Status -->
|
||||
<h5>Status</h5>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Status:</strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if is_active %}
|
||||
Active
|
||||
{% elif reward_campaign.starts_at > now %}
|
||||
Upcoming
|
||||
{% else %}
|
||||
Expired
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Starts:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<time datetime="{{ reward_campaign.starts_at|date:'c' }}"
|
||||
title="{{ reward_campaign.starts_at|date:'DATETIME_FORMAT' }}">
|
||||
{{ reward_campaign.starts_at|date:"M d, Y H:i" }}
|
||||
</time>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Ends:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<time datetime="{{ reward_campaign.ends_at|date:'c' }}"
|
||||
title="{{ reward_campaign.ends_at|date:'DATETIME_FORMAT' }}">
|
||||
{{ reward_campaign.ends_at|date:"M d, Y H:i" }}
|
||||
</time>
|
||||
</td>
|
||||
</tr>
|
||||
{% if reward_campaign.game %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Game:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'twitch:game_detail' reward_campaign.game.twitch_id %}">{{ reward_campaign.game.display_name }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% elif reward_campaign.is_sitewide %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Type:</strong>
|
||||
</td>
|
||||
<td>Site-wide reward campaign</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Instructions -->
|
||||
{% if reward_campaign.instructions %}
|
||||
<h5>Instructions</h5>
|
||||
<p>{{ reward_campaign.instructions|linebreaksbr }}</p>
|
||||
{% endif %}
|
||||
<!-- Actions -->
|
||||
{% if reward_campaign.external_url or reward_campaign.about_url %}
|
||||
<p>
|
||||
{% if reward_campaign.external_url %}
|
||||
<a href="{{ reward_campaign.external_url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Claim Reward →</a>
|
||||
{% endif %}
|
||||
{% if reward_campaign.about_url %}
|
||||
<a href="{{ reward_campaign.about_url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Learn More →</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<!-- Metadata -->
|
||||
<h5>Campaign Information</h5>
|
||||
<table>
|
||||
<tbody>
|
||||
{% if reward_campaign.brand %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Brand:</strong>
|
||||
</td>
|
||||
<td>{{ reward_campaign.brand }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Twitch ID:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ reward_campaign.twitch_id }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Added to tracker:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<time datetime="{{ reward_campaign.added_at|date:'c' }}"
|
||||
title="{{ reward_campaign.added_at|date:'DATETIME_FORMAT' }}">
|
||||
{{ reward_campaign.added_at|date:"M d, Y H:i" }}
|
||||
</time>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Last updated:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<time datetime="{{ reward_campaign.updated_at|date:'c' }}"
|
||||
title="{{ reward_campaign.updated_at|date:'DATETIME_FORMAT' }}">
|
||||
{{ reward_campaign.updated_at|date:"M d, Y H:i" }}
|
||||
</time>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
{{ campaign_data|safe }}
|
||||
{% endblock content %}
|
||||
145
templates/twitch/reward_campaign_list.html
Normal file
145
templates/twitch/reward_campaign_list.html
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}
|
||||
Reward Campaigns - Twitch Drops Tracker
|
||||
{% endblock title %}
|
||||
{% block content %}
|
||||
<h1 id="page-title">Reward Campaigns (Quest Rewards)</h1>
|
||||
<p>Browse all available quest reward campaigns</p>
|
||||
<!-- RSS Feeds -->
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<a href="{% url 'twitch:reward_campaign_feed' %}"
|
||||
style="margin-right: 1rem"
|
||||
title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a>
|
||||
</div>
|
||||
<!-- Filter Form -->
|
||||
<form id="filter-form"
|
||||
method="get"
|
||||
action="{% url 'twitch:reward_campaign_list' %}">
|
||||
<label for="game">Game:</label>
|
||||
<select id="game" name="game">
|
||||
<option value="">All Games</option>
|
||||
{% for game in games %}
|
||||
<option value="{{ game.twitch_id }}"
|
||||
{% if selected_game == game.twitch_id %}selected{% endif %}>
|
||||
{{ game.display_name|default:game.name|default:game.slug|default:game.twitch_id }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label for="status">Status:</label>
|
||||
<select id="status" name="status">
|
||||
<option value="">All Statuses</option>
|
||||
{% for status in status_options %}
|
||||
<option value="{{ status }}"
|
||||
{% if selected_status == status %}selected{% endif %}>{{ status|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button id="apply-filters-button" type="submit">Apply Filters</button>
|
||||
</form>
|
||||
{% if reward_campaigns %}
|
||||
<h5>Active Reward Campaigns</h5>
|
||||
<table>
|
||||
<tbody>
|
||||
{% for campaign in reward_campaigns %}
|
||||
{% if campaign.starts_at <= now and campaign.ends_at >= now %}
|
||||
<tr id="reward-campaign-{{ campaign.twitch_id }}">
|
||||
<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>
|
||||
{% if campaign.game %}
|
||||
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
|
||||
{% elif campaign.is_sitewide %}
|
||||
Site-wide
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span title="{{ campaign.ends_at|date:'M d, Y H:i' }}">Ends in {{ campaign.ends_at|timeuntil }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<h5>Upcoming Reward Campaigns</h5>
|
||||
<table>
|
||||
<tbody>
|
||||
{% for campaign in reward_campaigns %}
|
||||
{% if campaign.starts_at > now %}
|
||||
<tr id="reward-campaign-{{ campaign.twitch_id }}">
|
||||
<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>
|
||||
{% if campaign.game %}
|
||||
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
|
||||
{% elif campaign.is_sitewide %}
|
||||
Site-wide
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span title="Starts on {{ campaign.starts_at|date:'M d, Y H:i' }}">Starts in {{ campaign.starts_at|timeuntil }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<h5>Past Reward Campaigns</h5>
|
||||
<table>
|
||||
<tbody>
|
||||
{% for campaign in reward_campaigns %}
|
||||
{% if campaign.ends_at < now %}
|
||||
<tr id="reward-campaign-{{ campaign.twitch_id }}">
|
||||
<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>
|
||||
{% if campaign.game %}
|
||||
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.display_name }}</a>
|
||||
{% elif campaign.is_sitewide %}
|
||||
Site-wide
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span title="Ended on {{ campaign.ends_at|date:'M d, Y H:i' }}">{{ campaign.ends_at|timesince }} ago</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No reward campaigns found.</p>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
{% block content %}
|
||||
<div class="container" id="search-results-container">
|
||||
<h1 id="page-title">Search Results for "{{ query }}"</h1>
|
||||
{% if not results.organizations and not results.games and not results.campaigns and not results.drops and not results.benefits %}
|
||||
{% if not results.organizations and not results.games and not results.campaigns and not results.drops and not results.benefits and not results.reward_campaigns %}
|
||||
<p id="no-results">No results found.</p>
|
||||
{% else %}
|
||||
{% if results.organizations %}
|
||||
|
|
@ -68,6 +68,20 @@
|
|||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if results.reward_campaigns %}
|
||||
<h2 id="reward-campaigns-header">Reward Campaigns</h2>
|
||||
<ul id="reward-campaigns-list">
|
||||
{% for campaign in results.reward_campaigns %}
|
||||
<li id="reward-campaign-{{ campaign.twitch_id }}">
|
||||
{% if campaign.brand %}
|
||||
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">{{ campaign.brand }}: {{ campaign.name }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">{{ campaign.name }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
125
twitch/feeds.py
125
twitch/feeds.py
|
|
@ -20,6 +20,7 @@ from twitch.models import DropBenefit
|
|||
from twitch.models import DropCampaign
|
||||
from twitch.models import Game
|
||||
from twitch.models import Organization
|
||||
from twitch.models import RewardCampaign
|
||||
from twitch.models import TimeBasedDrop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -743,3 +744,127 @@ class OrganizationCampaignFeed(Feed):
|
|||
parts.append(format_html('<a href="{}">About</a>', details_url))
|
||||
|
||||
return SafeText("".join(str(p) for p in parts))
|
||||
|
||||
|
||||
# MARK: /rss/reward-campaigns/
|
||||
class RewardCampaignFeed(Feed):
|
||||
"""RSS feed for latest reward campaigns (Quest rewards)."""
|
||||
|
||||
title: str = "Twitch Reward Campaigns (Quest Rewards)"
|
||||
link: str = "/campaigns/"
|
||||
description: str = "Latest Twitch reward campaigns (Quest rewards) on TTVDrops"
|
||||
feed_url: str = "/rss/reward-campaigns/"
|
||||
feed_copyright: str = "Information wants to be free."
|
||||
|
||||
def items(self) -> list[RewardCampaign]:
|
||||
"""Return the latest 100 reward campaigns."""
|
||||
return list(
|
||||
RewardCampaign.objects.select_related("game").order_by("-added_at")[:100],
|
||||
)
|
||||
|
||||
def item_title(self, item: Model) -> SafeText:
|
||||
"""Return the reward campaign name as the item title."""
|
||||
brand: str = getattr(item, "brand", "")
|
||||
name: str = getattr(item, "name", str(item))
|
||||
if brand:
|
||||
return SafeText(f"{brand}: {name}")
|
||||
return SafeText(name)
|
||||
|
||||
def item_description(self, item: Model) -> SafeText:
|
||||
"""Return a description of the reward campaign."""
|
||||
parts: list[SafeText] = []
|
||||
|
||||
summary: str | None = getattr(item, "summary", None)
|
||||
if summary:
|
||||
parts.append(format_html("<p>{}</p>", summary))
|
||||
|
||||
# Insert start and end date info (uses starts_at/ends_at instead of start_at/end_at)
|
||||
ends_at: datetime.datetime | None = getattr(item, "ends_at", None)
|
||||
starts_at: datetime.datetime | None = getattr(item, "starts_at", None)
|
||||
|
||||
if starts_at or ends_at:
|
||||
start_part: SafeString = (
|
||||
format_html("Starts: {} ({})", starts_at.strftime("%Y-%m-%d %H:%M %Z"), naturaltime(starts_at))
|
||||
if starts_at
|
||||
else SafeText("")
|
||||
)
|
||||
end_part: SafeString = (
|
||||
format_html("Ends: {} ({})", ends_at.strftime("%Y-%m-%d %H:%M %Z"), naturaltime(ends_at))
|
||||
if ends_at
|
||||
else SafeText("")
|
||||
)
|
||||
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))
|
||||
|
||||
is_sitewide: bool = getattr(item, "is_sitewide", False)
|
||||
if is_sitewide:
|
||||
parts.append(SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"))
|
||||
else:
|
||||
game: Game | None = getattr(item, "game", None)
|
||||
if game:
|
||||
parts.append(format_html("<p>Game: {}</p>", game.display_name or game.name))
|
||||
|
||||
about_url: str | None = getattr(item, "about_url", None)
|
||||
if about_url:
|
||||
parts.append(format_html('<p><a href="{}">Learn more</a></p>', about_url))
|
||||
|
||||
external_url: str | None = getattr(item, "external_url", None)
|
||||
if external_url:
|
||||
parts.append(format_html('<p><a href="{}">Redeem reward</a></p>', external_url))
|
||||
|
||||
return SafeText("".join(str(p) for p in parts))
|
||||
|
||||
def item_link(self, item: Model) -> str:
|
||||
"""Return the link to the reward campaign (external URL or dashboard)."""
|
||||
external_url: str | None = getattr(item, "external_url", None)
|
||||
if external_url:
|
||||
return external_url
|
||||
return reverse("twitch:dashboard")
|
||||
|
||||
def item_pubdate(self, item: Model) -> datetime.datetime:
|
||||
"""Returns the publication date to the feed item.
|
||||
|
||||
Fallback to added_at or now if missing.
|
||||
"""
|
||||
added_at: datetime.datetime | None = getattr(item, "added_at", None)
|
||||
if added_at:
|
||||
return 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"]
|
||||
|
||||
brand: str | None = getattr(item, "brand", None)
|
||||
if brand:
|
||||
categories.append(brand)
|
||||
|
||||
item_game: Game | None = getattr(item, "game", None)
|
||||
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 item.twitch_id + "@ttvdrops.com"
|
||||
|
||||
def item_author_name(self, item: RewardCampaign) -> str:
|
||||
"""Return the author name for the reward campaign."""
|
||||
brand: str | None = getattr(item, "brand", None)
|
||||
if brand:
|
||||
return brand
|
||||
|
||||
item_game: Game | None = getattr(item, "game", None)
|
||||
if item_game and item_game.display_name:
|
||||
return item_game.display_name
|
||||
|
||||
return "Twitch"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from twitch.models import DropBenefitEdge
|
|||
from twitch.models import DropCampaign
|
||||
from twitch.models import Game
|
||||
from twitch.models import Organization
|
||||
from twitch.models import RewardCampaign
|
||||
from twitch.models import TimeBasedDrop
|
||||
from twitch.schemas import ChannelInfoSchema
|
||||
from twitch.schemas import CurrentUserSchema
|
||||
|
|
@ -37,6 +38,7 @@ from twitch.schemas import DropCampaignSchema
|
|||
from twitch.schemas import GameSchema
|
||||
from twitch.schemas import GraphQLResponse
|
||||
from twitch.schemas import OrganizationSchema
|
||||
from twitch.schemas import RewardCampaign as RewardCampaignSchema
|
||||
from twitch.schemas import TimeBasedDropSchema
|
||||
from twitch.utils import parse_date
|
||||
|
||||
|
|
@ -852,6 +854,13 @@ class Command(BaseCommand):
|
|||
allow_schema=drop_campaign.allow,
|
||||
)
|
||||
|
||||
# Process reward campaigns if present
|
||||
if response.data.reward_campaigns_available_to_user:
|
||||
self._process_reward_campaigns(
|
||||
reward_campaigns=response.data.reward_campaigns_available_to_user,
|
||||
options=options,
|
||||
)
|
||||
|
||||
return True, None
|
||||
|
||||
def _process_time_based_drops(
|
||||
|
|
@ -989,6 +998,73 @@ class Command(BaseCommand):
|
|||
# Update the M2M relationship with the allowed channels
|
||||
campaign_obj.allow_channels.set(channel_objects)
|
||||
|
||||
def _process_reward_campaigns(
|
||||
self,
|
||||
reward_campaigns: list[RewardCampaignSchema],
|
||||
options: dict[str, Any],
|
||||
) -> None:
|
||||
"""Process reward campaigns from the API response.
|
||||
|
||||
Args:
|
||||
reward_campaigns: List of RewardCampaign Pydantic schemas.
|
||||
options: Command options dictionary.
|
||||
|
||||
Raises:
|
||||
ValueError: If datetime parsing fails for campaign dates and
|
||||
crash-on-error is enabled.
|
||||
"""
|
||||
for reward_campaign in reward_campaigns:
|
||||
starts_at_dt: datetime | None = parse_date(reward_campaign.starts_at)
|
||||
ends_at_dt: datetime | None = parse_date(reward_campaign.ends_at)
|
||||
|
||||
if starts_at_dt is None or ends_at_dt is None:
|
||||
tqdm.write(f"{Fore.RED}✗{Style.RESET_ALL} Invalid datetime in reward campaign: {reward_campaign.name}")
|
||||
if options.get("crash_on_error"):
|
||||
msg: str = f"Failed to parse datetime for reward campaign {reward_campaign.name}"
|
||||
raise ValueError(msg)
|
||||
continue
|
||||
|
||||
# Handle game reference if present
|
||||
game_obj: Game | None = None
|
||||
if reward_campaign.game:
|
||||
# The game field in reward campaigns is a dict, not a full GameSchema
|
||||
# We'll try to find an existing game by twitch_id if available
|
||||
game_id = reward_campaign.game.get("id")
|
||||
if game_id:
|
||||
try:
|
||||
game_obj = Game.objects.get(twitch_id=game_id)
|
||||
except Game.DoesNotExist:
|
||||
if options.get("verbose"):
|
||||
tqdm.write(
|
||||
f"{Fore.YELLOW}→{Style.RESET_ALL} Game not found for reward campaign: {game_id}",
|
||||
)
|
||||
|
||||
defaults: dict[str, str | datetime | Game | bool | None] = {
|
||||
"name": reward_campaign.name,
|
||||
"brand": reward_campaign.brand,
|
||||
"starts_at": starts_at_dt,
|
||||
"ends_at": ends_at_dt,
|
||||
"status": reward_campaign.status,
|
||||
"summary": reward_campaign.summary,
|
||||
"instructions": reward_campaign.instructions,
|
||||
"external_url": reward_campaign.external_url,
|
||||
"reward_value_url_param": reward_campaign.reward_value_url_param,
|
||||
"about_url": reward_campaign.about_url,
|
||||
"is_sitewide": reward_campaign.is_sitewide,
|
||||
"game": game_obj,
|
||||
}
|
||||
|
||||
_reward_campaign_obj, created = RewardCampaign.objects.update_or_create(
|
||||
twitch_id=reward_campaign.twitch_id,
|
||||
defaults=defaults,
|
||||
)
|
||||
|
||||
action: Literal["Imported new", "Updated"] = "Imported new" if created else "Updated"
|
||||
display_name = (
|
||||
f"{reward_campaign.brand}: {reward_campaign.name}" if reward_campaign.brand else reward_campaign.name
|
||||
)
|
||||
tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} {action} reward campaign: {display_name}")
|
||||
|
||||
def handle(self, *args, **options) -> None: # noqa: ARG002
|
||||
"""Main entry point for the command.
|
||||
|
||||
|
|
|
|||
120
twitch/migrations/0005_add_reward_campaign.py
Normal file
120
twitch/migrations/0005_add_reward_campaign.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Generated by Django 6.0.1 on 2026-01-13 20:31
|
||||
from __future__ import annotations
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""Add RewardCampaign model."""
|
||||
|
||||
dependencies = [
|
||||
("twitch", "0004_remove_game_twitch_game_owner_i_398fa9_idx_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="RewardCampaign",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"twitch_id",
|
||||
models.TextField(editable=False, help_text="The Twitch ID for this reward campaign.", unique=True),
|
||||
),
|
||||
("name", models.TextField(help_text="Name of the reward campaign.")),
|
||||
(
|
||||
"brand",
|
||||
models.TextField(blank=True, default="", help_text="Brand associated with the reward campaign."),
|
||||
),
|
||||
(
|
||||
"starts_at",
|
||||
models.DateTimeField(blank=True, help_text="Datetime when the reward campaign starts.", null=True),
|
||||
),
|
||||
(
|
||||
"ends_at",
|
||||
models.DateTimeField(blank=True, help_text="Datetime when the reward campaign ends.", null=True),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.TextField(default="UNKNOWN", help_text="Status of the reward campaign.", max_length=50),
|
||||
),
|
||||
(
|
||||
"summary",
|
||||
models.TextField(blank=True, default="", help_text="Summary description of the reward campaign."),
|
||||
),
|
||||
(
|
||||
"instructions",
|
||||
models.TextField(blank=True, default="", help_text="Instructions for the reward campaign."),
|
||||
),
|
||||
(
|
||||
"external_url",
|
||||
models.URLField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="External URL for the reward campaign.",
|
||||
max_length=500,
|
||||
),
|
||||
),
|
||||
(
|
||||
"reward_value_url_param",
|
||||
models.TextField(blank=True, default="", help_text="URL parameter for reward value."),
|
||||
),
|
||||
(
|
||||
"about_url",
|
||||
models.URLField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="About URL for the reward campaign.",
|
||||
max_length=500,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_sitewide",
|
||||
models.BooleanField(default=False, help_text="Whether the reward campaign is sitewide."),
|
||||
),
|
||||
(
|
||||
"added_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="Timestamp when this reward campaign record was created.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="Timestamp when this reward campaign record was last updated.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Game associated with this reward campaign (if any).",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="reward_campaigns",
|
||||
to="twitch.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-starts_at"],
|
||||
"indexes": [
|
||||
models.Index(fields=["-starts_at"], name="twitch_rewa_starts__4df564_idx"),
|
||||
models.Index(fields=["ends_at"], name="twitch_rewa_ends_at_354b15_idx"),
|
||||
models.Index(fields=["twitch_id"], name="twitch_rewa_twitch__797967_idx"),
|
||||
models.Index(fields=["name"], name="twitch_rewa_name_f1e3dd_idx"),
|
||||
models.Index(fields=["brand"], name="twitch_rewa_brand_41c321_idx"),
|
||||
models.Index(fields=["status"], name="twitch_rewa_status_a96d6b_idx"),
|
||||
models.Index(fields=["is_sitewide"], name="twitch_rewa_is_site_7d2c9f_idx"),
|
||||
models.Index(fields=["game"], name="twitch_rewa_game_id_678fbb_idx"),
|
||||
models.Index(fields=["added_at"], name="twitch_rewa_added_a_ae3748_idx"),
|
||||
models.Index(fields=["updated_at"], name="twitch_rewa_updated_fdf599_idx"),
|
||||
models.Index(fields=["starts_at", "ends_at"], name="twitch_rewa_starts__dd909d_idx"),
|
||||
models.Index(fields=["status", "-starts_at"], name="twitch_rewa_status_3641a4_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
112
twitch/models.py
112
twitch/models.py
|
|
@ -652,3 +652,115 @@ class TimeBasedDrop(models.Model):
|
|||
def __str__(self) -> str:
|
||||
"""Return a string representation of the time-based drop."""
|
||||
return self.name
|
||||
|
||||
|
||||
# MARK: RewardCampaign
|
||||
class RewardCampaign(models.Model):
|
||||
"""Represents a Twitch reward campaign (Quest rewards)."""
|
||||
|
||||
twitch_id = models.TextField(
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text="The Twitch ID for this reward campaign.",
|
||||
)
|
||||
name = models.TextField(
|
||||
help_text="Name of the reward campaign.",
|
||||
)
|
||||
brand = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Brand associated with the reward campaign.",
|
||||
)
|
||||
starts_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Datetime when the reward campaign starts.",
|
||||
)
|
||||
ends_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Datetime when the reward campaign ends.",
|
||||
)
|
||||
status = models.TextField(
|
||||
max_length=50,
|
||||
default="UNKNOWN",
|
||||
help_text="Status of the reward campaign.",
|
||||
)
|
||||
summary = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Summary description of the reward campaign.",
|
||||
)
|
||||
instructions = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Instructions for the reward campaign.",
|
||||
)
|
||||
external_url = models.URLField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="External URL for the reward campaign.",
|
||||
)
|
||||
reward_value_url_param = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="URL parameter for reward value.",
|
||||
)
|
||||
about_url = models.URLField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="About URL for the reward campaign.",
|
||||
)
|
||||
is_sitewide = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the reward campaign is sitewide.",
|
||||
)
|
||||
game = models.ForeignKey(
|
||||
Game,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="reward_campaigns",
|
||||
help_text="Game associated with this reward campaign (if any).",
|
||||
)
|
||||
|
||||
added_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="Timestamp when this reward campaign record was created.",
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="Timestamp when this reward campaign record was last updated.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-starts_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["-starts_at"]),
|
||||
models.Index(fields=["ends_at"]),
|
||||
models.Index(fields=["twitch_id"]),
|
||||
models.Index(fields=["name"]),
|
||||
models.Index(fields=["brand"]),
|
||||
models.Index(fields=["status"]),
|
||||
models.Index(fields=["is_sitewide"]),
|
||||
models.Index(fields=["game"]),
|
||||
models.Index(fields=["added_at"]),
|
||||
models.Index(fields=["updated_at"]),
|
||||
# Composite indexes for common queries
|
||||
models.Index(fields=["starts_at", "ends_at"]),
|
||||
models.Index(fields=["status", "-starts_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return a string representation of the reward campaign."""
|
||||
return f"{self.brand}: {self.name}" if self.brand else self.name
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Check if the reward campaign is currently active."""
|
||||
now: datetime.datetime = timezone.now()
|
||||
if self.starts_at is None or self.ends_at is None:
|
||||
return False
|
||||
return self.starts_at <= now <= self.ends_at
|
||||
|
|
|
|||
|
|
@ -365,12 +365,16 @@ class DataSchema(BaseModel):
|
|||
"""Schema for the data field in Twitch API responses.
|
||||
|
||||
Handles both currentUser (standard) and user (legacy) field names,
|
||||
as well as channel-based campaign structures.
|
||||
as well as channel-based campaign structures and reward campaigns.
|
||||
"""
|
||||
|
||||
current_user: CurrentUserSchema | None = Field(default=None, alias="currentUser")
|
||||
user: CurrentUserSchema | None = Field(default=None, alias="user")
|
||||
channel: ChannelSchema | None = Field(default=None, alias="channel")
|
||||
reward_campaigns_available_to_user: list[RewardCampaign] | None = Field(
|
||||
default=None,
|
||||
alias="rewardCampaignsAvailableToUser",
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
|
|
@ -409,6 +413,84 @@ class DataSchema(BaseModel):
|
|||
return self
|
||||
|
||||
|
||||
class QuestRewardUnlockRequirements(BaseModel):
|
||||
"""Schema for quest reward unlock requirements."""
|
||||
|
||||
subs_goal: int | None = Field(default=None, alias="subsGoal")
|
||||
minute_watched_goal: int | None = Field(default=None, alias="minuteWatchedGoal")
|
||||
type_name: Literal["QuestRewardUnlockRequirements"] = Field(alias="__typename")
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"validate_assignment": True,
|
||||
"strict": True,
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class RewardCampaignImageSet(BaseModel):
|
||||
"""Schema for reward campaign image sets."""
|
||||
|
||||
image1x_url: str | None = Field(default=None, alias="image1xURL")
|
||||
type_name: Literal["RewardCampaignImageSet"] = Field(alias="__typename")
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"validate_assignment": True,
|
||||
"strict": True,
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class Reward(BaseModel):
|
||||
"""Schema for a reward in a RewardCampaign."""
|
||||
|
||||
twitch_id: str = Field(alias="id")
|
||||
name: str
|
||||
banner_image: RewardCampaignImageSet | None = Field(default=None, alias="bannerImage")
|
||||
thumbnail_image: RewardCampaignImageSet | None = Field(default=None, alias="thumbnailImage")
|
||||
earnable_until: str | None = Field(default=None, alias="earnableUntil")
|
||||
redemption_instructions: str = Field(default="", alias="redemptionInstructions")
|
||||
redemption_url: str = Field(default="", alias="redemptionURL")
|
||||
type_name: Literal["Reward"] = Field(alias="__typename")
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"validate_assignment": True,
|
||||
"strict": True,
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class RewardCampaign(BaseModel):
|
||||
"""Schema for a RewardCampaign from rewardCampaignsAvailableToUser."""
|
||||
|
||||
twitch_id: str = Field(alias="id")
|
||||
name: str
|
||||
brand: str
|
||||
starts_at: str = Field(alias="startsAt")
|
||||
ends_at: str = Field(alias="endsAt")
|
||||
status: str
|
||||
summary: str = Field(default="")
|
||||
instructions: str = Field(default="")
|
||||
external_url: str = Field(default="", alias="externalURL")
|
||||
reward_value_url_param: str = Field(default="", alias="rewardValueURLParam")
|
||||
about_url: str = Field(default="", alias="aboutURL")
|
||||
is_sitewide: bool = Field(default=False, alias="isSitewide")
|
||||
game: dict | None = None
|
||||
unlock_requirements: QuestRewardUnlockRequirements | None = Field(default=None, alias="unlockRequirements")
|
||||
image: RewardCampaignImageSet | None = None
|
||||
rewards: list[Reward] = Field(default=[])
|
||||
type_name: Literal["RewardCampaign"] = Field(alias="__typename")
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
"validate_assignment": True,
|
||||
"strict": True,
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class Extensions(BaseModel):
|
||||
"""Schema for the extensions field in GraphQL responses."""
|
||||
|
||||
|
|
|
|||
|
|
@ -376,3 +376,104 @@ def test_drop_campaign_details_missing_distribution_type() -> None:
|
|||
benefit: DropBenefitSchema = first_drop.benefit_edges[0].benefit
|
||||
assert benefit.name == "13.7 Update: 250 CT"
|
||||
assert benefit.distribution_type is None # This field was missing in the API response
|
||||
|
||||
|
||||
def test_reward_campaigns_available_to_user() -> None:
|
||||
"""Test that rewardCampaignsAvailableToUser field validates correctly.
|
||||
|
||||
The ViewerDropsDashboard operation can include reward campaigns (Quest rewards)
|
||||
alongside drop campaigns. This test verifies that the schema properly handles
|
||||
this additional data.
|
||||
"""
|
||||
payload: dict[str, object] = {
|
||||
"data": {
|
||||
"currentUser": {
|
||||
"id": "58162970",
|
||||
"login": "lovibot",
|
||||
"dropCampaigns": [],
|
||||
"__typename": "User",
|
||||
},
|
||||
"rewardCampaignsAvailableToUser": [
|
||||
{
|
||||
"id": "dc4ff0b4-4de0-11ef-9ec3-621fb0811846",
|
||||
"name": "Buy 1 new sub, get 3 months of Apple TV+",
|
||||
"brand": "Apple TV+",
|
||||
"startsAt": "2024-07-30T19:00:00Z",
|
||||
"endsAt": "2024-08-19T19:00:00Z",
|
||||
"status": "UNKNOWN",
|
||||
"summary": "Get 3 months of Apple TV+",
|
||||
"instructions": "",
|
||||
"externalURL": "https://tv.apple.com/includes/commerce/redeem/code-entry",
|
||||
"rewardValueURLParam": "",
|
||||
"aboutURL": "https://blog.twitch.tv/2024/07/26/sub-and-get-apple-tv/",
|
||||
"isSitewide": True,
|
||||
"game": None,
|
||||
"unlockRequirements": {
|
||||
"subsGoal": 1,
|
||||
"minuteWatchedGoal": 0,
|
||||
"__typename": "QuestRewardUnlockRequirements",
|
||||
},
|
||||
"image": {
|
||||
"image1xURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_150x200.png",
|
||||
"__typename": "RewardCampaignImageSet",
|
||||
},
|
||||
"rewards": [
|
||||
{
|
||||
"id": "dc2e9810-4de0-11ef-9ec3-621fb0811846",
|
||||
"name": "3 months of Apple TV+",
|
||||
"bannerImage": {
|
||||
"image1xURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_200x200.png",
|
||||
"__typename": "RewardCampaignImageSet",
|
||||
},
|
||||
"thumbnailImage": {
|
||||
"image1xURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_200x200.png",
|
||||
"__typename": "RewardCampaignImageSet",
|
||||
},
|
||||
"earnableUntil": "2024-08-19T19:00:00Z",
|
||||
"redemptionInstructions": "",
|
||||
"redemptionURL": "https://tv.apple.com/includes/commerce/redeem/code-entry",
|
||||
"__typename": "Reward",
|
||||
},
|
||||
],
|
||||
"__typename": "RewardCampaign",
|
||||
},
|
||||
],
|
||||
},
|
||||
"extensions": {
|
||||
"operationName": "ViewerDropsDashboard",
|
||||
},
|
||||
}
|
||||
|
||||
# This should not raise ValidationError
|
||||
response: GraphQLResponse = GraphQLResponse.model_validate(payload)
|
||||
|
||||
# Verify the reward campaigns were parsed correctly
|
||||
assert response.data.reward_campaigns_available_to_user is not None
|
||||
assert len(response.data.reward_campaigns_available_to_user) == 1
|
||||
|
||||
reward_campaign = response.data.reward_campaigns_available_to_user[0]
|
||||
assert reward_campaign.twitch_id == "dc4ff0b4-4de0-11ef-9ec3-621fb0811846"
|
||||
assert reward_campaign.name == "Buy 1 new sub, get 3 months of Apple TV+"
|
||||
assert reward_campaign.brand == "Apple TV+"
|
||||
assert reward_campaign.is_sitewide is True
|
||||
assert reward_campaign.game is None
|
||||
|
||||
# Verify unlock requirements
|
||||
assert reward_campaign.unlock_requirements is not None
|
||||
assert reward_campaign.unlock_requirements.subs_goal == 1
|
||||
assert reward_campaign.unlock_requirements.minute_watched_goal == 0
|
||||
|
||||
# Verify image
|
||||
assert reward_campaign.image is not None
|
||||
assert (
|
||||
reward_campaign.image.image1x_url
|
||||
== "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/quests_appletv_q3_2024/apple_150x200.png"
|
||||
)
|
||||
|
||||
# Verify rewards
|
||||
assert len(reward_campaign.rewards) == 1
|
||||
reward = reward_campaign.rewards[0]
|
||||
assert reward.twitch_id == "dc2e9810-4de0-11ef-9ec3-621fb0811846"
|
||||
assert reward.name == "3 months of Apple TV+"
|
||||
assert reward.banner_image is not None
|
||||
assert reward.thumbnail_image is not None
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from twitch.feeds import GameCampaignFeed
|
|||
from twitch.feeds import GameFeed
|
||||
from twitch.feeds import OrganizationCampaignFeed
|
||||
from twitch.feeds import OrganizationFeed
|
||||
from twitch.feeds import RewardCampaignFeed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.urls.resolvers import URLPattern
|
||||
|
|
@ -30,10 +31,13 @@ urlpatterns: list[URLPattern] = [
|
|||
path("games/<str:twitch_id>/", views.GameDetailView.as_view(), name="game_detail"),
|
||||
path("organizations/", views.org_list_view, name="org_list"),
|
||||
path("organizations/<str:twitch_id>/", views.organization_detail_view, name="organization_detail"),
|
||||
path("reward-campaigns/", views.reward_campaign_list_view, name="reward_campaign_list"),
|
||||
path("reward-campaigns/<str:twitch_id>/", views.reward_campaign_detail_view, name="reward_campaign_detail"),
|
||||
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
|
||||
path("rss/games/", GameFeed(), name="game_feed"),
|
||||
path("rss/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"),
|
||||
path("rss/organizations/", OrganizationFeed(), name="organization_feed"),
|
||||
path("rss/organizations/<str:twitch_id>/campaigns/", OrganizationCampaignFeed(), name="organization_campaign_feed"),
|
||||
path("rss/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed"),
|
||||
path("search/", views.search_view, name="search"),
|
||||
]
|
||||
|
|
|
|||
127
twitch/views.py
127
twitch/views.py
|
|
@ -36,6 +36,7 @@ from twitch.models import DropBenefit
|
|||
from twitch.models import DropCampaign
|
||||
from twitch.models import Game
|
||||
from twitch.models import Organization
|
||||
from twitch.models import RewardCampaign
|
||||
from twitch.models import TimeBasedDrop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -109,6 +110,9 @@ def search_view(request: HttpRequest) -> HttpResponse:
|
|||
results["benefits"] = DropBenefit.objects.filter(name__istartswith=query).prefetch_related(
|
||||
"drops__campaign",
|
||||
)
|
||||
results["reward_campaigns"] = RewardCampaign.objects.filter(
|
||||
Q(name__istartswith=query) | Q(brand__istartswith=query) | Q(summary__icontains=query),
|
||||
).select_related("game")
|
||||
else:
|
||||
# SQLite-compatible text search using icontains
|
||||
results["organizations"] = Organization.objects.filter(
|
||||
|
|
@ -126,6 +130,9 @@ def search_view(request: HttpRequest) -> HttpResponse:
|
|||
results["benefits"] = DropBenefit.objects.filter(
|
||||
name__icontains=query,
|
||||
).prefetch_related("drops__campaign")
|
||||
results["reward_campaigns"] = RewardCampaign.objects.filter(
|
||||
Q(name__icontains=query) | Q(brand__icontains=query) | Q(summary__icontains=query),
|
||||
).select_related("game")
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
|
@ -701,17 +708,132 @@ def dashboard(request: HttpRequest) -> HttpResponse:
|
|||
|
||||
campaigns_by_game[game_id]["campaigns"].append(campaign)
|
||||
|
||||
# Get active reward campaigns (Quest rewards)
|
||||
active_reward_campaigns: QuerySet[RewardCampaign] = (
|
||||
RewardCampaign.objects
|
||||
.filter(starts_at__lte=now, ends_at__gte=now)
|
||||
.select_related("game")
|
||||
.order_by("-starts_at")
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"twitch/dashboard.html",
|
||||
{
|
||||
"active_campaigns": active_campaigns,
|
||||
"campaigns_by_game": campaigns_by_game,
|
||||
"active_reward_campaigns": active_reward_campaigns,
|
||||
"now": now,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# MARK: /reward-campaigns/
|
||||
def reward_campaign_list_view(request: HttpRequest) -> HttpResponse:
|
||||
"""Function-based view for reward campaigns list.
|
||||
|
||||
Args:
|
||||
request: The HTTP request.
|
||||
|
||||
Returns:
|
||||
HttpResponse: The rendered reward campaigns list page.
|
||||
"""
|
||||
game_filter: str | None = request.GET.get("game")
|
||||
status_filter: str | None = request.GET.get("status")
|
||||
per_page: int = 100
|
||||
queryset: QuerySet[RewardCampaign] = RewardCampaign.objects.all()
|
||||
|
||||
if game_filter:
|
||||
queryset = queryset.filter(game__twitch_id=game_filter)
|
||||
|
||||
queryset = queryset.select_related("game").order_by("-starts_at")
|
||||
|
||||
# Optionally filter by status (active, upcoming, expired)
|
||||
now = timezone.now()
|
||||
if status_filter == "active":
|
||||
queryset = queryset.filter(starts_at__lte=now, ends_at__gte=now)
|
||||
elif status_filter == "upcoming":
|
||||
queryset = queryset.filter(starts_at__gt=now)
|
||||
elif status_filter == "expired":
|
||||
queryset = queryset.filter(ends_at__lt=now)
|
||||
|
||||
paginator = Paginator(queryset, per_page)
|
||||
page = request.GET.get("page") or 1
|
||||
try:
|
||||
reward_campaigns = paginator.page(page)
|
||||
except PageNotAnInteger:
|
||||
reward_campaigns = paginator.page(1)
|
||||
except EmptyPage:
|
||||
reward_campaigns = paginator.page(paginator.num_pages)
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"reward_campaigns": reward_campaigns,
|
||||
"games": Game.objects.all().order_by("display_name"),
|
||||
"status_options": ["active", "upcoming", "expired"],
|
||||
"now": now,
|
||||
"selected_game": game_filter or "",
|
||||
"selected_per_page": per_page,
|
||||
"selected_status": status_filter or "",
|
||||
}
|
||||
return render(request, "twitch/reward_campaign_list.html", context)
|
||||
|
||||
|
||||
# MARK: /reward-campaigns/<twitch_id>/
|
||||
def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse:
|
||||
"""Function-based view for a reward campaign detail.
|
||||
|
||||
Args:
|
||||
request: The HTTP request.
|
||||
twitch_id: The Twitch ID of the reward campaign.
|
||||
|
||||
Returns:
|
||||
HttpResponse: The rendered reward campaign detail page.
|
||||
|
||||
Raises:
|
||||
Http404: If the reward campaign is not found.
|
||||
"""
|
||||
try:
|
||||
reward_campaign: RewardCampaign = RewardCampaign.objects.select_related("game").get(
|
||||
twitch_id=twitch_id,
|
||||
)
|
||||
except RewardCampaign.DoesNotExist as exc:
|
||||
msg = "No reward campaign found matching the query"
|
||||
raise Http404(msg) from exc
|
||||
|
||||
serialized_campaign = serialize(
|
||||
"json",
|
||||
[reward_campaign],
|
||||
fields=(
|
||||
"twitch_id",
|
||||
"name",
|
||||
"brand",
|
||||
"summary",
|
||||
"instructions",
|
||||
"external_url",
|
||||
"about_url",
|
||||
"reward_value_url_param",
|
||||
"starts_at",
|
||||
"ends_at",
|
||||
"is_sitewide",
|
||||
"game",
|
||||
"added_at",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
campaign_data: list[dict[str, Any]] = json.loads(serialized_campaign)
|
||||
|
||||
now: datetime.datetime = timezone.now()
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"reward_campaign": reward_campaign,
|
||||
"now": now,
|
||||
"campaign_data": format_and_color_json(campaign_data[0]),
|
||||
"is_active": reward_campaign.is_active,
|
||||
}
|
||||
|
||||
return render(request, "twitch/reward_campaign_detail.html", context)
|
||||
|
||||
|
||||
# MARK: /debug/
|
||||
def debug_view(request: HttpRequest) -> HttpResponse:
|
||||
"""Debug view showing potentially broken or inconsistent data.
|
||||
|
|
@ -821,6 +943,11 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
|
|||
"description": "Latest drop campaigns across all games",
|
||||
"url": "/rss/campaigns/",
|
||||
},
|
||||
{
|
||||
"title": "All Reward Campaigns",
|
||||
"description": "Latest reward campaigns (Quest rewards) on Twitch",
|
||||
"url": "/rss/reward-campaigns/",
|
||||
},
|
||||
]
|
||||
|
||||
# Get sample game and organization for examples
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue