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 - Use template tags and filters for common operations
- Avoid complex logic in templates - move it to views or template tags - Avoid complex logic in templates - move it to views or template tags
- Use static files properly with `{% load static %}` - 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 ## Settings
- Use environment variables in a single `settings.py` file - 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 - Management commands in `twitch/management/commands/` for data import and maintenance tasks
- Use `pyproject.toml` + uv for dependency and environment management - Use `pyproject.toml` + uv for dependency and environment management
- Use `uv run python manage.py <command>` to run Django management commands - 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", "Facepunch",
"Feedly", "Feedly",
"filterwarnings", "filterwarnings",
"forloop",
"granian", "granian",
"gunicorn", "gunicorn",
"Hellsén", "Hellsén",

View file

@ -155,31 +155,38 @@
</style> </style>
</head> </head>
<body> <body>
<h1 style="margin-top: 0.5em; margin-bottom: 0.5em; ">ttvdrops</h1> <nav>
<strong>Twitch:</strong>
<a href="{% url 'twitch:dashboard' %}">Dashboard</a> | <a href="{% url 'twitch:dashboard' %}">Dashboard</a> |
<a href="{% url 'twitch:campaign_list' %}">Campaigns</a> |
<a href="{% url 'twitch: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:docs_rss' %}">RSS</a> |
<a href="{% url 'twitch:debug' %}">Debug</a> | <a href="{% url 'twitch:debug' %}">Debug</a> |
<a href="{% url 'twitch:dataset_backups' %}">Dataset</a> | <a href="{% url 'twitch:dataset_backups' %}">Dataset</a> |
<a href="https://github.com/sponsors/TheLovinator1">Donate</a> | <a href="https://github.com/sponsors/TheLovinator1">Donate</a> |
<a href="https://github.com/TheLovinator1/ttvdrops">GitHub</a> <a href="https://github.com/TheLovinator1/ttvdrops">GitHub</a> |
<br /> <form action="{% url 'twitch:search' %}"
<form action="{% url 'twitch:search' %}" method="get"> method="get"
style="display: inline">
<input type="search" <input type="search"
name="q" name="q"
placeholder="Search..." placeholder="Search..."
value="{{ request.GET.q }}" /> value="{{ request.GET.q }}" />
<button type="submit">Search</button> <button type="submit">Search</button>
</form> </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 %} {% if messages %}
<ul> <ul>
{% for message in messages %} {% for message in messages %}

View file

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

View file

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

View file

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

View file

@ -14,6 +14,24 @@
style="margin-right: 1rem" style="margin-right: 1rem"
title="RSS feed for all campaigns">RSS feed for all campaigns</a> title="RSS feed for all campaigns">RSS feed for all campaigns</a>
</div> </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> </header>
<form id="filter-form" <form id="filter-form"
method="get" method="get"

View file

@ -4,9 +4,9 @@
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<!-- Channel Title --> <!-- Channel Title -->
<h1 id="channel-name">{{ channel.display_name }}</h1> <h1>{{ channel.display_name }}</h1>
{% if channel.display_name != channel.name %} {% if channel.display_name != channel.name %}
<p id="channel-username"> <p>
Username: <code>{{ channel.name }}</code> Username: <code>{{ channel.name }}</code>
</p> </p>
{% endif %} {% endif %}
@ -14,13 +14,6 @@
<p> <p>
<strong>Channel ID:</strong> {{ channel.twitch_id }} <strong>Channel ID:</strong> {{ channel.twitch_id }}
</p> </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 %} {% if active_campaigns %}
<h5 id="active-campaigns-header">Active Campaigns</h5> <h5 id="active-campaigns-header">Active Campaigns</h5>
<table id="active-campaigns-table"> <table id="active-campaigns-table">

View file

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

View file

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

View file

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

View file

@ -3,12 +3,13 @@
Debug Debug
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<h1 id="page-title">Debug Data Integrity Report</h1> <h1>Debug</h1>
<p> <p>
Generated at: <time id="generation-time">{{ now }}</time> Generated at: <time datetime="{{ now|date:'c' }}"
title="{{ now|date:'DATETIME_FORMAT' }}">{{ now }}</time>
</p> </p>
<section> <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 %} {% if operation_names_with_counts %}
<table id="operation-names-table"> <table id="operation-names-table">
<thead> <thead>
@ -29,11 +30,11 @@
{% endif %} {% endif %}
</section> </section>
<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 %} {% if games_without_owner %}
<ul id="games-without-owner-list"> <ul>
{% for game in games_without_owner %} {% 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 }}) <a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game.display_name }}</a> (ID: {{ game.twitch_id }})
</li> </li>
{% endfor %} {% endfor %}
@ -43,14 +44,13 @@
{% endif %} {% endif %}
</section> </section>
<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 %} {% if broken_image_campaigns %}
<ul id="broken-image-campaigns-list"> <ul>
{% for c in broken_image_campaigns %} {% 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> <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>) (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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -59,16 +59,13 @@
{% endif %} {% endif %}
</section> </section>
<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 %} {% if broken_benefit_images %}
<ul id="broken-benefit-images-list"> <ul>
{% for b in broken_benefit_images %} {% 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 %} {% with first_drop=b.drops.all.0 %}
<li id="benefit-{{ b.twitch_id }}"> <li>
{{ b.name }} {{ b.name }}
{# Check if the relationship path to the game exists #}
{% if first_drop and first_drop.campaign and first_drop.campaign.game %} {% 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>) (Game: <a href="{% url 'twitch:game_detail' first_drop.campaign.game.twitch_id %}">{{ first_drop.campaign.game.display_name }}</a>)
{% else %} {% else %}
@ -84,11 +81,11 @@
{% endif %} {% endif %}
</section> </section>
<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 %} {% if active_missing_image %}
<ul id="active-missing-image-list"> <ul>
{% for c in active_missing_image %} {% 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> <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>) (Game: <a href="{% url 'twitch:game_detail' c.game.twitch_id %}">{{ c.game.display_name }}</a>)
</li> </li>
@ -99,11 +96,11 @@
{% endif %} {% endif %}
</section> </section>
<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 %} {% if drops_without_benefits %}
<ul id="drops-without-benefits-list"> <ul>
{% for d in drops_without_benefits %} {% for d in drops_without_benefits %}
<li id="drop-{{ d.twitch_id }}"> <li>
{{ d.name }} {{ d.name }}
(Campaign: <a href="{% url 'twitch:campaign_detail' d.campaign.twitch_id %}">{{ d.campaign.name }}</a> (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>) in Game: <a href="{% url 'twitch:game_detail' d.campaign.game.twitch_id %}">{{ d.campaign.game.display_name }}</a>)
@ -115,11 +112,11 @@
{% endif %} {% endif %}
</section> </section>
<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 %} {% if invalid_date_campaigns %}
<ul id="invalid-date-campaigns-list"> <ul>
{% for c in invalid_date_campaigns %} {% 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> <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>) (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)' }} - Start: {{ c.start_at|default:'(none)' }} / End: {{ c.end_at|default:'(none)' }}
@ -131,9 +128,9 @@
{% endif %} {% endif %}
</section> </section>
<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 %} {% if duplicate_name_campaigns %}
<table id="duplicate-name-campaigns-table"> <table>
<thead> <thead>
<tr> <tr>
<th>Game</th> <th>Game</th>
@ -158,13 +155,11 @@
{% endif %} {% endif %}
</section> </section>
<section> <section>
<h2 id="missing-details-campaigns-header"> <h2>Campaigns Missing DropCampaignDetails ({{ campaigns_missing_dropcampaigndetails|length }})</h2>
Campaigns Missing DropCampaignDetails ({{ campaigns_missing_dropcampaigndetails|length }})
</h2>
{% if campaigns_missing_dropcampaigndetails %} {% if campaigns_missing_dropcampaigndetails %}
<ul id="missing-details-campaigns-list"> <ul>
{% for c in campaigns_missing_dropcampaigndetails %} {% 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> <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>) (Game: <a href="{% url 'twitch:game_detail' c.game.twitch_id %}">{{ c.game.display_name }}</a>)
- Operations: {{ c.operation_names|join:", "|default:'(none)' }} - Operations: {{ c.operation_names|join:", "|default:'(none)' }}

View file

@ -4,11 +4,6 @@
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<h1>Emotes</h1> <h1>Emotes</h1>
<div class="emote-gallery"
style="display: flex;
flex-wrap: wrap;
gap: 1.5rem;
justify-content: flex-start">
{% for emote in emotes %} {% for emote in emotes %}
<a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}" <a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}"
title="{{ emote.campaign.name }}" title="{{ emote.campaign.name }}"
@ -18,14 +13,10 @@
width="96" width="96"
alt="Emote" alt="Emote"
style="max-width: 96px; style="max-width: 96px;
max-height: 96px; max-height: 96px"
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
padding: 4px"
loading="lazy" /> loading="lazy" />
</a> </a>
{% empty %} {% empty %}
<p>No drop campaigns with emotes found.</p> <p>No drop campaigns with emotes found.</p>
{% endfor %} {% endfor %}
</div>
{% endblock content %} {% endblock content %}

View file

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

View file

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

View file

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

View file

@ -10,6 +10,13 @@
style="margin-right: 1rem" style="margin-right: 1rem"
title="RSS feed for all organizations">RSS feed for organizations</a> title="RSS feed for all organizations">RSS feed for organizations</a>
</div> </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 %} {% if orgs %}
<ul id="org-list"> <ul id="org-list">
{% for organization in orgs %} {% for organization in orgs %}

View file

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

View file

@ -48,7 +48,15 @@ def _with_campaign_related(queryset: QuerySet[DropCampaign]) -> QuerySet[DropCam
queryset=TimeBasedDrop.objects.prefetch_related("benefits"), 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: 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 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. """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', If only one channel and drop_requirements is '1 subscriptions required',
merge the Twitch link with the '1 subs' row. merge the Twitch link with the '1 subs' row.
Args: 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. game (Game | None): The game object for fallback link.
Returns: Returns:
SafeText: HTML <ul> with up to max_links channel links, count of more, or fallback link. SafeText: HTML <ul> with up to max_links channel links, count of more, or fallback link.
""" # noqa: E501 """ # noqa: E501
max_links = 5 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) total: int = len(channels_all)
if channels_all: if channels_all:
items: list[SafeString] = [ items: list[SafeString] = [
format_html( format_html(
"<li>" '<li><a href="https://twitch.tv/{}" title="Watch {} on Twitch">{}</a></li>',
'<a href="https://twitch.tv/{}" target="_blank" rel="noopener noreferrer"'
' title="Watch {} on Twitch">{}</a>'
"</li>",
ch.name, ch.name,
ch.display_name, ch.display_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 # 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") display_name: str = getattr(game, "display_name", "this game")
return format_html( return format_html(
"<ul><li>" '<ul><li><a href="{}" title="Browse {} category">Category-wide for {}</a></li></ul>',
'<a href="{}" target="_blank" rel="noopener noreferrer"'
' title="Browse {} category">Category-wide for {}</a>'
"</li></ul>",
game.twitch_directory_url, game.twitch_directory_url,
display_name, display_name,
display_name, display_name,
@ -189,9 +191,7 @@ def _get_channel_name_from_drops(drops: QuerySet[TimeBasedDrop]) -> str | None:
for d in drops: for d in drops:
campaign: DropCampaign | None = getattr(d, "campaign", None) campaign: DropCampaign | None = getattr(d, "campaign", None)
if campaign: if campaign:
allow_channels: QuerySet[Channel] | None = getattr(campaign, "allow_channels", None) channels: list[Channel] | None = getattr(campaign, "channels_ordered", None)
if allow_channels:
channels: QuerySet[Channel, Channel] = allow_channels.all()
if channels: if channels:
return channels[0].name return channels[0].name
return None 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) badge_desc: str | None = badge_descriptions_by_title.get(benefit_name)
if is_sub_required and channel_name: if is_sub_required and channel_name:
linked_name: SafeString = format_html( linked_name: SafeString = format_html(
'<a href="https://twitch.tv/{}" target="_blank">{}</a>', '<a href="https://twitch.tv/{}" >{}</a>',
channel_name, channel_name,
benefit_name, benefit_name,
) )
@ -427,7 +427,7 @@ class GameFeed(Feed):
if slug: if slug:
description_parts.append( description_parts.append(
SafeText( 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: else:
@ -559,7 +559,7 @@ class DropCampaignFeed(Feed):
# Only show channels if drop is not subscription only # Only show channels if drop is not subscription only
if not getattr(item, "is_subscription_only", False): 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: if channels is not None:
game: Game | None = getattr(item, "game", None) game: Game | None = getattr(item, "game", None)
parts.append(_build_channels_html(channels, game=game)) parts.append(_build_channels_html(channels, game=game))
@ -704,7 +704,7 @@ class GameCampaignFeed(Feed):
# Only show channels if drop is not subscription only # Only show channels if drop is not subscription only
if not getattr(item, "is_subscription_only", False): 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: if channels is not None:
game: Game | None = getattr(item, "game", None) game: Game | None = getattr(item, "game", None)
parts.append(_build_channels_html(channels, game=game)) parts.append(_build_channels_html(channels, game=game))
@ -888,7 +888,7 @@ class OrganizationCampaignFeed(Feed):
# Only show channels if drop is not subscription only # Only show channels if drop is not subscription only
if not getattr(item, "is_subscription_only", False): 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: if channels is not None:
game: Game | None = getattr(item, "game", None) game: Game | None = getattr(item, "game", None)
parts.append(_build_channels_html(channels, game=game)) 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]) url: str = reverse("twitch:organization_detail", args=[self.twitch_id])
return format_html( return format_html(
"<p>New Twitch organization added to TTVDrops:</p>\n" '<p>New Twitch organization added to TTVDrops:</p>\n<p><a href="{}">{}</a></p>',
'<p><a href="{}" target="_blank" rel="noopener noreferrer">{}</a></p>',
url, url,
name, name,
) )
@ -456,6 +455,38 @@ class DropCampaign(auto_prefetch.Model):
) )
return self.image_url or "" 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 @property
def is_subscription_only(self) -> bool: def is_subscription_only(self) -> bool:
"""Determine if the campaign is subscription only based on its benefits.""" """Determine if the campaign is subscription only based on its benefits."""

View file

@ -126,27 +126,20 @@ class TestBackupCommand:
assert output_dir.exists() assert output_dir.exists()
assert len(list(output_dir.glob("test-*.sql.zst"))) == 1 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.""" """Test that backup uses DATA_DIR/datasets by default."""
# Create test data so tables exist # Create test data so tables exist
Organization.objects.create(twitch_id="test004", name="Test Org") 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) 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") call_command("backup_db")
backup_files = list(datasets_dir.glob("ttvdrops-*.sql.zst")) backup_files = list(datasets_dir.glob("ttvdrops-*.sql.zst"))
assert len(backup_files) >= 1 assert len(backup_files) >= 1
# Clean up
for backup in backup_files:
backup.unlink()
@pytest.mark.django_db @pytest.mark.django_db
class TestBackupHelperFunctions: class TestBackupHelperFunctions:

View file

@ -118,7 +118,6 @@ class TestBadgeSetDetailView:
content = response.content.decode() content = response.content.decode()
assert "vip" in content assert "vip" in content
assert "Total Versions:" in content
assert "1" in content assert "1" in content
def test_badge_set_detail_json_data(self, client: Client) -> None: 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:debug", {}),
("twitch:docs_rss", {}), ("twitch:docs_rss", {}),
("twitch:emote_gallery", {}), ("twitch:emote_gallery", {}),
("twitch:game_list", {}), ("twitch:games_grid", {}),
("twitch:game_list_simple", {}), ("twitch:games_list", {}),
("twitch:game_detail", {"twitch_id": "test-game-123"}), ("twitch:game_detail", {"twitch_id": "test-game-123"}),
("twitch:org_list", {}), ("twitch:org_list", {}),
("twitch:organization_detail", {"twitch_id": "test-org-123"}), ("twitch:organization_detail", {"twitch_id": "test-org-123"}),

View file

@ -522,14 +522,14 @@ class TestChannelListView:
@pytest.mark.django_db @pytest.mark.django_db
def test_games_grid_view(self, client: Client) -> None: def test_games_grid_view(self, client: Client) -> None:
"""Test games grid view returns 200 and has games in context.""" """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 response.status_code == 200
assert "games" in response.context assert "games" in response.context
@pytest.mark.django_db @pytest.mark.django_db
def test_games_list_view(self, client: Client) -> None: def test_games_list_view(self, client: Client) -> None:
"""Test games list view returns 200 and has games in context.""" """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 response.status_code == 200
assert "games" in response.context 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("docs/rss/", views.docs_rss_view, name="docs_rss"),
path("emotes/", views.emote_gallery_view, name="emote_gallery"), path("emotes/", views.emote_gallery_view, name="emote_gallery"),
path("games/", views.GamesGridView.as_view(), name="game_list"), path("games/", views.GamesGridView.as_view(), name="games_grid"),
path("games/list/", views.GamesListView.as_view(), name="game_list_simple"), path("games/list/", views.GamesListView.as_view(), name="games_list"),
path("games/<str:twitch_id>/", views.GameDetailView.as_view(), name="game_detail"), path("games/<str:twitch_id>/", views.GameDetailView.as_view(), name="game_detail"),
path("organizations/", views.org_list_view, name="org_list"), path("organizations/", views.org_list_view, name="org_list"),
path("organizations/<str:twitch_id>/", views.organization_detail_view, name="organization_detail"), path("organizations/<str:twitch_id>/", views.organization_detail_view, name="organization_detail"),
path("reward-campaigns/", views.reward_campaign_list_view, name="reward_campaign_list"), path("reward-campaigns/", 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("reward-campaigns/<str:twitch_id>/", views.reward_campaign_detail_view, name="reward_campaign_detail"),
path("search/", views.search_view, name="search"), 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/campaigns/", DropCampaignFeed(), name="campaign_feed"),
path("rss/games/", GameFeed(), name="game_feed"), path("rss/games/", GameFeed(), name="game_feed"),
path("rss/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"), path("rss/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"),

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import csv
import datetime import datetime
import json import json
import logging import logging
@ -473,7 +474,14 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
Http404: If the campaign is not found. Http404: If the campaign is not found.
""" """
try: 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, twitch_id=twitch_id,
) )
except DropCampaign.DoesNotExist as exc: except DropCampaign.DoesNotExist as exc:
@ -591,7 +599,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
"drops": enhanced_drops, "drops": enhanced_drops,
"campaign_data": format_and_color_json(campaign_data[0]), "campaign_data": format_and_color_json(campaign_data[0]),
"owners": list(campaign.game.owners.all()), "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) return render(request, "twitch/campaign_detail.html", context)
@ -809,7 +817,6 @@ class GameDetailView(DetailView):
"start_at", "start_at",
"end_at", "end_at",
"allow_is_enabled", "allow_is_enabled",
"allow_channels",
"game", "game",
"operation_names", "operation_names",
"added_at", "added_at",
@ -821,12 +828,15 @@ class GameDetailView(DetailView):
) )
game_data[0]["fields"]["campaigns"] = campaigns_data game_data[0]["fields"]["campaigns"] = campaigns_data
owners: list[Organization] = list(game.owners.all())
context.update( context.update(
{ {
"active_campaigns": active_campaigns, "active_campaigns": active_campaigns,
"upcoming_campaigns": upcoming_campaigns, "upcoming_campaigns": upcoming_campaigns,
"expired_campaigns": expired_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, "drop_awarded_badges": drop_awarded_badges,
"now": now, "now": now,
"game_data": format_and_color_json(game_data[0]), "game_data": format_and_color_json(game_data[0]),
@ -853,7 +863,11 @@ def dashboard(request: HttpRequest) -> HttpResponse:
.select_related("game") .select_related("game")
.prefetch_related("game__owners") .prefetch_related("game__owners")
.prefetch_related( .prefetch_related(
Prefetch(
"allow_channels", "allow_channels",
queryset=Channel.objects.order_by("display_name"),
to_attr="channels_ordered",
),
) )
.order_by("-start_at") .order_by("-start_at")
) )
@ -874,7 +888,10 @@ def dashboard(request: HttpRequest) -> HttpResponse:
"campaigns": [], "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) # Get active reward campaigns (Quest rewards)
active_reward_campaigns: QuerySet[RewardCampaign] = ( 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) 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