Refactor HTML
This commit is contained in:
parent
a12b34a665
commit
05eb0d92e3
27 changed files with 776 additions and 393 deletions
8
.github/copilot-instructions.md
vendored
8
.github/copilot-instructions.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -27,6 +27,7 @@
|
|||
"Facepunch",
|
||||
"Feedly",
|
||||
"filterwarnings",
|
||||
"forloop",
|
||||
"granian",
|
||||
"gunicorn",
|
||||
"Hellsén",
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)' }}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
125
twitch/tests/test_exports.py
Normal file
125
twitch/tests/test_exports.py
Normal 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"
|
||||
|
|
@ -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"}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
310
twitch/views.py
310
twitch/views.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue