Refactor HTML

This commit is contained in:
Joakim Hellsén 2026-02-11 03:14:04 +01:00
commit 05eb0d92e3
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
27 changed files with 776 additions and 393 deletions

View file

@ -30,6 +30,8 @@
- Use template tags and filters for common operations
- Avoid complex logic in templates - move it to views or template tags
- Use static files properly with `{% load static %}`
- Avoid hiding controls with `<details>` or other collapse elements unless explicitly needed
- Prioritize accessibility and discoverability of features
## Settings
- Use environment variables in a single `settings.py` file
@ -67,3 +69,9 @@
- Management commands in `twitch/management/commands/` for data import and maintenance tasks
- Use `pyproject.toml` + uv for dependency and environment management
- Use `uv run python manage.py <command>` to run Django management commands
## Documentation & Project Organization
- Only create documentation files when explicitly requested by the user
- Do not generate markdown files summarizing work or changes unless asked
- Keep code comments focused on "why" not "what"; the code itself should be clear
- Update existing documentation rather than creating new files

View file

@ -27,6 +27,7 @@
"Facepunch",
"Feedly",
"filterwarnings",
"forloop",
"granian",
"gunicorn",
"Hellsén",

View file

@ -155,31 +155,38 @@
</style>
</head>
<body>
<h1 style="margin-top: 0.5em; margin-bottom: 0.5em; ">ttvdrops</h1>
<strong>Twitch:</strong>
<nav>
<a href="{% url 'twitch:dashboard' %}">Dashboard</a> |
<a href="{% url 'twitch:campaign_list' %}">Campaigns</a> |
<a href="{% url 'twitch:reward_campaign_list' %}">Rewards</a> |
<a href="{% url 'twitch:game_list' %}">Games</a> |
<a href="{% url 'twitch:org_list' %}">Orgs</a> |
<a href="{% url 'twitch:channel_list' %}">Channels</a> |
<a href="{% url 'twitch:badge_list' %}">Badges</a> |
<a href="{% url 'twitch:emote_gallery' %}">Emotes</a>
<br />
<strong>Other:</strong>
<a href="{% url 'twitch:docs_rss' %}">RSS</a> |
<a href="{% url 'twitch:debug' %}">Debug</a> |
<a href="{% url 'twitch:dataset_backups' %}">Dataset</a> |
<a href="https://github.com/sponsors/TheLovinator1">Donate</a> |
<a href="https://github.com/TheLovinator1/ttvdrops">GitHub</a>
<br />
<form action="{% url 'twitch:search' %}" method="get">
<a href="https://github.com/TheLovinator1/ttvdrops">GitHub</a> |
<form action="{% url 'twitch:search' %}"
method="get"
style="display: inline">
<input type="search"
name="q"
placeholder="Search..."
value="{{ request.GET.q }}" />
<button type="submit">Search</button>
</form>
<br />
<strong>Twitch</strong>
<a href="{% url 'twitch:campaign_list' %}">Campaigns</a> |
<a href="{% url 'twitch:reward_campaign_list' %}">Rewards</a> |
<a href="{% url 'twitch:games_grid' %}">Games</a> |
<a href="{% url 'twitch:org_list' %}">Orgs</a> |
<a href="{% url 'twitch:channel_list' %}">Channels</a> |
<a href="{% url 'twitch:badge_list' %}">Badges</a> |
<a href="{% url 'twitch:emote_gallery' %}">Emotes</a>
<br />
<strong>Other sites</strong>
<a href="#">Steam</a> |
<a href="#">Kick</a> |
<a href="#">YouTube</a>
</nav>
<hr />
{% if messages %}
<ul>
{% for message in messages %}

View file

@ -3,14 +3,9 @@
Chat Badges - ttvdrops
{% endblock title %}
{% block content %}
<h1>Twitch Chat Badges</h1>
<pre>
These are the global chat badges available on Twitch.
</pre>
<h1>{{ badge_sets.count }} Twitch Chat Badges</h1>
{% if badge_sets %}
<p>total badge sets: {{ badge_sets.count }}</p>
{% for data in badge_data %}
<hr />
<h2>
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">[{{ data.set.set_id }}]</a>
</h2>
@ -28,9 +23,10 @@ These are the global chat badges available on Twitch.
</td>
<td>
<strong>{{ badge.title }}</strong>
<br />
<br />
{% if badge.description != badge.title %}
<br>
{{ badge.description }}
{% endif %}
</td>
</tr>
</table>
@ -38,7 +34,6 @@ These are the global chat badges available on Twitch.
<br />
{% if data.badges|length > 1 %}<small>versions: {{ data.badges|length }}</small>{% endif %}
{% endfor %}
<hr />
{% else %}
<p>No badge sets found.</p>
<p>

View file

@ -3,33 +3,21 @@
{{ badge_set.set_id }} Badges - ttvdrops
{% endblock title %}
{% block content %}
<h1>
Badge Set: <strong>{{ badge_set.set_id }}</strong>
</h1>
<p>
<a href="{% url 'twitch:badge_list' %}">Back to all badges</a>
</p>
<ul>
<li>
<strong>Set ID:</strong> {{ badge_set.set_id }}
</li>
<li>
<strong>Total Versions:</strong> {{ badges.count }}
</li>
<li>
<strong>Added:</strong> {{ badge_set.added_at|date:"Y-m-d H:i:s T" }}
</li>
<li>
<strong>Updated:</strong> {{ badge_set.updated_at|date:"Y-m-d H:i:s T" }}
</li>
</ul>
<h1>{{ badge_set.set_id }}</h1>
{% if badges %}
<h2>Badge Versions ({{ badges.count }})</h2>
<h2>
{{ badges.count }}
{% if badges.count == 1 %}
version
{% else %}
versions
{% endif %}
</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Preview</th>
<th></th>
<th>Title</th>
<th>Description</th>
<th>Images</th>
@ -52,25 +40,25 @@
height: 72px !important;
object-fit: contain" />
</td>
<td>
<strong>{{ badge.title }}</strong>
</td>
<td>{{ badge.title }}</td>
<td>{{ badge.description }}</td>
<td style="font-size: 0.85em">
<a href="{{ badge.image_url_1x }}" target="_blank">18px</a> |
<a href="{{ badge.image_url_2x }}" target="_blank">36px</a> |
<a href="{{ badge.image_url_4x }}" target="_blank">72px</a>
<td>
<a href="{{ badge.image_url_1x }}" rel="nofollow ugc">[18px]</a>
<a href="{{ badge.image_url_2x }}" rel="nofollow ugc">[36px]</a>
<a href="{{ badge.image_url_4x }}" rel="nofollow ugc">[72px]</a>
</td>
<td>
{% if badge.click_url %}
<a href="{{ badge.click_url }}" target="_blank" rel="noopener">{{ badge.click_action|default:"visit_url" }}</a>
<a href="{{ badge.click_url }}" rel="nofollow ugc">{{ badge.click_action }}</a>
{% else %}
<em>None</em>
-
{% endif %}
</td>
</tr>
{% if badge.award_campaigns %}
<div style="margin-top: 8px; font-size: 0.9em;">
<strong>Awarded by Drop Campaigns:</strong>
<ul style="margin: 0; padding-left: 18px;">
<div>
The following campaigns have the same name as this badge and may be awarding it:
<ul>
{% for campaign in badge.award_campaigns %}
<li>
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a>
@ -79,8 +67,6 @@
</ul>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -5,52 +5,37 @@
{% endblock title %}
{% block content %}
<!-- Campaign Title -->
<h1>
{% if campaign.game %}
<h1 id="campaign-title">
<a href="{% url 'twitch:game_detail' campaign.game.twitch_id %}">{{ campaign.game.get_game_name }}</a> - {{ campaign.clean_name }}
</h1>
{% else %}
<h1 id="campaign-title">{{ campaign.clean_name }}</h1>
{{ campaign.clean_name }}
{% endif %}
{% if owner %}
<p id="campaign-owner">
<a href="{% url 'twitch:organization_detail' owner.twitch_id %}">{{ owner.name }}</a>
</h1>
<!-- Campaign Owners -->
{% for org in owners %}
<p>
<a href="{% url 'twitch:organization_detail' org.twitch_id %}">{{ org.name }}</a>
</p>
{% endif %}
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
{% if campaign.game %}
<a href="{% url 'twitch:game_campaign_feed' campaign.game.twitch_id %}"
style="margin-right: 1rem"
title="RSS feed for {{ campaign.game.display_name }} campaigns">RSS feed for {{ campaign.game.display_name }} campaigns</a>
{% endif %}
{% if owner %}
<a href="{% url 'twitch:organization_campaign_feed' owner.twitch_id %}"
style="margin-right: 1rem"
title="RSS feed for {{ owner.name }} campaigns">RSS feed for {{ owner.name }} campaigns</a>
{% endif %}
</div>
{% endfor %}
<!-- Campaign image -->
{% if campaign.image_best_url or campaign.image_url %}
<img id="campaign-image"
height="160"
{% if campaign.image_url %}
<img height="160"
width="160"
src="{{ campaign.image_best_url|default:campaign.image_url }}"
alt="{{ campaign.name }}" />
{% endif %}
<!-- Campaign description -->
<p id="campaign-description">{{ campaign.description|linebreaksbr }}</p>
<p>{{ campaign.description|linebreaksbr }}</p>
<!-- Campaign end times -->
<div>
{% if campaign.end_at < now %}
<time id="campaign-end-time"
datetime="{{ campaign.end_at|date:'c' }}"
<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 id="campaign-end-time"
datetime="{{ campaign.end_at|date:'c' }}"
<time datetime="{{ campaign.end_at|date:'c' }}"
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}">
<strong>Ends in</strong> {{ campaign.end_at|timeuntil }}
</time>
@ -59,73 +44,47 @@
<!-- Campaign start times -->
<div>
{% if campaign.start_at > now %}
<time id="campaign-start-time"
datetime="{{ campaign.start_at|date:'c' }}"
<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 id="campaign-start-time"
datetime="{{ campaign.start_at|date:'c' }}"
<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 added times -->
<div>
<time id="campaign-added-time"
datetime="{{ campaign.added_at|date:'c' }}"
title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}">
<strong>Scraped</strong> {{ campaign.added_at|timesince }} ago
</time>
</div>
<!-- Campaign duration -->
<div>
<time id="campaign-duration"
datetime="{{ campaign.start_at|date:'c' }} to {{ campaign.end_at|date:'c' }}"
<time datetime="{{ campaign.duration_iso }}"
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign.end_at|date:'DATETIME_FORMAT' }}">
<strong>Duration</strong> {{ campaign.start_at|timesince:campaign.end_at }}
<strong>Duration</strong> {{ campaign.end_at|timeuntil:campaign.start_at }}
</time>
</div>
<div>
<!-- Campaign Detail URL -->
{% if campaign.details_url %}
{# TODO: Archive this URL automatically #}
<p>
<a id="campaign-details-url"
href="{{ campaign.details_url }}"
target="_blank">Official Details</a>
</p>
{% endif %}
{% if campaign.details_url %}<a href="{{ campaign.details_url }}" rel="nofollow ugc">[details]</a>{% endif %}
<!-- Campaign Account Link URL -->
{% if campaign.account_link_url %}
{# TODO: Archive this URL automatically #}
<p>
<a id="campaign-account-link-url"
href="{{ campaign.account_link_url }}"
target="_blank">Connect Account</a>
</p>
<a href="{{ campaign.account_link_url }}" rel="nofollow ugc">[connect]</a>
{% endif %}
<!-- Allowed Channels -->
<!-- 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>
{% endif %}
</div>
{% if allowed_channels %}
<h5>Allowed Channels</h5>
<div id="allowed-channels" style="margin-bottom: 20px;">
<div>
{% for channel in allowed_channels %}
<a href="{% url 'twitch:channel_detail' channel.twitch_id %}"
style="display: inline-block;
margin: 2px 5px 2px 0;
padding: 3px 8px;
background-color: #9146ff;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 0.9em">{{ channel.display_name }}</a>
<a href="{% url 'twitch:channel_detail' channel.twitch_id %}">{{ channel.display_name }}</a>
{% endfor %}
</div>
{% else %}
<a href="{{ campaign.game.twitch_directory_url }}"
target="_blank"
rel="noopener noreferrer"
rel="nofollow ugc"
title="Find streamers playing {{ campaign.game.display_name }} with drops enabled">
Go to a participating live channel
</a>
@ -135,7 +94,7 @@
<table id="drops-table" style="border-collapse: collapse; width: 100%;">
<thead>
<tr>
<th>Benefits</th>
<th></th>
<th>Drop Name</th>
<th>Requirements</th>
<th>Period</th>

View file

@ -14,6 +14,24 @@
style="margin-right: 1rem"
title="RSS feed for all campaigns">RSS feed for all campaigns</a>
</div>
<!-- Export Options -->
<div style="margin-bottom: 1rem;
display: flex;
gap: 1rem;
flex-wrap: wrap">
<a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"
title="Export campaigns as CSV">📥 Campaigns (CSV)</a>
<a href="{% url 'twitch:export_campaigns_json' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"
title="Export campaigns as JSON">📥 Campaigns (JSON)</a>
<a href="{% url 'twitch:export_games_csv' %}"
title="Export all games as CSV">📥 Games (CSV)</a>
<a href="{% url 'twitch:export_games_json' %}"
title="Export all games as JSON">📥 Games (JSON)</a>
<a href="{% url 'twitch:export_organizations_csv' %}"
title="Export all organizations as CSV">📥 Organizations (CSV)</a>
<a href="{% url 'twitch:export_organizations_json' %}"
title="Export all organizations as JSON">📥 Organizations (JSON)</a>
</div>
</header>
<form id="filter-form"
method="get"

View file

@ -4,9 +4,9 @@
{% endblock title %}
{% block content %}
<!-- Channel Title -->
<h1 id="channel-name">{{ channel.display_name }}</h1>
<h1>{{ channel.display_name }}</h1>
{% if channel.display_name != channel.name %}
<p id="channel-username">
<p>
Username: <code>{{ channel.name }}</code>
</p>
{% endif %}
@ -14,13 +14,6 @@
<p>
<strong>Channel ID:</strong> {{ channel.twitch_id }}
</p>
<p>
<strong>Added to database:</strong>
<time datetime="{{ channel.added_at|date:'c' }}"
title="{{ channel.added_at|date:'DATETIME_FORMAT' }}">
{{ channel.added_at|timesince }} ago ({{ channel.added_at|date:'M d, Y H:i' }})
</time>
</p>
{% if active_campaigns %}
<h5 id="active-campaigns-header">Active Campaigns</h5>
<table id="active-campaigns-table">

View file

@ -1,21 +1,17 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
Channels - Twitch Drops Tracker
Channels
{% endblock title %}
{% block content %}
<h1 id="page-title">Channels</h1>
<h1>Channels</h1>
<p>Browse all channels that can participate in drop campaigns</p>
<form id="search-form"
method="get"
action="{% url 'twitch:channel_list' %}">
<label for="search">Search:</label>
<form method="get" action="{% url 'twitch:channel_list' %}">
<input type="text"
id="search"
name="search"
value="{{ search_query }}"
placeholder="Search channels..." />
<button id="search-button" type="submit">Search</button>
<button type="submit">Search</button>
{% if search_query %}
<a href="{% url 'twitch:channel_list' %}">Clear</a>
{% endif %}
@ -27,42 +23,36 @@
<th>Channel</th>
<th>Username</th>
<th>Campaigns</th>
<th>Added</th>
</tr>
</thead>
<tbody>
{% for channel in channels %}
<tr id="channel-row-{{ channel.twitch_id }}">
<tr>
<td>
<a id="channel-link-{{ channel.twitch_id }}"
href="{% url 'twitch:channel_detail' channel.twitch_id %}">{{ channel.display_name }}</a>
<a href="{% url 'twitch:channel_detail' channel.twitch_id %}">{{ channel.display_name }}</a>
</td>
<td>{{ channel.name }}</td>
<td>{{ channel.campaign_count|default:0 }}</td>
<td>
<time datetime="{{ channel.added_at|date:'c' }}"
title="{{ channel.added_at|date:'DATETIME_FORMAT' }}">
{{ channel.added_at|timesince }} ago
</time>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Pagination -->
{% if is_paginated %}
<div>
<p>
{% if page_obj.has_previous %}
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page=1">««</a>
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page={{ page_obj.previous_page_number }}">«</a>
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page=1">[first]</a>
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page={{ page_obj.previous_page_number }}">[previous]</a>
{% endif %}
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
{% if page_obj.has_next %}
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page={{ page_obj.next_page_number }}">»</a>
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page={{ page_obj.paginator.num_pages }}">»»</a>
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page={{ page_obj.next_page_number }}">[next]</a>
<a href="?{% if search_query %}search={{ search_query }}&{% endif %}page={{ page_obj.paginator.num_pages }}">[last]</a>
{% endif %}
</p>
<p>Showing {{ page_obj.start_index }} to {{ page_obj.end_index }} of {{ page_obj.paginator.count }} channels</p>
</div>
{% endif %}
{% else %}
{% if search_query %}

View file

@ -5,7 +5,7 @@
{% endblock title %}
{% block content %}
<main>
<h1 id="page-title">Twitch Drops</h1>
<h1>Twitch Drops</h1>
<pre>
Latest drops are shown first within each game. Click on a campaign or game title to see more details.
Hover over the end time to see the exact date and time.
@ -43,82 +43,73 @@ Hover over the end time to see the exact date and time.
</div>
<div style="flex: 1; overflow-x: auto;">
<div style="display: flex; gap: 1rem; min-width: max-content;">
{% for campaign in game_data.campaigns %}
<article id="campaign-article-{{ campaign.twitch_id }}"
{% for campaign_data in game_data.campaigns %}
<article id="campaign-article-{{ campaign_data.campaign.twitch_id }}"
style="display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
flex-shrink: 0">
<div>
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">
<img src="{{ campaign.image_url }}"
alt="Image for {{ campaign.name }}"
<a href="{% url 'twitch:campaign_detail' campaign_data.campaign.twitch_id %}">
<img src="{{ campaign_data.campaign.image_url }}"
alt="Image for {{ campaign_data.campaign.name }}"
width="120"
height="120"
style="border-radius: 4px" />
<h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign.clean_name }}</h4>
<h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign_data.campaign.clean_name }}</h4>
</a>
<time datetime="{{ campaign.end_at|date:'c' }}"
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}"
<time datetime="{{ campaign_data.campaign.end_at|date:'c' }}"
title="{{ campaign_data.campaign.end_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Ends in {{ campaign.end_at|timeuntil }}
Ends in {{ campaign_data.campaign.end_at|timeuntil }}
</time>
<time datetime="{{ campaign.start_at|date:'c' }}"
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}"
<time datetime="{{ campaign_data.campaign.start_at|date:'c' }}"
title="{{ campaign_data.campaign.start_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Started {{ campaign.start_at|timesince }} ago
Started {{ campaign_data.campaign.start_at|timesince }} ago
</time>
<time datetime="{{ campaign.added_at|date:'c' }}"
title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}"
<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;
display: block;
text-align: left">
Scraped {{ campaign.added_at|timesince }} ago
</time>
<time datetime="{{ campaign.start_at|date:'c' }} to {{ campaign.end_at|date:'c' }}"
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign.end_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Duration: {{ campaign.start_at|timesince:campaign.end_at }}
Duration: {{ campaign_data.campaign.start_at|timesince:campaign_data.campaign.end_at }}
</time>
<div style="margin-top: 0.5rem; font-size: 0.8rem; ">
<strong>Channels:</strong>
<ul style="margin: 0.25rem 0 0 0;
padding-left: 1rem;
list-style-type: none">
{% if campaign.allow_is_enabled %}
{% if campaign.allow_channels.all %}
{% for channel in campaign.allow_channels.all %}
{% 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 }}"
target="_blank"
rel="noopener noreferrer"
rel="nofollow ugc"
title="Watch {{ channel.display_name }} on Twitch">
{{ channel.display_name }}
</a>
</li>
{% endif %}
{% endfor %}
{% if campaign.allow_channels.all|length > 5 %}
{% if campaign_data.allowed_channels|length > 5 %}
<li style="margin-bottom: 0.1rem; color: #666; font-style: italic;">
... and {{ campaign.allow_channels.all|length|add:"-5" }} more
... and {{ campaign_data.allowed_channels|length|add:"-5" }} more
</li>
{% endif %}
{% else %}
{% if campaign.game.twitch_directory_url %}
<li>
<a href="{{ campaign.game.twitch_directory_url }}"
target="_blank"
rel="noopener noreferrer"
title="Open Twitch category page for {{ campaign.game.display_name }} with Drops filter">
Browse {{ campaign.game.display_name }} category
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 %}
@ -126,12 +117,11 @@ Hover over the end time to see the exact date and time.
{% endif %}
{% endif %}
{% else %}
{% if campaign.game.twitch_directory_url %}
{% if campaign_data.campaign.game.twitch_directory_url %}
<li>
<a href="{{ campaign.game.twitch_directory_url }}"
target="_blank"
rel="noopener noreferrer"
title="Find streamers playing {{ campaign.game.display_name }} with drops enabled">
<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>
@ -227,8 +217,7 @@ Hover over the end time to see the exact date and time.
{% if campaign.external_url %}
<div style="margin-top: 0.75rem;">
<a href="{{ campaign.external_url }}"
target="_blank"
rel="noopener noreferrer"
rel="nofollow ugc"
style="display: inline-block;
padding: 0.5rem 1rem;
background-color: #9146ff;

View file

@ -1,41 +1,32 @@
{% extends "base.html" %}
{% block title %}
Dataset Backups
Dataset
{% endblock title %}
{% block content %}
<main>
<h1 id="page-title">Dataset Backups</h1>
<p>Scanning {{ data_dir }} for database backups.</p>
<h1>Dataset Backups</h1>
{% if datasets %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Path</th>
<th>Size</th>
<th>Updated</th>
<th>Download</th>
</tr>
</thead>
<tbody>
{% for dataset in datasets %}
<tr id="dataset-row-{{ forloop.counter }}">
<td>{{ dataset.name }}</td>
<td>{{ dataset.display_path }}</td>
<tr">
<td>
<a href="{% url 'twitch:dataset_backup_download' dataset.download_path %}">{{ dataset.name }}</a>
</td>
<td>{{ dataset.size }}</td>
<td>
<time datetime="{{ dataset.updated_at|date:'c' }}"
title="{{ dataset.updated_at|date:'DATETIME_FORMAT' }}">
{{ dataset.updated_at|timesince }} ago
{{ dataset.updated_at|timesince }} ago ({{ dataset.updated_at|date:'M d, Y H:i' }})
</time>
</td>
<td>
{% if dataset.download_path %}
<a href="{% url 'twitch:dataset_backup_download' dataset.download_path %}">Download</a>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>

View file

@ -3,12 +3,13 @@
Debug
{% endblock title %}
{% block content %}
<h1 id="page-title">Debug Data Integrity Report</h1>
<h1>Debug</h1>
<p>
Generated at: <time id="generation-time">{{ now }}</time>
Generated at: <time datetime="{{ now|date:'c' }}"
title="{{ now|date:'DATETIME_FORMAT' }}">{{ now }}</time>
</p>
<section>
<h2 id="operation-names-header">Distinct GraphQL operation_names ({{ operation_names_with_counts|length }})</h2>
<h2>Distinct GraphQL operation_names ({{ operation_names_with_counts|length }})</h2>
{% if operation_names_with_counts %}
<table id="operation-names-table">
<thead>
@ -29,11 +30,11 @@
{% endif %}
</section>
<section>
<h2 id="games-without-owner-header">Games Without an Assigned Owner ({{ games_without_owner|length }})</h2>
<h2>Games Without an Assigned Owner ({{ games_without_owner|length }})</h2>
{% if games_without_owner %}
<ul id="games-without-owner-list">
<ul>
{% for game in games_without_owner %}
<li id="game-{{ game.twitch_id }}">
<li>
<a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game.display_name }}</a> (ID: {{ game.twitch_id }})
</li>
{% endfor %}
@ -43,14 +44,13 @@
{% endif %}
</section>
<section>
<h2 id="broken-image-campaigns-header">Campaigns With Broken Image URLs ({{ broken_image_campaigns|length }})</h2>
<h2>Campaigns without Image URLs ({{ broken_image_campaigns|length }})</h2>
{% if broken_image_campaigns %}
<ul id="broken-image-campaigns-list">
<ul>
{% for c in broken_image_campaigns %}
<li id="campaign-{{ c.twitch_id }}">
<li>
<a href="{% url 'twitch:campaign_detail' c.twitch_id %}">{{ c.name }}</a>
(Game: <a href="{% url 'twitch:game_detail' c.game.twitch_id %}">{{ c.game.display_name }}</a>)
- URL: {{ c.image_best_url|default:c.image_url|default:'(empty)' }}
</li>
{% endfor %}
</ul>
@ -59,16 +59,13 @@
{% endif %}
</section>
<section>
<h2 id="broken-benefit-images-header">Benefits With Broken Image URLs ({{ broken_benefit_images|length }})</h2>
<h2>Benefits without image URLs ({{ broken_benefit_images|length }})</h2>
{% if broken_benefit_images %}
<ul id="broken-benefit-images-list">
<ul>
{% for b in broken_benefit_images %}
{# A benefit is linked to a game via a drop and a campaign. #}
{# We use the 'with' tag to get the first drop for cleaner access. #}
{% with first_drop=b.drops.all.0 %}
<li id="benefit-{{ b.twitch_id }}">
<li>
{{ b.name }}
{# Check if the relationship path to the game exists #}
{% if first_drop and first_drop.campaign and first_drop.campaign.game %}
(Game: <a href="{% url 'twitch:game_detail' first_drop.campaign.game.twitch_id %}">{{ first_drop.campaign.game.display_name }}</a>)
{% else %}
@ -84,11 +81,11 @@
{% endif %}
</section>
<section>
<h2 id="active-missing-image-header">Active Campaigns Missing Image ({{ active_missing_image|length }})</h2>
<h2>Active Campaigns Missing Image ({{ active_missing_image|length }})</h2>
{% if active_missing_image %}
<ul id="active-missing-image-list">
<ul>
{% for c in active_missing_image %}
<li id="campaign-{{ c.twitch_id }}">
<li>
<a href="{% url 'twitch:campaign_detail' c.twitch_id %}">{{ c.name }}</a>
(Game: <a href="{% url 'twitch:game_detail' c.game.twitch_id %}">{{ c.game.display_name }}</a>)
</li>
@ -99,11 +96,11 @@
{% endif %}
</section>
<section>
<h2 id="drops-without-benefits-header">Time-Based Drops Without Benefits ({{ drops_without_benefits|length }})</h2>
<h2>Time-Based Drops Without Benefits ({{ drops_without_benefits|length }})</h2>
{% if drops_without_benefits %}
<ul id="drops-without-benefits-list">
<ul>
{% for d in drops_without_benefits %}
<li id="drop-{{ d.twitch_id }}">
<li>
{{ d.name }}
(Campaign: <a href="{% url 'twitch:campaign_detail' d.campaign.twitch_id %}">{{ d.campaign.name }}</a>
in Game: <a href="{% url 'twitch:game_detail' d.campaign.game.twitch_id %}">{{ d.campaign.game.display_name }}</a>)
@ -115,11 +112,11 @@
{% endif %}
</section>
<section>
<h2 id="invalid-date-campaigns-header">Campaigns With Invalid Dates ({{ invalid_date_campaigns|length }})</h2>
<h2>Campaigns With Invalid Dates ({{ invalid_date_campaigns|length }})</h2>
{% if invalid_date_campaigns %}
<ul id="invalid-date-campaigns-list">
<ul>
{% for c in invalid_date_campaigns %}
<li id="campaign-{{ c.twitch_id }}">
<li>
<a href="{% url 'twitch:campaign_detail' c.twitch_id %}">{{ c.name }}</a>
(Game: <a href="{% url 'twitch:game_detail' c.game.twitch_id %}">{{ c.game.display_name }}</a>)
- Start: {{ c.start_at|default:'(none)' }} / End: {{ c.end_at|default:'(none)' }}
@ -131,9 +128,9 @@
{% endif %}
</section>
<section>
<h2 id="duplicate-name-campaigns-header">Duplicate Campaign Names Per Game ({{ duplicate_name_campaigns|length }})</h2>
<h2>Duplicate Campaign Names Per Game ({{ duplicate_name_campaigns|length }})</h2>
{% if duplicate_name_campaigns %}
<table id="duplicate-name-campaigns-table">
<table>
<thead>
<tr>
<th>Game</th>
@ -158,13 +155,11 @@
{% endif %}
</section>
<section>
<h2 id="missing-details-campaigns-header">
Campaigns Missing DropCampaignDetails ({{ campaigns_missing_dropcampaigndetails|length }})
</h2>
<h2>Campaigns Missing DropCampaignDetails ({{ campaigns_missing_dropcampaigndetails|length }})</h2>
{% if campaigns_missing_dropcampaigndetails %}
<ul id="missing-details-campaigns-list">
<ul>
{% for c in campaigns_missing_dropcampaigndetails %}
<li id="campaign-{{ c.twitch_id }}">
<li>
<a href="{% url 'twitch:campaign_detail' c.twitch_id %}">{{ c.name }}</a>
(Game: <a href="{% url 'twitch:game_detail' c.game.twitch_id %}">{{ c.game.display_name }}</a>)
- Operations: {{ c.operation_names|join:", "|default:'(none)' }}

View file

@ -4,11 +4,6 @@
{% endblock title %}
{% block content %}
<h1>Emotes</h1>
<div class="emote-gallery"
style="display: flex;
flex-wrap: wrap;
gap: 1.5rem;
justify-content: flex-start">
{% for emote in emotes %}
<a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}"
title="{{ emote.campaign.name }}"
@ -18,14 +13,10 @@
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"
max-height: 96px"
loading="lazy" />
</a>
{% empty %}
<p>No drop campaigns with emotes found.</p>
{% endfor %}
</div>
{% endblock content %}

View file

@ -4,22 +4,21 @@
{% endblock title %}
{% block content %}
<!-- Game Title -->
<h1 id="game-name">
<h1>
{{ game.display_name }}
{% if game.display_name != game.name and game.name %}<small>({{ game.name }})</small>{% endif %}
</h1>
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
<div>
<a href="{% url 'twitch:game_campaign_feed' game.twitch_id %}"
style="margin-right: 1rem"
title="RSS feed for {{ game.display_name }} campaigns">RSS feed for {{ game.display_name }} campaigns</a>
{% if owner %}
{% if owners %}
{% for owner in owners %}
<a href="{% url 'twitch:organization_campaign_feed' owner.twitch_id %}"
style="margin-right: 1rem"
title="RSS feed for {{ owner.name }} campaigns">RSS feed for {{ owner.name }} campaigns</a>
{% endfor %}
{% endif %}
<a href="{% url 'twitch:campaign_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all campaigns">RSS feed for all campaigns</a>
</div>
<!-- Game image -->
@ -31,9 +30,14 @@
alt="{{ game.name }}" />
{% endif %}
<!-- Game owner -->
{% if owner %}
<small><a id="owner-link"
href="{% url 'twitch:organization_detail' owner.twitch_id %}">{{ owner.name }}</a></small>
{% 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 %}
{% if active_campaigns %}
<h5 id="active-campaigns-header">Active Campaigns</h5>

View file

@ -6,15 +6,13 @@
<main>
<header>
<h1 id="page-title">All Games</h1>
<p>Browse all available games</p>
<p>
<a href="{% url 'twitch:game_list_simple' %}">List View</a>
</p>
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
<a href="{% url 'twitch:game_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all games">RSS feed for all games</a>
<div>
<a href="{% url 'twitch:games_list' %}" title="View games as list">[list]</a>
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a>
<a href="{% url 'twitch:export_games_csv' %}"
title="Export all games as CSV">[csv]</a>
<a href="{% url 'twitch:export_games_json' %}"
title="Export all games as JSON">[json]</a>
</div>
</header>
{% if games_by_org %}

View file

@ -4,22 +4,23 @@
{% endblock title %}
{% block content %}
<main>
<h1 id="page-title">Games List</h1>
<p>
<a href="{% url 'twitch:game_list' %}">Grid View</a>
</p>
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
<a href="{% url 'twitch:game_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all games">RSS feed for all games</a>
<h1>Games List</h1>
<div>
<a href="{% url 'twitch:games_grid' %}" title="View games as grid">[grid]</a>
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a>
<a href="{% url 'twitch:export_games_csv' %}"
title="Export all games as CSV">[csv]</a>
<a href="{% url 'twitch:export_games_json' %}"
title="Export all games as JSON">[json]</a>
</div>
{% if games_by_org %}
{% for organization, games in games_by_org.items %}
<h2 id="org-{{ organization.twitch_id }}">{{ organization.name }}</h2>
<ul style="list-style: none; padding: 0; margin: 0;">
<h2>
<a href="{% url 'twitch:organization_detail' organization.twitch_id %}">{{ organization.name }}</a>
</h2>
<ul>
{% for item in games %}
<li id="game-{{ item.game.twitch_id }}">
<li>
<a href="{% url 'twitch:game_detail' item.game.twitch_id %}">{{ item.game.display_name }}</a>
</li>
{% endfor %}

View file

@ -10,6 +10,13 @@
style="margin-right: 1rem"
title="RSS feed for all organizations">RSS feed for organizations</a>
</div>
<!-- Export Options -->
<div style="margin-bottom: 1rem; display: flex; gap: 1rem;">
<a href="{% url 'twitch:export_organizations_csv' %}"
title="Export all organizations as CSV">[csv]</a>
<a href="{% url 'twitch:export_organizations_json' %}"
title="Export all organizations as JSON">[json]</a>
</div>
{% if orgs %}
<ul id="org-list">
{% for organization in orgs %}

View file

@ -90,14 +90,10 @@
{% 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>
<a href="{{ reward_campaign.external_url }}" rel="nofollow ugc">Claim Reward →</a>
{% endif %}
{% if reward_campaign.about_url %}
<a href="{{ reward_campaign.about_url }}"
target="_blank"
rel="noopener noreferrer">Learn More →</a>
<a href="{{ reward_campaign.about_url }}" rel="nofollow ugc">Learn More →</a>
{% endif %}
</p>
{% endif %}

View file

@ -48,7 +48,15 @@ def _with_campaign_related(queryset: QuerySet[DropCampaign]) -> QuerySet[DropCam
queryset=TimeBasedDrop.objects.prefetch_related("benefits"),
)
return queryset.select_related("game").prefetch_related("game__owners", "allow_channels", drops_prefetch)
return queryset.select_related("game").prefetch_related(
"game__owners",
Prefetch(
"allow_channels",
queryset=Channel.objects.order_by("display_name"),
to_attr="channels_ordered",
),
drops_prefetch,
)
def insert_date_info(item: Model, parts: list[SafeText]) -> None:
@ -122,30 +130,27 @@ def _build_drops_data(drops_qs: QuerySet[TimeBasedDrop]) -> list[dict]:
return drops_data
def _build_channels_html(channels: QuerySet[Channel], game: Game | None) -> SafeText:
def _build_channels_html(channels: list[Channel] | QuerySet[Channel], game: Game | None) -> SafeText:
"""Render up to max_links channel links as <li>, then a count of additional channels, or fallback to game category link.
If only one channel and drop_requirements is '1 subscriptions required',
merge the Twitch link with the '1 subs' row.
Args:
channels (QuerySet[Channel]): The queryset of channels.
channels (list[Channel] | QuerySet[Channel]): The channels (already ordered).
game (Game | None): The game object for fallback link.
Returns:
SafeText: HTML <ul> with up to max_links channel links, count of more, or fallback link.
""" # noqa: E501
max_links = 5
channels_all: list[Channel] = list(channels.all())
channels_all: list[Channel] = list(channels) if isinstance(channels, list) else list(channels.all())
total: int = len(channels_all)
if channels_all:
items: list[SafeString] = [
format_html(
"<li>"
'<a href="https://twitch.tv/{}" target="_blank" rel="noopener noreferrer"'
' title="Watch {} on Twitch">{}</a>'
"</li>",
'<li><a href="https://twitch.tv/{}" title="Watch {} on Twitch">{}</a></li>',
ch.name,
ch.display_name,
ch.display_name,
@ -175,10 +180,7 @@ def _build_channels_html(channels: QuerySet[Channel], game: Game | None) -> Safe
# If no channel is associated, the drop is category-wide; link to the game's Twitch directory
display_name: str = getattr(game, "display_name", "this game")
return format_html(
"<ul><li>"
'<a href="{}" target="_blank" rel="noopener noreferrer"'
' title="Browse {} category">Category-wide for {}</a>'
"</li></ul>",
'<ul><li><a href="{}" title="Browse {} category">Category-wide for {}</a></li></ul>',
game.twitch_directory_url,
display_name,
display_name,
@ -189,9 +191,7 @@ def _get_channel_name_from_drops(drops: QuerySet[TimeBasedDrop]) -> str | None:
for d in drops:
campaign: DropCampaign | None = getattr(d, "campaign", None)
if campaign:
allow_channels: QuerySet[Channel] | None = getattr(campaign, "allow_channels", None)
if allow_channels:
channels: QuerySet[Channel, Channel] = allow_channels.all()
channels: list[Channel] | None = getattr(campaign, "channels_ordered", None)
if channels:
return channels[0].name
return None
@ -279,7 +279,7 @@ def _construct_drops_summary(drops_data: list[dict]) -> SafeText:
badge_desc: str | None = badge_descriptions_by_title.get(benefit_name)
if is_sub_required and channel_name:
linked_name: SafeString = format_html(
'<a href="https://twitch.tv/{}" target="_blank">{}</a>',
'<a href="https://twitch.tv/{}" >{}</a>',
channel_name,
benefit_name,
)
@ -427,7 +427,7 @@ class GameFeed(Feed):
if slug:
description_parts.append(
SafeText(
f"<p><a href='https://www.twitch.tv/directory/game/{slug}' target='_blank' rel='noopener noreferrer'>{game_name} by {game_owner}</a></p>", # noqa: E501
f"<p><a href='https://www.twitch.tv/directory/game/{slug}'>{game_name} by {game_owner}</a></p>",
),
)
else:
@ -559,7 +559,7 @@ class DropCampaignFeed(Feed):
# Only show channels if drop is not subscription only
if not getattr(item, "is_subscription_only", False):
channels: QuerySet[Channel] | None = getattr(item, "allow_channels", None)
channels: list[Channel] | None = getattr(item, "channels_ordered", None)
if channels is not None:
game: Game | None = getattr(item, "game", None)
parts.append(_build_channels_html(channels, game=game))
@ -704,7 +704,7 @@ class GameCampaignFeed(Feed):
# Only show channels if drop is not subscription only
if not getattr(item, "is_subscription_only", False):
channels: QuerySet[Channel] | None = getattr(item, "allow_channels", None)
channels: list[Channel] | None = getattr(item, "channels_ordered", None)
if channels is not None:
game: Game | None = getattr(item, "game", None)
parts.append(_build_channels_html(channels, game=game))
@ -888,7 +888,7 @@ class OrganizationCampaignFeed(Feed):
# Only show channels if drop is not subscription only
if not getattr(item, "is_subscription_only", False):
channels: QuerySet[Channel] | None = getattr(item, "allow_channels", None)
channels: list[Channel] | None = getattr(item, "channels_ordered", None)
if channels is not None:
game: Game | None = getattr(item, "game", None)
parts.append(_build_channels_html(channels, game=game))

View file

@ -66,8 +66,7 @@ class Organization(auto_prefetch.Model):
url: str = reverse("twitch:organization_detail", args=[self.twitch_id])
return format_html(
"<p>New Twitch organization added to TTVDrops:</p>\n"
'<p><a href="{}" target="_blank" rel="noopener noreferrer">{}</a></p>',
'<p>New Twitch organization added to TTVDrops:</p>\n<p><a href="{}">{}</a></p>',
url,
name,
)
@ -456,6 +455,38 @@ class DropCampaign(auto_prefetch.Model):
)
return self.image_url or ""
@property
def duration_iso(self) -> str:
"""Return the campaign duration in ISO 8601 format (e.g., 'P3DT4H30M').
This is used for the <time> element's datetime attribute to provide machine-readable duration.
If start_at or end_at is missing, returns an empty string.
"""
if not self.start_at or not self.end_at:
return ""
total_seconds: int = int((self.end_at - self.start_at).total_seconds())
if total_seconds < 0:
total_seconds = abs(total_seconds)
days, remainder = divmod(total_seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, seconds = divmod(remainder, 60)
time_parts: list[str] = []
if hours:
time_parts.append(f"{hours}H")
if minutes:
time_parts.append(f"{minutes}M")
if seconds or not time_parts:
time_parts.append(f"{seconds}S")
if days and time_parts:
return f"P{days}DT{''.join(time_parts)}"
if days:
return f"P{days}D"
return f"PT{''.join(time_parts)}"
@property
def is_subscription_only(self) -> bool:
"""Determine if the campaign is subscription only based on its benefits."""

View file

@ -126,27 +126,20 @@ class TestBackupCommand:
assert output_dir.exists()
assert len(list(output_dir.glob("test-*.sql.zst"))) == 1
def test_backup_uses_default_directory(self) -> None:
def test_backup_uses_default_directory(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that backup uses DATA_DIR/datasets by default."""
# Create test data so tables exist
Organization.objects.create(twitch_id="test004", name="Test Org")
datasets_dir = settings.DATA_DIR / "datasets"
monkeypatch.setattr(settings, "DATA_DIR", tmp_path)
datasets_dir = tmp_path / "datasets"
datasets_dir.mkdir(exist_ok=True, parents=True)
# Clean up any existing test backups
for old_backup in datasets_dir.glob("ttvdrops-*.sql.zst"):
old_backup.unlink()
call_command("backup_db")
backup_files = list(datasets_dir.glob("ttvdrops-*.sql.zst"))
assert len(backup_files) >= 1
# Clean up
for backup in backup_files:
backup.unlink()
@pytest.mark.django_db
class TestBackupHelperFunctions:

View file

@ -118,7 +118,6 @@ class TestBadgeSetDetailView:
content = response.content.decode()
assert "vip" in content
assert "Total Versions:" in content
assert "1" in content
def test_badge_set_detail_json_data(self, client: Client) -> None:

View file

@ -0,0 +1,125 @@
from __future__ import annotations
import json
from datetime import timedelta
from django.test import Client
from django.test import TestCase
from django.utils import timezone
from twitch.models import DropCampaign
from twitch.models import Game
from twitch.models import Organization
class ExportViewsTestCase(TestCase):
"""Test export views for CSV and JSON formats."""
def setUp(self) -> None:
"""Set up test data."""
self.client = Client()
# Create test organization
self.org = Organization.objects.create(
twitch_id="org123",
name="Test Organization",
)
# Create test game
self.game = Game.objects.create(
twitch_id="game123",
name="Test Game",
display_name="Test Game Display",
)
self.game.owners.add(self.org)
# Create test campaign
now = timezone.now()
self.campaign = DropCampaign.objects.create(
twitch_id="campaign123",
name="Test Campaign",
description="A test campaign description",
game=self.game,
start_at=now - timedelta(days=1),
end_at=now + timedelta(days=1),
)
def test_export_campaigns_csv(self) -> None:
"""Test CSV export of campaigns."""
response = self.client.get("/export/campaigns/csv/")
assert response.status_code == 200
assert response["Content-Type"] == "text/csv"
assert b"Twitch ID" in response.content
assert b"campaign123" in response.content
assert b"Test Campaign" in response.content
def test_export_campaigns_json(self) -> None:
"""Test JSON export of campaigns."""
response = self.client.get("/export/campaigns/json/")
assert response.status_code == 200
assert response["Content-Type"] == "application/json"
data = json.loads(response.content)
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["twitch_id"] == "campaign123"
assert data[0]["name"] == "Test Campaign"
assert data[0]["status"] == "Active"
def test_export_games_csv(self) -> None:
"""Test CSV export of games."""
response = self.client.get("/export/games/csv/")
assert response.status_code == 200
assert response["Content-Type"] == "text/csv"
assert b"Twitch ID" in response.content
assert b"game123" in response.content
assert b"Test Game Display" in response.content
def test_export_games_json(self) -> None:
"""Test JSON export of games."""
response = self.client.get("/export/games/json/")
assert response.status_code == 200
assert response["Content-Type"] == "application/json"
data = json.loads(response.content)
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["twitch_id"] == "game123"
assert data[0]["display_name"] == "Test Game Display"
def test_export_organizations_csv(self) -> None:
"""Test CSV export of organizations."""
response = self.client.get("/export/organizations/csv/")
assert response.status_code == 200
assert response["Content-Type"] == "text/csv"
assert b"Twitch ID" in response.content
assert b"org123" in response.content
assert b"Test Organization" in response.content
def test_export_organizations_json(self) -> None:
"""Test JSON export of organizations."""
response = self.client.get("/export/organizations/json/")
assert response.status_code == 200
assert response["Content-Type"] == "application/json"
data = json.loads(response.content)
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["twitch_id"] == "org123"
assert data[0]["name"] == "Test Organization"
def test_export_campaigns_csv_with_filters(self) -> None:
"""Test CSV export of campaigns with status filter."""
response = self.client.get("/export/campaigns/csv/?status=active")
assert response.status_code == 200
assert b"campaign123" in response.content
def test_export_campaigns_json_with_filters(self) -> None:
"""Test JSON export of campaigns with status filter."""
response = self.client.get("/export/campaigns/json/?status=active")
assert response.status_code == 200
data = json.loads(response.content)
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["status"] == "Active"

View file

@ -442,8 +442,8 @@ URL_NAMES: list[tuple[str, dict[str, str]]] = [
("twitch:debug", {}),
("twitch:docs_rss", {}),
("twitch:emote_gallery", {}),
("twitch:game_list", {}),
("twitch:game_list_simple", {}),
("twitch:games_grid", {}),
("twitch:games_list", {}),
("twitch:game_detail", {"twitch_id": "test-game-123"}),
("twitch:org_list", {}),
("twitch:organization_detail", {"twitch_id": "test-org-123"}),

View file

@ -522,14 +522,14 @@ class TestChannelListView:
@pytest.mark.django_db
def test_games_grid_view(self, client: Client) -> None:
"""Test games grid view returns 200 and has games in context."""
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:game_list"))
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:games_grid"))
assert response.status_code == 200
assert "games" in response.context
@pytest.mark.django_db
def test_games_list_view(self, client: Client) -> None:
"""Test games list view returns 200 and has games in context."""
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:game_list_simple"))
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:games_list"))
assert response.status_code == 200
assert "games" in response.context

View file

@ -35,14 +35,20 @@ urlpatterns: list[URLPattern] = [
),
path("docs/rss/", views.docs_rss_view, name="docs_rss"),
path("emotes/", views.emote_gallery_view, name="emote_gallery"),
path("games/", views.GamesGridView.as_view(), name="game_list"),
path("games/list/", views.GamesListView.as_view(), name="game_list_simple"),
path("games/", views.GamesGridView.as_view(), name="games_grid"),
path("games/list/", views.GamesListView.as_view(), name="games_list"),
path("games/<str:twitch_id>/", views.GameDetailView.as_view(), name="game_detail"),
path("organizations/", views.org_list_view, name="org_list"),
path("organizations/<str:twitch_id>/", views.organization_detail_view, name="organization_detail"),
path("reward-campaigns/", views.reward_campaign_list_view, name="reward_campaign_list"),
path("reward-campaigns/<str:twitch_id>/", views.reward_campaign_detail_view, name="reward_campaign_detail"),
path("search/", views.search_view, name="search"),
path("export/campaigns/csv/", views.export_campaigns_csv, name="export_campaigns_csv"),
path("export/campaigns/json/", views.export_campaigns_json, name="export_campaigns_json"),
path("export/games/csv/", views.export_games_csv, name="export_games_csv"),
path("export/games/json/", views.export_games_json, name="export_games_json"),
path("export/organizations/csv/", views.export_organizations_csv, name="export_organizations_csv"),
path("export/organizations/json/", views.export_organizations_json, name="export_organizations_json"),
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
path("rss/games/", GameFeed(), name="game_feed"),
path("rss/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"),

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import csv
import datetime
import json
import logging
@ -473,7 +474,14 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
Http404: If the campaign is not found.
"""
try:
campaign: DropCampaign = DropCampaign.objects.prefetch_related("game__owners").get(
campaign: DropCampaign = DropCampaign.objects.prefetch_related(
"game__owners",
Prefetch(
"allow_channels",
queryset=Channel.objects.order_by("display_name"),
to_attr="channels_ordered",
),
).get(
twitch_id=twitch_id,
)
except DropCampaign.DoesNotExist as exc:
@ -591,7 +599,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
"drops": enhanced_drops,
"campaign_data": format_and_color_json(campaign_data[0]),
"owners": list(campaign.game.owners.all()),
"allowed_channels": campaign.allow_channels.all().order_by("display_name"),
"allowed_channels": getattr(campaign, "channels_ordered", []),
}
return render(request, "twitch/campaign_detail.html", context)
@ -809,7 +817,6 @@ class GameDetailView(DetailView):
"start_at",
"end_at",
"allow_is_enabled",
"allow_channels",
"game",
"operation_names",
"added_at",
@ -821,12 +828,15 @@ class GameDetailView(DetailView):
)
game_data[0]["fields"]["campaigns"] = campaigns_data
owners: list[Organization] = list(game.owners.all())
context.update(
{
"active_campaigns": active_campaigns,
"upcoming_campaigns": upcoming_campaigns,
"expired_campaigns": expired_campaigns,
"owners": list(game.owners.all()),
"owner": owners[0] if owners else None,
"owners": owners,
"drop_awarded_badges": drop_awarded_badges,
"now": now,
"game_data": format_and_color_json(game_data[0]),
@ -853,7 +863,11 @@ def dashboard(request: HttpRequest) -> HttpResponse:
.select_related("game")
.prefetch_related("game__owners")
.prefetch_related(
Prefetch(
"allow_channels",
queryset=Channel.objects.order_by("display_name"),
to_attr="channels_ordered",
),
)
.order_by("-start_at")
)
@ -874,7 +888,10 @@ def dashboard(request: HttpRequest) -> HttpResponse:
"campaigns": [],
}
campaigns_by_game[game_id]["campaigns"].append(campaign)
campaigns_by_game[game_id]["campaigns"].append({
"campaign": campaign,
"allowed_channels": getattr(campaign, "channels_ordered", []),
})
# Get active reward campaigns (Quest rewards)
active_reward_campaigns: QuerySet[RewardCampaign] = (
@ -1519,3 +1536,286 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse:
}
return render(request, "twitch/badge_set_detail.html", context)
# MARK: Export Views
def export_campaigns_csv(request: HttpRequest) -> HttpResponse:
"""Export drop campaigns to CSV format.
Args:
request: The HTTP request.
Returns:
HttpResponse: CSV file response.
"""
# Get filters from query parameters
game_filter: str | None = request.GET.get("game")
status_filter: str | None = request.GET.get("status")
queryset: QuerySet[DropCampaign] = DropCampaign.objects.all()
if game_filter:
queryset = queryset.filter(game__twitch_id=game_filter)
queryset = queryset.prefetch_related("game__owners").order_by("-start_at")
now: datetime.datetime = timezone.now()
if status_filter == "active":
queryset = queryset.filter(start_at__lte=now, end_at__gte=now)
elif status_filter == "upcoming":
queryset = queryset.filter(start_at__gt=now)
elif status_filter == "expired":
queryset = queryset.filter(end_at__lt=now)
# Create CSV response
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = "attachment; filename=campaigns.csv"
writer = csv.writer(response)
writer.writerow([
"Twitch ID",
"Name",
"Description",
"Game",
"Status",
"Start Date",
"End Date",
"Details URL",
"Created At",
"Updated At",
])
for campaign in queryset:
# Determine campaign status
if campaign.start_at and campaign.end_at:
if campaign.start_at <= now <= campaign.end_at:
status = "Active"
elif campaign.start_at > now:
status = "Upcoming"
else:
status = "Expired"
else:
status = "Unknown"
writer.writerow([
campaign.twitch_id,
campaign.name,
campaign.description[:100] if campaign.description else "", # Truncate for CSV
campaign.game.name if campaign.game else "",
status,
campaign.start_at.isoformat() if campaign.start_at else "",
campaign.end_at.isoformat() if campaign.end_at else "",
campaign.details_url,
campaign.added_at.isoformat() if campaign.added_at else "",
campaign.updated_at.isoformat() if campaign.updated_at else "",
])
return response
def export_campaigns_json(request: HttpRequest) -> HttpResponse:
"""Export drop campaigns to JSON format.
Args:
request: The HTTP request.
Returns:
HttpResponse: JSON file response.
"""
# Get filters from query parameters
game_filter: str | None = request.GET.get("game")
status_filter: str | None = request.GET.get("status")
queryset: QuerySet[DropCampaign] = DropCampaign.objects.all()
if game_filter:
queryset = queryset.filter(game__twitch_id=game_filter)
queryset = queryset.prefetch_related("game__owners").order_by("-start_at")
now = timezone.now()
if status_filter == "active":
queryset = queryset.filter(start_at__lte=now, end_at__gte=now)
elif status_filter == "upcoming":
queryset = queryset.filter(start_at__gt=now)
elif status_filter == "expired":
queryset = queryset.filter(end_at__lt=now)
# Build data list
campaigns_data: list[dict[str, Any]] = []
for campaign in queryset:
# Determine campaign status
if campaign.start_at and campaign.end_at:
if campaign.start_at <= now <= campaign.end_at:
status = "Active"
elif campaign.start_at > now:
status = "Upcoming"
else:
status = "Expired"
else:
status = "Unknown"
campaigns_data.append({
"twitch_id": campaign.twitch_id,
"name": campaign.name,
"description": campaign.description,
"game": campaign.game.name if campaign.game else None,
"game_twitch_id": campaign.game.twitch_id if campaign.game else None,
"status": status,
"start_at": campaign.start_at.isoformat() if campaign.start_at else None,
"end_at": campaign.end_at.isoformat() if campaign.end_at else None,
"details_url": campaign.details_url,
"account_link_url": campaign.account_link_url,
"added_at": campaign.added_at.isoformat() if campaign.added_at else None,
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
})
# Create JSON response
response = HttpResponse(
json.dumps(campaigns_data, indent=2),
content_type="application/json",
)
response["Content-Disposition"] = "attachment; filename=campaigns.json"
return response
def export_games_csv(request: HttpRequest) -> HttpResponse: # noqa: ARG001 # noqa: ARG001
"""Export games to CSV format.
Args:
request: The HTTP request.
Returns:
HttpResponse: CSV file response.
"""
queryset: QuerySet[Game] = Game.objects.all().order_by("display_name")
# Create CSV response
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = "attachment; filename=games.csv"
writer = csv.writer(response)
writer.writerow([
"Twitch ID",
"Name",
"Display Name",
"Slug",
"Box Art URL",
"Added At",
"Updated At",
])
for game in queryset:
writer.writerow([
game.twitch_id,
game.name,
game.display_name,
game.slug,
game.box_art,
game.added_at.isoformat() if game.added_at else "",
game.updated_at.isoformat() if game.updated_at else "",
])
return response
def export_games_json(request: HttpRequest) -> HttpResponse: # noqa: ARG001 # noqa: ARG001
"""Export games to JSON format.
Args:
request: The HTTP request.
Returns:
HttpResponse: JSON file response.
"""
queryset: QuerySet[Game] = Game.objects.all().order_by("display_name")
# Build data list
games_data: list[dict[str, Any]] = [
{
"twitch_id": game.twitch_id,
"name": game.name,
"display_name": game.display_name,
"slug": game.slug,
"box_art_url": game.box_art,
"added_at": game.added_at.isoformat() if game.added_at else None,
"updated_at": game.updated_at.isoformat() if game.updated_at else None,
}
for game in queryset
]
# Create JSON response
response = HttpResponse(
json.dumps(games_data, indent=2),
content_type="application/json",
)
response["Content-Disposition"] = "attachment; filename=games.json"
return response
def export_organizations_csv(request: HttpRequest) -> HttpResponse: # noqa: ARG001
"""Export organizations to CSV format.
Args:
request: The HTTP request.
Returns:
HttpResponse: CSV file response.
"""
queryset: QuerySet[Organization] = Organization.objects.all().order_by("name")
# Create CSV response
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = "attachment; filename=organizations.csv"
writer = csv.writer(response)
writer.writerow([
"Twitch ID",
"Name",
"Added At",
"Updated At",
])
for org in queryset:
writer.writerow([
org.twitch_id,
org.name,
org.added_at.isoformat() if org.added_at else "",
org.updated_at.isoformat() if org.updated_at else "",
])
return response
def export_organizations_json(request: HttpRequest) -> HttpResponse: # noqa: ARG001
"""Export organizations to JSON format.
Args:
request: The HTTP request.
Returns:
HttpResponse: JSON file response.
"""
queryset: QuerySet[Organization] = Organization.objects.all().order_by("name")
# Build data list
orgs_data: list[dict[str, Any]] = [
{
"twitch_id": org.twitch_id,
"name": org.name,
"added_at": org.added_at.isoformat() if org.added_at else None,
"updated_at": org.updated_at.isoformat() if org.updated_at else None,
}
for org in queryset
]
# Create JSON response
response = HttpResponse(
json.dumps(orgs_data, indent=2),
content_type="application/json",
)
response["Content-Disposition"] = "attachment; filename=organizations.json"
return response