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
|
- 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
|
||||||
|
|
|
||||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -27,6 +27,7 @@
|
||||||
"Facepunch",
|
"Facepunch",
|
||||||
"Feedly",
|
"Feedly",
|
||||||
"filterwarnings",
|
"filterwarnings",
|
||||||
|
"forloop",
|
||||||
"granian",
|
"granian",
|
||||||
"gunicorn",
|
"gunicorn",
|
||||||
"Hellsén",
|
"Hellsén",
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)' }}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
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: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"}),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
310
twitch/views.py
310
twitch/views.py
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue