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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<strong>Twitch:</strong>
|
||||||
<a href="{% url 'twitch:dashboard' %}">Dashboard</a> |
|
<a href="{% url 'twitch:dashboard' %}">Dashboard</a> |
|
||||||
<a href="{% url 'twitch:campaign_list' %}">Campaigns</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: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: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>
|
<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"
|
method="get"
|
||||||
style="display: inline">
|
style="display: inline;
|
||||||
|
margin-left: 1rem">
|
||||||
<input type="search"
|
<input type="search"
|
||||||
name="q"
|
name="q"
|
||||||
placeholder="Search..."
|
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;">
|
<div style="font-size: 0.9rem; color: #666;">
|
||||||
Organizations:
|
Organizations:
|
||||||
{% for org in game_data.owners %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -151,5 +152,96 @@ Hover over the end time to see the exact date and time.
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>No active campaigns at the moment.</p>
|
<p>No active campaigns at the moment.</p>
|
||||||
{% endif %}
|
{% 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>
|
</main>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,31 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Emotes{% endblock %}
|
{% block title %}
|
||||||
|
Emotes
|
||||||
|
{% endblock title %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Emotes</h1>
|
<h1>Emotes</h1>
|
||||||
<div class="emote-gallery" style="display: flex; flex-wrap: wrap; gap: 1.5rem; justify-content: flex-start;">
|
<div class="emote-gallery"
|
||||||
|
style="display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: flex-start">
|
||||||
{% for emote in emotes %}
|
{% for emote in emotes %}
|
||||||
<a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}" title="{{ emote.campaign.name }}" style="display: inline-block;">
|
<a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}"
|
||||||
<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" />
|
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>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<p>No drop campaigns with emotes found.</p>
|
<p>No drop campaigns with emotes found.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="container" id="search-results-container">
|
<div class="container" id="search-results-container">
|
||||||
<h1 id="page-title">Search Results for "{{ query }}"</h1>
|
<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>
|
<p id="no-results">No results found.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if results.organizations %}
|
{% if results.organizations %}
|
||||||
|
|
@ -68,6 +68,20 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% 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 DropCampaign
|
||||||
from twitch.models import Game
|
from twitch.models import Game
|
||||||
from twitch.models import Organization
|
from twitch.models import Organization
|
||||||
|
from twitch.models import RewardCampaign
|
||||||
from twitch.models import TimeBasedDrop
|
from twitch.models import TimeBasedDrop
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -743,3 +744,127 @@ class OrganizationCampaignFeed(Feed):
|
||||||
parts.append(format_html('<a href="{}">About</a>', details_url))
|
parts.append(format_html('<a href="{}">About</a>', details_url))
|
||||||
|
|
||||||
return SafeText("".join(str(p) for p in parts))
|
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 DropCampaign
|
||||||
from twitch.models import Game
|
from twitch.models import Game
|
||||||
from twitch.models import Organization
|
from twitch.models import Organization
|
||||||
|
from twitch.models import RewardCampaign
|
||||||
from twitch.models import TimeBasedDrop
|
from twitch.models import TimeBasedDrop
|
||||||
from twitch.schemas import ChannelInfoSchema
|
from twitch.schemas import ChannelInfoSchema
|
||||||
from twitch.schemas import CurrentUserSchema
|
from twitch.schemas import CurrentUserSchema
|
||||||
|
|
@ -37,6 +38,7 @@ from twitch.schemas import DropCampaignSchema
|
||||||
from twitch.schemas import GameSchema
|
from twitch.schemas import GameSchema
|
||||||
from twitch.schemas import GraphQLResponse
|
from twitch.schemas import GraphQLResponse
|
||||||
from twitch.schemas import OrganizationSchema
|
from twitch.schemas import OrganizationSchema
|
||||||
|
from twitch.schemas import RewardCampaign as RewardCampaignSchema
|
||||||
from twitch.schemas import TimeBasedDropSchema
|
from twitch.schemas import TimeBasedDropSchema
|
||||||
from twitch.utils import parse_date
|
from twitch.utils import parse_date
|
||||||
|
|
||||||
|
|
@ -852,6 +854,13 @@ class Command(BaseCommand):
|
||||||
allow_schema=drop_campaign.allow,
|
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
|
return True, None
|
||||||
|
|
||||||
def _process_time_based_drops(
|
def _process_time_based_drops(
|
||||||
|
|
@ -989,6 +998,73 @@ class Command(BaseCommand):
|
||||||
# Update the M2M relationship with the allowed channels
|
# Update the M2M relationship with the allowed channels
|
||||||
campaign_obj.allow_channels.set(channel_objects)
|
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
|
def handle(self, *args, **options) -> None: # noqa: ARG002
|
||||||
"""Main entry point for the command.
|
"""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:
|
def __str__(self) -> str:
|
||||||
"""Return a string representation of the time-based drop."""
|
"""Return a string representation of the time-based drop."""
|
||||||
return self.name
|
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.
|
"""Schema for the data field in Twitch API responses.
|
||||||
|
|
||||||
Handles both currentUser (standard) and user (legacy) field names,
|
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")
|
current_user: CurrentUserSchema | None = Field(default=None, alias="currentUser")
|
||||||
user: CurrentUserSchema | None = Field(default=None, alias="user")
|
user: CurrentUserSchema | None = Field(default=None, alias="user")
|
||||||
channel: ChannelSchema | None = Field(default=None, alias="channel")
|
channel: ChannelSchema | None = Field(default=None, alias="channel")
|
||||||
|
reward_campaigns_available_to_user: list[RewardCampaign] | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="rewardCampaignsAvailableToUser",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "forbid",
|
"extra": "forbid",
|
||||||
|
|
@ -409,6 +413,84 @@ class DataSchema(BaseModel):
|
||||||
return self
|
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):
|
class Extensions(BaseModel):
|
||||||
"""Schema for the extensions field in GraphQL responses."""
|
"""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
|
benefit: DropBenefitSchema = first_drop.benefit_edges[0].benefit
|
||||||
assert benefit.name == "13.7 Update: 250 CT"
|
assert benefit.name == "13.7 Update: 250 CT"
|
||||||
assert benefit.distribution_type is None # This field was missing in the API response
|
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 GameFeed
|
||||||
from twitch.feeds import OrganizationCampaignFeed
|
from twitch.feeds import OrganizationCampaignFeed
|
||||||
from twitch.feeds import OrganizationFeed
|
from twitch.feeds import OrganizationFeed
|
||||||
|
from twitch.feeds import RewardCampaignFeed
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.urls.resolvers import URLPattern
|
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("games/<str:twitch_id>/", views.GameDetailView.as_view(), name="game_detail"),
|
||||||
path("organizations/", views.org_list_view, name="org_list"),
|
path("organizations/", views.org_list_view, name="org_list"),
|
||||||
path("organizations/<str:twitch_id>/", views.organization_detail_view, name="organization_detail"),
|
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/campaigns/", DropCampaignFeed(), name="campaign_feed"),
|
||||||
path("rss/games/", GameFeed(), name="game_feed"),
|
path("rss/games/", GameFeed(), name="game_feed"),
|
||||||
path("rss/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"),
|
path("rss/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"),
|
||||||
path("rss/organizations/", OrganizationFeed(), name="organization_feed"),
|
path("rss/organizations/", OrganizationFeed(), name="organization_feed"),
|
||||||
path("rss/organizations/<str:twitch_id>/campaigns/", OrganizationCampaignFeed(), name="organization_campaign_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"),
|
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 DropCampaign
|
||||||
from twitch.models import Game
|
from twitch.models import Game
|
||||||
from twitch.models import Organization
|
from twitch.models import Organization
|
||||||
|
from twitch.models import RewardCampaign
|
||||||
from twitch.models import TimeBasedDrop
|
from twitch.models import TimeBasedDrop
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -109,6 +110,9 @@ def search_view(request: HttpRequest) -> HttpResponse:
|
||||||
results["benefits"] = DropBenefit.objects.filter(name__istartswith=query).prefetch_related(
|
results["benefits"] = DropBenefit.objects.filter(name__istartswith=query).prefetch_related(
|
||||||
"drops__campaign",
|
"drops__campaign",
|
||||||
)
|
)
|
||||||
|
results["reward_campaigns"] = RewardCampaign.objects.filter(
|
||||||
|
Q(name__istartswith=query) | Q(brand__istartswith=query) | Q(summary__icontains=query),
|
||||||
|
).select_related("game")
|
||||||
else:
|
else:
|
||||||
# SQLite-compatible text search using icontains
|
# SQLite-compatible text search using icontains
|
||||||
results["organizations"] = Organization.objects.filter(
|
results["organizations"] = Organization.objects.filter(
|
||||||
|
|
@ -126,6 +130,9 @@ def search_view(request: HttpRequest) -> HttpResponse:
|
||||||
results["benefits"] = DropBenefit.objects.filter(
|
results["benefits"] = DropBenefit.objects.filter(
|
||||||
name__icontains=query,
|
name__icontains=query,
|
||||||
).prefetch_related("drops__campaign")
|
).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(
|
return render(
|
||||||
request,
|
request,
|
||||||
|
|
@ -701,17 +708,132 @@ def dashboard(request: HttpRequest) -> HttpResponse:
|
||||||
|
|
||||||
campaigns_by_game[game_id]["campaigns"].append(campaign)
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"twitch/dashboard.html",
|
"twitch/dashboard.html",
|
||||||
{
|
{
|
||||||
"active_campaigns": active_campaigns,
|
"active_campaigns": active_campaigns,
|
||||||
"campaigns_by_game": campaigns_by_game,
|
"campaigns_by_game": campaigns_by_game,
|
||||||
|
"active_reward_campaigns": active_reward_campaigns,
|
||||||
"now": now,
|
"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/
|
# MARK: /debug/
|
||||||
def debug_view(request: HttpRequest) -> HttpResponse:
|
def debug_view(request: HttpRequest) -> HttpResponse:
|
||||||
"""Debug view showing potentially broken or inconsistent data.
|
"""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",
|
"description": "Latest drop campaigns across all games",
|
||||||
"url": "/rss/campaigns/",
|
"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
|
# Get sample game and organization for examples
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue