Make templates sexier
All checks were successful
Deploy to Server / deploy (push) Successful in 10s

This commit is contained in:
Joakim Hellsén 2026-03-11 23:38:31 +01:00
commit 4663a827e4
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
12 changed files with 434 additions and 405 deletions

View file

@ -1,17 +1,19 @@
{% extends "base.html" %}
{% load image_tags %}
{% block title %}
Chat Badges - ttvdrops
Chat Badges
{% endblock title %}
{% block content %}
<h1>{{ badge_sets.count }} Twitch Chat Badges</h1>
{% if badge_sets %}
{% for data in badge_data %}
<!-- {{ data.set.set_id }} - {{ data.badges|length }} version{% if data.badges|length > 1 %}s{% endif %} -->
<h2>
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">{{ data.set.set_id }}</a>
</h2>
<table>
{% for badge in data.badges %}
<!-- {{ badge.title }} {% if badge.description != badge.title %}- {{ badge.description }}{% else %}{% endif %} -->
<tr>
<td style="width: 40px;">
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">

View file

@ -9,6 +9,7 @@
<table>
<tbody>
{% for badge in badges %}
<!-- {{ badge.title }} {% if badge.description != badge.title %}- {{ badge.description }}{% else %}{% endif %} -->
<tr>
<td>
<code>{{ badge.badge_id }}</code>
@ -24,18 +25,16 @@
<a href="{{ badge.image_url_4x }}" rel="nofollow ugc">[72px]</a>
</td>
<td>
{% if badge.click_url %}
<a href="{{ badge.click_url }}" rel="nofollow ugc">{{ badge.click_action }}</a>
{% else %}
-
{% endif %}
{% if badge.click_url %}<a href="{{ badge.click_url }}" rel="nofollow ugc">{{ badge.click_action }}</a>{% endif %}
</td>
</tr>
{% if badge.award_campaigns %}
<!-- If there are campaigns awarding this badge, show them in a nested row -->
<div>
The following campaigns have the same name as this badge and may be awarding it:
<ul>
{% for campaign in badge.award_campaigns %}
<!-- Note: We can't be sure if these campaigns are actually awarding this badge, but it's likely given the name match. -->
<li>
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a>
</li>

View file

@ -17,110 +17,99 @@
{% endif %}
{% endblock extra_head %}
{% block content %}
<!-- Campaign Title -->
<h1>
{% if campaign.game %}
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.get_game_name }}</a> - {{ campaign.clean_name }}
{% else %}
{{ campaign.clean_name }}
{% endif %}
</h1>
<!-- Campaign Owners -->
{% for org in owners %}
<p>
<a href="{% url 'twitch:organization_detail' org.twitch_id %}">{{ org.name }}</a>
</p>
{% endfor %}
<!-- Campaign image -->
{% if campaign.image_best_url %}
{% picture campaign.image_best_url alt=campaign.name width=160 %}
{% endif %}
<!-- Campaign description -->
<p>{{ campaign.description|linebreaksbr }}</p>
<!-- Campaign end times -->
<div>
{% if campaign.end_at < now %}
<time datetime="{{ campaign.end_at|date:'c' }}"
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}">
<strong>Ended</strong> {{ campaign.end_at|timesince }} ago
</time>
{% else %}
<time datetime="{{ campaign.end_at|date:'c' }}"
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}">
<strong>Ends in</strong> {{ campaign.end_at|timeuntil }}
</time>
{% endif %}
</div>
<!-- Campaign start times -->
<div>
{% if campaign.start_at > now %}
<time datetime="{{ campaign.start_at|date:'c' }}"
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}">
<strong>Starts in</strong> {{ campaign.start_at|timeuntil }}
</time>
{% else %}
<time datetime="{{ campaign.start_at|date:'c' }}"
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}">
<strong>Started</strong> {{ campaign.start_at|timesince }} ago
</time>
{% endif %}
</div>
<!-- Campaign duration -->
<div>
<time datetime="{{ campaign.duration_iso }}"
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign.end_at|date:'DATETIME_FORMAT' }}">
<strong>Duration</strong> {{ campaign.end_at|timeuntil:campaign.start_at }}
</time>
</div>
<div>
<!-- Campaign Detail URL -->
{% if campaign.details_url %}<a href="{{ campaign.details_url }}" rel="nofollow ugc">[details]</a>{% endif %}
<!-- Campaign Account Link URL -->
{% if campaign.account_link_url %}
<a href="{{ campaign.account_link_url }}" rel="nofollow ugc">[connect]</a>
{% endif %}
<!-- RSS Feeds -->
{% if campaign.game %}
<a href="{% url 'twitch:game_campaign_feed' campaign.game.twitch_id %}"
title="RSS feed for {{ campaign.game.display_name }} campaigns">[rss]</a>
<a href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}"
title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a>
{% endif %}
</div>
{% if allowed_channels %}
<h5>Allowed Channels</h5>
<div>
{% for channel in allowed_channels %}
<a href="{% url 'twitch:channel_detail' channel.twitch_id %}">{{ channel.display_name }}</a>
{% endfor %}
<div style="display: flex; align-items: flex-start;">
<!-- Campaign image -->
<div style="margin-right: 16px;">
{% if campaign.image_best_url %}
{% picture campaign.image_best_url alt=campaign.name width=160 %}
{% endif %}
</div>
{% else %}
<a href="{{ campaign.game.twitch_directory_url }}"
rel="nofollow ugc"
title="Find streamers playing {{ campaign.game.display_name }} with drops enabled">
Go to a participating live channel
</a>
{% endif %}
<!-- Campaign Title -->
<div style="display: flex; flex-direction: column;">
<h1 style="margin-top: 0; margin-bottom: 0px;">{{ campaign.clean_name }}</h1>
<div>
{% if campaign.game %}
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.get_game_name }}</a>
{% endif %}
{% if owners %}
-
<!-- Campaign Owners -->
{% for org in owners %}
<a href="{% url 'twitch:organization_detail' org.twitch_id %}">{{ org.name }}</a>
{% endfor %}
{% endif %}
</div>
<!-- Campaign description -->
<p>{{ campaign.description|linebreaksbr }}</p>
<!-- Campaign end times -->
<div>
{% if campaign.end_at < now %}
<time datetime="{{ campaign.end_at|date:'c' }}"
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}">
<strong>Ended</strong> {{ campaign.end_at|timesince }} ago
</time>
{% else %}
<time datetime="{{ campaign.end_at|date:'c' }}"
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}">
<strong>Ends in</strong> {{ campaign.end_at|timeuntil }}
</time>
{% endif %}
</div>
<!-- Campaign start times -->
<div>
{% if campaign.start_at > now %}
<time datetime="{{ campaign.start_at|date:'c' }}"
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}">
<strong>Starts in</strong> {{ campaign.start_at|timeuntil }}
</time>
{% else %}
<time datetime="{{ campaign.start_at|date:'c' }}"
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}">
<strong>Started</strong> {{ campaign.start_at|timesince }} ago
</time>
{% endif %}
</div>
<!-- Campaign duration -->
<div>
<time datetime="{{ campaign.duration_iso }}"
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign.end_at|date:'DATETIME_FORMAT' }}">
<strong>Duration</strong> {{ campaign.end_at|timeuntil:campaign.start_at }}
</time>
</div>
<!-- Buttons -->
<div>
<!-- Campaign Detail URL -->
{% if campaign.details_url %}<a href="{{ campaign.details_url }}" rel="nofollow ugc">[details]</a>{% endif %}
<!-- Campaign Account Link URL -->
{% if campaign.account_link_url %}
<a href="{{ campaign.account_link_url }}" rel="nofollow ugc">[connect]</a>
{% endif %}
<!-- RSS Feeds -->
{% if campaign.game %}
<a href="{% url 'twitch:game_campaign_feed' campaign.game.twitch_id %}"
title="RSS feed for {{ campaign.game.display_name }} campaigns">[rss]</a>
<a href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}"
title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a>
{% endif %}
</div>
</div>
</div>
<h5>Campaign Info</h5>
{% if drops %}
<table id="drops-table" style="border-collapse: collapse; width: 100%;">
<thead>
<tr>
<th></th>
<th>Drop Name</th>
<th>Requirements</th>
<th>Period</th>
</tr>
</thead>
<!-- Drops table -->
<table style="border-collapse: collapse; width: 100%;">
<tbody>
{% for drop in drops %}
<tr id="drop-{{ drop.drop.twitch_id }}">
<!-- {{ drop.drop.name }} - {{ drop.drop.benefits.all|join:", " }} -->
<tr>
<td>
{% for benefit in drop.drop.benefits.all %}
{% if benefit.image_asset_url %}
<!-- Show the benefit image if available -->
{% picture benefit.image_best_url|default:benefit.image_asset_url alt=benefit.name width=160 height=160 style="object-fit: cover; margin-right: 3px" %}
{% endif %}
{% if benefit.distribution_type == "BADGE" and drop.awarded_badge %}
<!-- If the drop awards a badge, show the badge prominently with its description -->
<div style="margin-top: 6px; font-size: 0.9em;">
<strong>Awards Badge:</strong>
<a href="{% url 'twitch:badge_set_detail' set_id=drop.awarded_badge.badge_set.set_id %}">
@ -128,6 +117,7 @@
{{ drop.awarded_badge.title }}
</a>
{% if drop.awarded_badge.description %}
<!-- Show the badge description if available -->
<div style="margin-top: 4px; color: #a9a9a9; font-size: 0.9em;">{{ drop.awarded_badge.description|linebreaksbr }}</div>
{% endif %}
</div>
@ -138,6 +128,7 @@
<div style="margin-bottom: 5px;">{{ drop.drop.name }}</div>
{% for benefit in drop.drop.benefits.all %}
{% if benefit.name != drop.drop.name %}
<!-- Show additional benefits if they have a different name than the drop itself -->
<div style="font-size: 0.9em; color: #a9a9a9; margin-bottom: 3px;">{{ benefit.name }}</div>
{% endif %}
{% endfor %}
@ -163,8 +154,27 @@
</tbody>
</table>
{% else %}
<!-- If there are no drops, show a message indicating that there are no drops available for this campaign -->
<p>No drops available for this campaign.</p>
{% endif %}
<!-- Allowed channels -->
{% if allowed_channels %}
<!-- If specific allowed channels are specified, list them -->
<h5>Allowed Channels</h5>
<div>
{% for channel in allowed_channels %}
<!-- {{ channel.display_name }} https://www.twitch.tv/{{ channel.display_name }} -->
<a href="{% url 'twitch:channel_detail' channel.twitch_id %}">{{ channel.display_name }}</a>
{% endfor %}
</div>
{% else %}
<!-- If no allowed channels are specified, link to the game's Twitch directory for channels with drops enabled -->
<a href="{{ campaign.game.twitch_directory_url }}"
rel="nofollow ugc"
title="Find streamers playing {{ campaign.game.display_name }} with drops enabled">
Go to a participating live channel
</a>
{% endif %}
<!-- Campaign JSON -->
{{ campaign_data|safe }}
{% endblock content %}

View file

@ -3,7 +3,7 @@
{% load image_tags %}
{% load image_tags %}
{% block title %}
Drop Campaigns - Twitch Drops Tracker
Drop Campaigns
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
@ -41,6 +41,7 @@
<select name="game">
<option value="">All Games</option>
{% for game in games %}
<!-- Game option with Twitch ID {{ game.twitch_id }} and display name "{{ game.display_name }}" -->
<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 }}
@ -51,6 +52,7 @@
<select id="status" name="status">
<option value="">All Statuses</option>
{% for status in status_options %}
<!-- Status option "{{ status }}" -->
<option value="{{ status }}"
{% if selected_status == status %}selected{% endif %}>{{ status|title }}</option>
{% endfor %}
@ -63,6 +65,7 @@
{% if campaigns %}
{% regroup campaigns by game as campaigns_by_game %}
{% for game_group in campaigns_by_game %}
<!-- Game group for game "{{ game_group.grouper.display_name }}" with {{ game_group.list|length }} campaigns -->
<section>
<div style="display: flex; gap: 1rem;">
<div>
@ -99,6 +102,9 @@
<div style="overflow-x: auto;">
<div style="display: flex; gap: 1rem; min-width: max-content;">
{% for campaign in game_group.list %}
<!-- Campaign "{{ campaign.clean_name }}" with Twitch ID {{ campaign.twitch_id }} -->
<!-- https://ttvdrops.lovinator.space{% url 'twitch:campaign_detail' campaign.twitch_id %} -->
<!-- https://ttvdrops.lovinator.space{{ campaign.image_best_url }} -->
<article style="display: flex;
flex-direction: column;
align-items: flex-start;
@ -108,7 +114,7 @@
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}"
style="text-decoration: none">
{% if campaign.image_best_url %}
{% picture campaign.image_best_url alt="Campaign artwork for "|add:campaign.name width=120 height=120 style="border-radius: 4px" %}
{% picture campaign.image_best_url alt="Campaign artwork for "|add:campaign.name width=120 %}
{% endif %}
<h4 style="margin: 0.5rem 0; text-align: left">{{ campaign.clean_name }}</h4>
</a>
@ -153,61 +159,34 @@
{% endif %}
<!-- Pagination -->
{% if is_paginated %}
<nav style="margin-top: 3rem; text-align: center;">
<div style="display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap">
<!-- {{ page_obj.paginator.count }} total campaigns, showing {{ page_obj.start_index }} to {{ page_obj.end_index }} on page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} -->
<nav style="text-align: center;">
<div>
{% if page_obj.has_previous %}
<a href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 100 %}per_page={{ selected_per_page }}&{% endif %}page=1"
style="padding: 0.5rem 1rem;
border: 1px solid;
border-radius: 4px;
text-decoration: none">[first]</a>
<a href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 100 %}per_page={{ selected_per_page }}&{% endif %}page=1">[first]</a>
<a href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 100 %}per_page={{ selected_per_page }}&{% endif %}page={{ page_obj.previous_page_number }}"
style="padding: 0.5rem 1rem;
border: 1px solid;
border-radius: 4px;
text-decoration: none"
aria-label="Previous">[previous]</a>
{% else %}
<span style="padding: 0.5rem 1rem;">[first]</span>
<span style="padding: 0.5rem 1rem;">[previous]</span>
<span>[first]</span>
<span>[previous]</span>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<span style="padding: 0.5rem 1rem; border-radius: 4px; font-weight: 600">{{ num }}</span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<a href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 100 %}per_page={{ selected_per_page }}&{% endif %}page={{ num }}"
style="padding: 0.5rem 1rem;
border: 1px solid;
border-radius: 4px;
text-decoration: none">{{ num }}</a>
<span>{{ num }}</span>
{% elif num > page_obj.number|add:'-10' and num < page_obj.number|add:'10' %}
<a href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 100 %}per_page={{ selected_per_page }}&{% endif %}page={{ num }}">{{ num }}</a>
{% elif num == 1 or num == page_obj.paginator.num_pages %}
<a href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 100 %}per_page={{ selected_per_page }}&{% endif %}page={{ num }}"
style="padding: 0.5rem 1rem;
border: 1px solid;
border-radius: 4px;
text-decoration: none">{{ num }}</a>
{% elif num == page_obj.number|add:'-4' or num == page_obj.number|add:'4' %}
<span style="padding: 0.5rem 1rem;">...</span>
<a href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 100 %}per_page={{ selected_per_page }}&{% endif %}page={{ num }}">{{ num }}</a>
{% elif num == page_obj.number|add:'-10' or num == page_obj.number|add:'10' %}
<span>...</span>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<a href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 100 %}per_page={{ selected_per_page }}&{% endif %}page={{ page_obj.next_page_number }}"
style="padding: 0.5rem 1rem;
border: 1px solid;
border-radius: 4px;
text-decoration: none">[next]</a>
<a href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 100 %}per_page={{ selected_per_page }}&{% endif %}page={{ page_obj.paginator.num_pages }}"
style="padding: 0.5rem 1rem;
border: 1px solid;
border-radius: 4px;
text-decoration: none">[last]</a>
<a href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 100 %}per_page={{ selected_per_page }}&{% endif %}page={{ page_obj.next_page_number }}">[next]</a>
<a href="?{% if selected_status %}status={{ selected_status }}&{% endif %}{% if selected_game %}game={{ selected_game }}&{% endif %}{% if selected_per_page != 100 %}per_page={{ selected_per_page }}&{% endif %}page={{ page_obj.paginator.num_pages }}">[last]</a>
{% else %}
<span style="padding: 0.5rem 1rem;">[next]</span>
<span style="padding: 0.5rem 1rem;">[last]</span>
<span>[next]</span>
<span>[last]</span>
{% endif %}
</div>
<small style="display: block; margin-top: 1rem;">

View file

@ -19,22 +19,26 @@
border: none">
</iframe>
<!-- Channel Info -->
<p>
<strong>Channel ID:</strong> {{ channel.twitch_id }}
</p>
<p>Channel ID: {{ channel.twitch_id }}</p>
{% if active_campaigns %}
<h5 id="active-campaigns-header">Active Campaigns</h5>
<table id="active-campaigns-table">
<h5>Active Campaigns</h5>
<table>
<tbody>
{% for campaign in active_campaigns %}
<tr id="campaign-row-{{ campaign.twitch_id }}">
<!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) -->
<tr>
<td>
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a>
{% if campaign.time_based_drops.all %}
<div class="campaign-benefits">
<!-- If the campaign has time-based drops, show the benefits in a nested div -->
<div>
<!-- swag swag swag {{campaign.sorted_benefits}} -->
{% for benefit in campaign.sorted_benefits %}
<span class="benefit-item" title="{{ benefit.name }}">
<!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) -->
<!-- {{ benefit.image_best_url }} -->
<span title="{{ benefit.name }}">
{% if benefit.image_best_url or benefit.image_asset_url %}
<!-- Show the benefit image if available -->
<img src="{{ benefit.image_best_url|default:benefit.image_asset_url }}"
alt="{{ benefit.name }}"
width="24"
@ -51,6 +55,7 @@
</td>
<td>
{% if campaign.game %}
<!-- If the campaign has an associated game, show the game name with a link to the game detail page -->
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">
{{ campaign.game.display_name|default:campaign.game.name }}
</a>
@ -67,18 +72,23 @@
</table>
{% endif %}
{% if upcoming_campaigns %}
<h5 id="upcoming-campaigns-header">Upcoming Campaigns</h5>
<table id="upcoming-campaigns-table">
<!-- If there are upcoming campaigns, show them in a separate section -->
<h5>Upcoming Campaigns</h5>
<table>
<tbody>
{% for campaign in upcoming_campaigns %}
<tr id="campaign-row-{{ campaign.twitch_id }}">
<!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) -->
<tr>
<td>
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a>
{% if campaign.time_based_drops.all %}
<div class="campaign-benefits">
<!-- If the campaign has time-based drops, show the benefits in a nested div -->
<div>
{% for benefit in campaign.sorted_benefits %}
<span class="benefit-item" title="{{ benefit.name }}">
<!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) -->
<span title="{{ benefit.name }}">
{% if benefit.image_best_url or benefit.image_asset_url %}
<!-- Show the benefit image if available -->
<img src="{{ benefit.image_best_url|default:benefit.image_asset_url }}"
alt="{{ benefit.name }}"
width="24"
@ -95,6 +105,7 @@
</td>
<td>
{% if campaign.game %}
<!-- If the campaign has an associated game, show the game name with a link to the game detail page -->
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">
{{ campaign.game.display_name|default:campaign.game.name }}
</a>
@ -111,17 +122,21 @@
</table>
{% endif %}
{% if expired_campaigns %}
<h5 id="expired-campaigns-header">Past Campaigns</h5>
<table id="expired-campaigns-table">
<!-- If there are expired campaigns, show them in a separate section -->
<h5>Past Campaigns</h5>
<table>
<tbody>
{% for campaign in expired_campaigns %}
<tr id="campaign-row-{{ campaign.twitch_id }}">
<!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) -->
<tr>
<td>
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a>
{% if campaign.time_based_drops.all %}
<div class="campaign-benefits">
<!-- If the campaign has time-based drops, show the benefits in a nested div -->
<div>
{% for benefit in campaign.sorted_benefits %}
<span class="benefit-item" title="{{ benefit.name }}">
<!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) -->
<span title="{{ benefit.name }}">
{% if benefit.image_best_url or benefit.image_asset_url %}
<img src="{{ benefit.image_best_url|default:benefit.image_asset_url }}"
alt="{{ benefit.name }}"
@ -139,6 +154,7 @@
</td>
<td>
{% if campaign.game %}
<!-- If the campaign has an associated game, show the game name with a link to the game detail page -->
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">
{{ campaign.game.display_name|default:campaign.game.name }}
</a>
@ -155,7 +171,7 @@
</table>
{% endif %}
{% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %}
<p id="no-campaigns-message">No campaigns found for this channel.</p>
<p>No campaigns found for this channel.</p>
{% endif %}
{{ channel_data|safe }}
{% endblock content %}

View file

@ -17,13 +17,24 @@
{% block content %}
<main>
<h1>Active Twitch Drops Campaigns</h1>
<p>
This page lists all currently active Twitch Drops campaigns
organized by game.
<br />
Click on a campaign for more details about it and how to
earn drops.
<br />
Individual RSS feeds are available under each game, and
there is also a global feed for all campaigns:
</p>
<!-- RSS Feeds -->
<div>
<a href="{% url 'twitch:campaign_feed' %}"
title="RSS feed for all campaigns">[rss - all campaigns]</a>
title="RSS feed for all campaigns">[rss]</a>
<a href="{% url 'twitch:campaign_feed_atom' %}"
title="Atom feed for campaigns">[atom - all campaigns]</a>
title="Atom feed for campaigns">[atom]</a>
</div>
<hr />
{% if campaigns_by_game %}
{% for game_id, game_data in campaigns_by_game.items %}
<article id="game-article-{{ game_id }}" style="margin-bottom: 2rem;">
@ -42,23 +53,22 @@
{% endif %}
</header>
<div style="display: flex; gap: 1rem;">
<div style="flex-shrink: 0;">
{% picture game_data.box_art alt="Box art for "|add:game_data.name width=200 height=267 style="border-radius: 8px" %}
</div>
<div style="flex-shrink: 0;">{% picture game_data.box_art alt="Box art for "|add:game_data.name width=200 %}</div>
<div style="flex: 1; overflow-x: auto;">
<div style="display: flex; gap: 1rem; min-width: max-content;">
{% for campaign_data in game_data.campaigns %}
<article id="campaign-article-{{ campaign_data.campaign.twitch_id }}"
style="display: flex;
<!-- {{ campaign_data.campaign.name }} -->
<article style="display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
flex-shrink: 0">
<div>
<a href="{% url 'twitch:campaign_detail' campaign_data.campaign.twitch_id %}">
{% picture campaign_data.campaign.image_best_url|default:campaign_data.campaign.image_url alt="Image for "|add:campaign_data.campaign.name width=120 height=120 style="border-radius: 4px" %}
{% picture campaign_data.campaign.image_best_url|default:campaign_data.campaign.image_url alt="Image for "|add:campaign_data.campaign.name width=120 %}
<h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign_data.campaign.clean_name }}</h4>
</a>
<!-- End time -->
<time datetime="{{ campaign_data.campaign.end_at|date:'c' }}"
title="{{ campaign_data.campaign.end_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
@ -66,6 +76,7 @@
text-align: left">
Ends in {{ campaign_data.campaign.end_at|timeuntil }}
</time>
<!-- Start time -->
<time datetime="{{ campaign_data.campaign.start_at|date:'c' }}"
title="{{ campaign_data.campaign.start_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
@ -73,6 +84,7 @@
text-align: left">
Started {{ campaign_data.campaign.start_at|timesince }} ago
</time>
<!-- Duration -->
<time datetime="{{ campaign_data.campaign.duration_iso }}"
title="{{ campaign_data.campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign_data.campaign.end_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
@ -87,154 +99,155 @@
list-style-type: none">
{% if campaign_data.campaign.allow_is_enabled %}
{% if campaign_data.allowed_channels %}
{% for channel in campaign_data.allowed_channels %}
{% if forloop.counter <= 5 %}
<li style="margin-bottom: 0.1rem;">
<a href="https://twitch.tv/{{ channel.name }}"
rel="nofollow ugc"
title="Watch {{ channel.display_name }} on Twitch">
{{ channel.display_name }}
</a>
<a href="{% url 'twitch:channel_detail' channel.twitch_id %}"
title="View {{ channel.display_name }} details"
style="font-family: monospace;
text-decoration: none">[i]</a>
{% for channel in campaign_data.allowed_channels|slice:":5" %}
<!-- {{ channel.name }} -->
<li style="margin-bottom: 0.1rem;">
<a href="https://twitch.tv/{{ channel.name }}"
rel="nofollow ugc"
title="Watch {{ channel.display_name }} on Twitch">
{{ channel.display_name }}</a><a href="{% url 'twitch:channel_detail' channel.twitch_id %}"
title="View {{ channel.display_name }} details"
style="font-family: monospace;
text-decoration: none">[i]</a>
</li>
{% endfor %}
{% else %}
<!-- No allowed channels means drops are available in any stream of the game's category -->
{% if campaign.game.twitch_directory_url %}
<li>
<a href="{{ campaign.game.twitch_directory_url }}"
rel="nofollow ugc"
title="Open Twitch category page for {{ campaign_data.campaign.game.display_name }} with Drops filter">
Browse {{ campaign_data.campaign.game.display_name }} category
</a>
</li>
{% else %}
<li>Failed to get Twitch category URL :(</li>
{% endif %}
{% endfor %}
{% endif %}
{% if campaign_data.allowed_channels|length > 5 %}
<!-- {{ campaign_data.allowed_channels|length }} allowed channels -->
<li style="margin-bottom: 0.1rem; color: #666; font-style: italic;">
... and {{ campaign_data.allowed_channels|length|add:"-5" }} more
</li>
{% endif %}
{% else %}
{% if campaign.game.twitch_directory_url %}
{% if campaign_data.campaign.game.twitch_directory_url %}
<!--{{ campaign_data.campaign.game.display_name }} Twitch directory URL: {{ campaign_data.campaign.game.twitch_directory_url }} -->
<li>
<a href="{{ campaign.game.twitch_directory_url }}"
<a href="{{ campaign_data.campaign.game.twitch_directory_url }}"
rel="nofollow ugc"
title="Open Twitch category page for {{ campaign_data.campaign.game.display_name }} with Drops filter">
Browse {{ campaign_data.campaign.game.display_name }} category
title="Find streamers playing {{ campaign_data.campaign.game.display_name }} with drops enabled">
Go to a participating live channel
</a>
</li>
{% else %}
<li>Failed to get Twitch category URL :(</li>
<!-- {{ campaign_data.campaign.game.display_name }} Twitch directory URL not available -->
<li>Failed to get Twitch directory URL :(</li>
{% endif %}
{% endif %}
{% else %}
{% if campaign_data.campaign.game.twitch_directory_url %}
<li>
<a href="{{ campaign_data.campaign.game.twitch_directory_url }}"
rel="nofollow ugc"
title="Find streamers playing {{ campaign_data.campaign.game.display_name }} with drops enabled">
Go to a participating live channel
</a>
</li>
{% else %}
<li>Failed to get Twitch directory URL :(</li>
{% endif %}
{% endif %}
</ul>
</ul>
</div>
</div>
</div>
</article>
{% endfor %}
</article>
{% endfor %}
</div>
</div>
</div>
</div>
</article>
{% endfor %}
{% 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 }}
</article>
{% endfor %}
{% 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 %}
</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>
{% 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 %}
</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 }}"
rel="nofollow ugc"
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 %}
{% if campaign.external_url %}
<div style="margin-top: 0.75rem;">
<a href="{{ campaign.external_url }}"
rel="nofollow ugc"
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 %}

View file

@ -6,6 +6,8 @@
{% block content %}
<h1>Emotes</h1>
{% for emote in emotes %}
<!-- Emote from campaign {{ emote.campaign.name }} -->
<!-- https://ttvdrops.lovinator.space{{ emote.image_url }} -->
<a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}"
title="{{ emote.campaign.name }}"
style="display: inline-block">

View file

@ -16,31 +16,41 @@
{% endif %}
{% endblock extra_head %}
{% block content %}
<!-- Game Title -->
<h1>
{{ game.display_name }}
{% if game.display_name != game.name and game.name %}<small>({{ game.name }})</small>{% endif %}
</h1>
<!-- Game image -->
{% if game.box_art_best_url %}
{% picture game.box_art_best_url alt=game.name width=160 %}
{% endif %}
<!-- Game owner -->
{% if owners %}
<small>
{% for owner in owners %}
<a id="owner-link-{{ owner.twitch_id }}"
href="{% url 'twitch:organization_detail' owner.twitch_id %}">{{ owner.name }}</a>
{% if not forloop.last %},{% endif %}
{% endfor %}
</small>
{% endif %}
<!-- RSS Feeds -->
<div>
<a href="{% url 'twitch:game_campaign_feed' game.twitch_id %}"
title="RSS feed for {{ game.display_name }} campaigns">[rss - {{ game.display_name|default:game.name|lower }}]</a>
<a href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}"
title="Atom feed for {{ game.display_name }} campaigns">[atom - {{ game.display_name|default:game.name|lower }}]</a>
<div style="display: flex; align-items: flex-start;">
<!-- Game image -->
<div style="margin-right: 16px;">
{% if game.box_art_best_url %}
{% picture game.box_art_best_url alt=game.name width=160 %}
{% endif %}
</div>
<!-- Game Title and Details -->
<div style="display: flex; flex-direction: column;">
<h1 style="margin-top: 0; margin-bottom: 0px;">
{{ game.display_name }}
{% if game.display_name != game.name and game.name %}<small>({{ game.name }})</small>{% endif %}
</h1>
<!-- Game owner -->
{% if owners %}
<small>
Owned by
{% for owner in owners %}
<a href="{% url 'twitch:organization_detail' owner.twitch_id %}">{{ owner.name }}</a>
{% if not forloop.last %},{% endif %}
{% endfor %}
</small>
{% endif %}
<div>
Twitch ID: <a href="https://www.twitch.tv/directory/category/{{ game.slug|urlencode }}">{{ game.twitch_id }}</a>
</div>
<div>Twitch slug: {{ game.slug }}</div>
<!-- RSS Feeds -->
<div>
<a href="{% url 'twitch:game_campaign_feed' game.twitch_id %}"
title="RSS feed for {{ game.display_name }} campaigns">[rss]</a>
<a href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}"
title="Atom feed for {{ game.display_name }} campaigns">[atom]</a>
</div>
</div>
</div>
{% if active_campaigns %}
<h5 id="active-campaigns-header">Active Campaigns</h5>

View file

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
Reward Campaigns - Twitch Drops Tracker
Reward Campaigns
{% endblock title %}
{% block extra_head %}
<link rel="alternate"
@ -14,47 +14,27 @@
href="{% url 'twitch:reward_campaign_feed_atom' %}" />
{% endblock extra_head %}
{% block content %}
<h1 id="page-title">Reward Campaigns (Quest Rewards)</h1>
<p>Browse all available quest reward campaigns</p>
<h1>Reward Campaigns</h1>
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
<div>
<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>
title="RSS feed for all reward campaigns">[rss]</a>
<a href="{% url 'twitch:reward_campaign_feed_atom' %}"
title="Atom feed for all reward campaigns">[atom]</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>
<p>This is an archive of old Twitch reward campaigns because we do not monitor them.</p>
<p>
Feel free to submit a pull request on <a href="https://github.com/TheLovinator1/ttvdrops">GitHub</a>
with a working implementation :-).
</p>
{% if reward_campaigns %}
{% comment %}
<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 }}">
<tr>
<td>
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
{% if campaign.brand %}
@ -83,12 +63,15 @@
{% endfor %}
</tbody>
</table>
{% endcomment %}
{% comment %}
<h5>Upcoming Reward Campaigns</h5>
<table>
<tbody>
{% for campaign in reward_campaigns %}
{% if campaign.starts_at > now %}
<tr id="reward-campaign-{{ campaign.twitch_id }}">
<tr>
<td>
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
{% if campaign.brand %}
@ -117,12 +100,13 @@
{% endfor %}
</tbody>
</table>
{% endcomment %}
<h5>Past Reward Campaigns</h5>
<table>
<tbody>
{% for campaign in reward_campaigns %}
{% if campaign.ends_at < now %}
<tr id="reward-campaign-{{ campaign.twitch_id }}">
<tr>
<td>
<a href="{% url 'twitch:reward_campaign_detail' campaign.twitch_id %}">
{% if campaign.brand %}

View file

@ -599,6 +599,14 @@ class DropCampaign(auto_prefetch.Model):
"""Return the campaign image URL for RSS enclosures."""
return self.image_best_url
@property
def sorted_benefits(self) -> list[DropBenefit]:
"""Return a sorted list of benefits for the campaign."""
benefits: list[DropBenefit] = []
for drop in self.time_based_drops.all(): # pyright: ignore[reportAttributeAccessIssue]
benefits.extend(drop.benefits.all()) # pyright: ignore[reportAttributeAccessIssue]
return sorted(benefits, key=lambda benefit: benefit.name)
# MARK: DropBenefit
class DropBenefit(auto_prefetch.Model):

View file

@ -503,7 +503,7 @@ class TestChannelListView:
)
game.owners.add(org1, org2)
campaign: DropCampaign = DropCampaign.objects.create(
_campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="camp1",
name="Campaign",
game=game,
@ -519,14 +519,11 @@ class TestChannelListView:
if isinstance(context, list):
context = context[-1]
# campaigns_by_game should include one deduplicated campaign entry for the game.
assert "campaigns_by_game" in context
assert game.twitch_id in context["campaigns_by_game"]
assert len(context["campaigns_by_game"][game.twitch_id]["campaigns"]) == 1
# Template renders each campaign with a stable id, so we can assert it appears once.
html = response.content.decode("utf-8")
assert html.count(f"campaign-article-{campaign.twitch_id}") == 1
@pytest.mark.django_db
def test_debug_view(self, client: Client) -> None:
"""Test debug view returns 200 and has games_without_owner in context."""

View file

@ -290,23 +290,29 @@ def search_view(request: HttpRequest) -> HttpResponse:
results["games"] = Game.objects.filter(
Q(name__istartswith=query) | Q(display_name__istartswith=query),
)
results["campaigns"] = DropCampaign.objects.filter(
Q(name__istartswith=query) | Q(description__icontains=query),
).select_related("game")
results["drops"] = TimeBasedDrop.objects.filter(
name__istartswith=query,
).select_related("campaign")
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")
results["badge_sets"] = ChatBadgeSet.objects.filter(
set_id__istartswith=query,
)
results["badges"] = ChatBadge.objects.filter(
Q(title__istartswith=query) | Q(description__icontains=query),
).select_related("badge_set")
@ -317,20 +323,25 @@ def search_view(request: HttpRequest) -> HttpResponse:
results["games"] = Game.objects.filter(
Q(name__icontains=query) | Q(display_name__icontains=query),
)
results["campaigns"] = DropCampaign.objects.filter(
Q(name__icontains=query) | Q(description__icontains=query),
).select_related("game")
results["drops"] = TimeBasedDrop.objects.filter(
name__icontains=query,
).select_related("campaign")
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")
results["badge_sets"] = ChatBadgeSet.objects.filter(set_id__icontains=query)
results["badges"] = ChatBadge.objects.filter(
Q(title__icontains=query) | Q(description__icontains=query),
@ -1127,42 +1138,13 @@ class GameDetailView(DetailView):
either end date or status.
"""
context: dict[str, Any] = super().get_context_data(**kwargs)
game: Game = self.get_object() # pyright: ignore[reportAssignmentType]
game: Game = self.object # pyright: ignore[reportAssignmentType]
now: datetime.datetime = timezone.now()
# For each drop, find awarded badge (distribution_type BADGE)
drop_awarded_badges: dict[str, ChatBadge] = {}
drops: QuerySet[TimeBasedDrop, TimeBasedDrop] = TimeBasedDrop.objects.filter(
campaign__game=game,
).prefetch_related("benefits")
# Materialize drops so we can iterate multiple times without extra DB hits
drops_list: list[TimeBasedDrop] = list(drops)
# Collect all benefit names that award badges
benefit_badge_titles: set[str] = set()
for drop in drops_list:
for benefit in drop.benefits.all():
if benefit.distribution_type == "BADGE" and benefit.name:
benefit_badge_titles.add(benefit.name)
# Bulk-load all matching ChatBadge instances to avoid N+1 queries
badges_by_title: dict[str, ChatBadge] = {
badge.title: badge
for badge in ChatBadge.objects.filter(title__in=benefit_badge_titles)
}
for drop in drops_list:
for benefit in drop.benefits.all():
if benefit.distribution_type == "BADGE":
badge: ChatBadge | None = badges_by_title.get(benefit.name)
if badge:
drop_awarded_badges[drop.twitch_id] = badge
all_campaigns: QuerySet[DropCampaign] = (
DropCampaign.objects
.filter(game=game)
.prefetch_related("game__owners")
.select_related("game")
.prefetch_related(
Prefetch(
"time_based_drops",
@ -1177,9 +1159,34 @@ class GameDetailView(DetailView):
.order_by("-end_at")
)
campaigns_list: list[DropCampaign] = list(all_campaigns)
# For each drop, find awarded badge (distribution_type BADGE)
drop_awarded_badges: dict[str, ChatBadge] = {}
benefit_badge_titles: set[str] = set()
for campaign in campaigns_list:
for drop in campaign.time_based_drops.all(): # pyright: ignore[reportAttributeAccessIssue]
for benefit in drop.benefits.all():
if benefit.distribution_type == "BADGE" and benefit.name:
benefit_badge_titles.add(benefit.name)
# Bulk-load all matching ChatBadge instances to avoid N+1 queries
badges_by_title: dict[str, ChatBadge] = {
badge.title: badge
for badge in ChatBadge.objects.filter(title__in=benefit_badge_titles)
}
for campaign in campaigns_list:
for drop in campaign.time_based_drops.all(): # pyright: ignore[reportAttributeAccessIssue]
for benefit in drop.benefits.all():
if benefit.distribution_type == "BADGE":
badge: ChatBadge | None = badges_by_title.get(benefit.name)
if badge:
drop_awarded_badges[drop.twitch_id] = badge
active_campaigns: list[DropCampaign] = [
campaign
for campaign in all_campaigns
for campaign in campaigns_list
if campaign.start_at is not None
and campaign.start_at <= now
and campaign.end_at is not None
@ -1195,7 +1202,7 @@ class GameDetailView(DetailView):
upcoming_campaigns: list[DropCampaign] = [
campaign
for campaign in all_campaigns
for campaign in campaigns_list
if campaign.start_at is not None and campaign.start_at > now
]
@ -1209,7 +1216,7 @@ class GameDetailView(DetailView):
expired_campaigns: list[DropCampaign] = [
campaign
for campaign in all_campaigns
for campaign in campaigns_list
if campaign.end_at is not None and campaign.end_at < now
]
@ -1229,10 +1236,10 @@ class GameDetailView(DetailView):
)
game_data: list[dict[str, Any]] = json.loads(serialized_game)
if all_campaigns.exists():
if campaigns_list:
serialized_campaigns = serialize(
"json",
all_campaigns,
campaigns_list,
fields=(
"twitch_id",
"name",
@ -2028,13 +2035,13 @@ class ChannelDetailView(DetailView):
dict: Context data with active, upcoming, and expired campaigns.
"""
context: dict[str, Any] = super().get_context_data(**kwargs)
channel: Channel = self.get_object() # pyright: ignore[reportAssignmentType]
channel: Channel = self.object # pyright: ignore[reportAssignmentType]
now: datetime.datetime = timezone.now()
all_campaigns: QuerySet[DropCampaign] = (
DropCampaign.objects
.filter(allow_channels=channel)
.prefetch_related("game__owners")
.select_related("game")
.prefetch_related(
Prefetch(
"time_based_drops",
@ -2049,9 +2056,11 @@ class ChannelDetailView(DetailView):
.order_by("-start_at")
)
campaigns_list: list[DropCampaign] = list(all_campaigns)
active_campaigns: list[DropCampaign] = [
campaign
for campaign in all_campaigns
for campaign in campaigns_list
if campaign.start_at is not None
and campaign.start_at <= now
and campaign.end_at is not None
@ -2067,7 +2076,7 @@ class ChannelDetailView(DetailView):
upcoming_campaigns: list[DropCampaign] = [
campaign
for campaign in all_campaigns
for campaign in campaigns_list
if campaign.start_at is not None and campaign.start_at > now
]
upcoming_campaigns.sort(
@ -2080,7 +2089,7 @@ class ChannelDetailView(DetailView):
expired_campaigns: list[DropCampaign] = [
campaign
for campaign in all_campaigns
for campaign in campaigns_list
if campaign.end_at is not None and campaign.end_at < now
]
@ -2091,10 +2100,10 @@ class ChannelDetailView(DetailView):
)
channel_data: list[dict[str, Any]] = json.loads(serialized_channel)
if all_campaigns.exists():
if campaigns_list:
serialized_campaigns: str = serialize(
"json",
all_campaigns,
campaigns_list,
fields=(
"twitch_id",
"name",
@ -2112,7 +2121,7 @@ class ChannelDetailView(DetailView):
channel_data[0]["fields"]["campaigns"] = campaigns_data
name: str = channel.display_name or channel.name or channel.twitch_id
total_campaigns: int = len(all_campaigns)
total_campaigns: int = len(campaigns_list)
description: str = f"{name} participates in {total_campaigns} drop campaign"
if total_campaigns > 1:
description += "s"