Archive Twitch chat badges
This commit is contained in:
parent
443bd88cb8
commit
6842581656
14 changed files with 1394 additions and 1 deletions
|
|
@ -6,6 +6,12 @@ DEBUG=True
|
||||||
# Generate a new secret key for production: python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
|
# Generate a new secret key for production: python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
|
||||||
DJANGO_SECRET_KEY=your-secret-key-here
|
DJANGO_SECRET_KEY=your-secret-key-here
|
||||||
|
|
||||||
|
# Twitch API Configuration
|
||||||
|
# Get your Client ID and Client Secret from https://dev.twitch.tv/console
|
||||||
|
# You can use either an app access token or user access token
|
||||||
|
TWITCH_CLIENT_ID=your-twitch-client-id
|
||||||
|
TWITCH_CLIENT_SECRET=your-twitch-client-secret
|
||||||
|
|
||||||
# Email Configuration
|
# Email Configuration
|
||||||
# SMTP Host (examples below)
|
# SMTP Host (examples below)
|
||||||
EMAIL_HOST=smtp.gmail.com
|
EMAIL_HOST=smtp.gmail.com
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -18,3 +18,13 @@ uv run pytest
|
||||||
```bash
|
```bash
|
||||||
uv run python manage.py better_import_drops <file|dir> [--recursive] [--verbose] [--crash-on-error] [--skip-broken-moves]
|
uv run python manage.py better_import_drops <file|dir> [--recursive] [--verbose] [--crash-on-error] [--skip-broken-moves]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Import Chat Badges
|
||||||
|
|
||||||
|
Import Twitch's global chat badges for archival and reference:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python manage.py import_chat_badges
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET` environment variables to be set.
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,7 @@
|
||||||
<a href="{% url 'twitch:game_list' %}">Games</a> |
|
<a href="{% url 'twitch:game_list' %}">Games</a> |
|
||||||
<a href="{% url 'twitch:org_list' %}">Orgs</a> |
|
<a href="{% url 'twitch:org_list' %}">Orgs</a> |
|
||||||
<a href="{% url 'twitch:channel_list' %}">Channels</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>
|
<a href="{% url 'twitch:emote_gallery' %}">Emotes</a>
|
||||||
<br />
|
<br />
|
||||||
<a href="{% url 'twitch:docs_rss' %}">RSS</a> | <a href="{% url 'twitch:debug' %}">Debug</a>
|
<a href="{% url 'twitch:docs_rss' %}">RSS</a> | <a href="{% url 'twitch:debug' %}">Debug</a>
|
||||||
|
|
|
||||||
48
templates/twitch/badge_list.html
Normal file
48
templates/twitch/badge_list.html
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}
|
||||||
|
Chat Badges - ttvdrops
|
||||||
|
{% endblock title %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Twitch Chat Badges</h1>
|
||||||
|
<pre>
|
||||||
|
These are the global chat badges available on Twitch.
|
||||||
|
</pre>
|
||||||
|
{% if badge_sets %}
|
||||||
|
<p>total badge sets: {{ badge_sets.count }}</p>
|
||||||
|
{% for data in badge_data %}
|
||||||
|
<hr />
|
||||||
|
<h2>
|
||||||
|
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">[{{ data.set.set_id }}]</a>
|
||||||
|
</h2>
|
||||||
|
{% for badge in data.badges %}
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td style="width: 40px;">
|
||||||
|
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">
|
||||||
|
<img src="{{ badge.image_url_4x }}"
|
||||||
|
height="36"
|
||||||
|
width="36"
|
||||||
|
alt="{{ badge.title }}"
|
||||||
|
title="{{ badge.description }}" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ badge.title }}</strong>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
{{ badge.description }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endfor %}
|
||||||
|
<br />
|
||||||
|
{% if data.badges|length > 1 %}<small>versions: {{ data.badges|length }}</small>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<hr />
|
||||||
|
{% else %}
|
||||||
|
<p>no badge sets found.</p>
|
||||||
|
<p>
|
||||||
|
run: <code>uv run python manage.py import_chat_badges</code>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
79
templates/twitch/badge_set_detail.html
Normal file
79
templates/twitch/badge_set_detail.html
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}
|
||||||
|
{{ badge_set.set_id }} Badges - ttvdrops
|
||||||
|
{% endblock title %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>
|
||||||
|
Badge Set: <strong>{{ badge_set.set_id }}</strong>
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
<a href="{% url 'twitch:badge_list' %}">Back to all badges</a>
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Set ID:</strong> {{ badge_set.set_id }}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Total Versions:</strong> {{ badges.count }}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Added:</strong> {{ badge_set.added_at|date:"Y-m-d H:i:s T" }}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Updated:</strong> {{ badge_set.updated_at|date:"Y-m-d H:i:s T" }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% if badges %}
|
||||||
|
<h2>Badge Versions ({{ badges.count }})</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Preview</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Images</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for badge in badges %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>{{ badge.badge_id }}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<img src="{{ badge.image_url_4x }}"
|
||||||
|
height="72"
|
||||||
|
width="72"
|
||||||
|
alt="{{ badge.title }}"
|
||||||
|
title="{{ badge.description }}"
|
||||||
|
style="width: 72px !important;
|
||||||
|
height: 72px !important;
|
||||||
|
object-fit: contain" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ badge.title }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>{{ badge.description }}</td>
|
||||||
|
<td style="font-size: 0.85em">
|
||||||
|
<a href="{{ badge.image_url_1x }}" target="_blank">18px</a> |
|
||||||
|
<a href="{{ badge.image_url_2x }}" target="_blank">36px</a> |
|
||||||
|
<a href="{{ badge.image_url_4x }}" target="_blank">72px</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if badge.click_url %}
|
||||||
|
<a href="{{ badge.click_url }}" target="_blank" rel="noopener">{{ badge.click_action|default:"visit_url" }}</a>
|
||||||
|
{% else %}
|
||||||
|
<em>None</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No badges found in this set.</p>
|
||||||
|
{% endif %}
|
||||||
|
{{ set_data|safe }}
|
||||||
|
{% endblock content %}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container" id="search-results-container">
|
<div class="container" id="search-results-container">
|
||||||
<h1 id="page-title">Search Results for "{{ query }}"</h1>
|
<h1 id="page-title">Search Results for "{{ query }}"</h1>
|
||||||
{% if not results.organizations and not results.games and not results.campaigns and not results.drops and not results.benefits and not results.reward_campaigns %}
|
{% if not results.organizations and not results.games and not results.campaigns and not results.drops and not results.benefits and not results.reward_campaigns and not results.badge_sets and not results.badges %}
|
||||||
<p id="no-results">No results found.</p>
|
<p id="no-results">No results found.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if results.organizations %}
|
{% if results.organizations %}
|
||||||
|
|
@ -82,6 +82,27 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if results.badge_sets %}
|
||||||
|
<h2 id="badge-sets-header">Badge Sets</h2>
|
||||||
|
<ul id="badge-sets-list">
|
||||||
|
{% for badge_set in results.badge_sets %}
|
||||||
|
<li id="badge-set-{{ badge_set.set_id }}">
|
||||||
|
<a href="{% url 'twitch:badge_set_detail' set_id=badge_set.set_id %}">{{ badge_set.set_id }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% if results.badges %}
|
||||||
|
<h2 id="badges-header">Chat Badges</h2>
|
||||||
|
<ul id="badges-list">
|
||||||
|
{% for badge in results.badges %}
|
||||||
|
<li id="badge-{{ badge.badge_set.set_id }}-{{ badge.badge_id }}">
|
||||||
|
<a href="{% url 'twitch:badge_set_detail' set_id=badge.badge_set.set_id %}">{{ badge.title }}</a>
|
||||||
|
<small>({{ badge.badge_set.set_id }}/{{ badge.badge_id }})</small>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
||||||
273
twitch/management/commands/import_chat_badges.py
Normal file
273
twitch/management/commands/import_chat_badges.py
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
"""Management command to import Twitch global chat badges."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from colorama import Fore
|
||||||
|
from colorama import Style
|
||||||
|
from colorama import init as colorama_init
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
from django.core.management.base import CommandParser
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from twitch.models import ChatBadge
|
||||||
|
from twitch.models import ChatBadgeSet
|
||||||
|
from twitch.schemas import ChatBadgeSetSchema
|
||||||
|
from twitch.schemas import GlobalChatBadgesResponse
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from twitch.schemas import ChatBadgeVersionSchema
|
||||||
|
|
||||||
|
logger: logging.Logger = logging.getLogger("ttvdrops")
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Import Twitch global chat badges from the Twitch Helix API."""
|
||||||
|
|
||||||
|
help = "Import Twitch global chat badges from the Twitch Helix API"
|
||||||
|
requires_migrations_checks = True
|
||||||
|
|
||||||
|
def add_arguments(self, parser: CommandParser) -> None:
|
||||||
|
"""Add command arguments."""
|
||||||
|
parser.add_argument(
|
||||||
|
"--client-id",
|
||||||
|
type=str,
|
||||||
|
help="Twitch Client ID (or set TWITCH_CLIENT_ID environment variable)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--client-secret",
|
||||||
|
type=str,
|
||||||
|
help="Twitch Client Secret (or set TWITCH_CLIENT_SECRET environment variable)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--access-token",
|
||||||
|
type=str,
|
||||||
|
help="Twitch Access Token (optional - will be obtained automatically if not provided)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options) -> None: # noqa: ARG002
|
||||||
|
"""Main entry point for the command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CommandError: If required arguments are missing or API calls fail
|
||||||
|
"""
|
||||||
|
colorama_init(autoreset=True)
|
||||||
|
|
||||||
|
# Get credentials from arguments or environment
|
||||||
|
client_id: str | None = options.get("client_id") or os.getenv("TWITCH_CLIENT_ID")
|
||||||
|
client_secret: str | None = options.get("client_secret") or os.getenv("TWITCH_CLIENT_SECRET")
|
||||||
|
access_token: str | None = options.get("access_token") or os.getenv("TWITCH_ACCESS_TOKEN")
|
||||||
|
|
||||||
|
if not client_id:
|
||||||
|
msg = (
|
||||||
|
"Twitch Client ID is required. "
|
||||||
|
"Provide it via --client-id argument or set TWITCH_CLIENT_ID environment variable."
|
||||||
|
)
|
||||||
|
raise CommandError(msg)
|
||||||
|
|
||||||
|
# If access token is not provided, obtain it automatically using client credentials
|
||||||
|
if not access_token:
|
||||||
|
if not client_secret:
|
||||||
|
msg = (
|
||||||
|
"Either --access-token or --client-secret must be provided. "
|
||||||
|
"Set TWITCH_ACCESS_TOKEN or TWITCH_CLIENT_SECRET environment variable, "
|
||||||
|
"or provide them via command arguments."
|
||||||
|
)
|
||||||
|
raise CommandError(msg)
|
||||||
|
|
||||||
|
self.stdout.write("Obtaining access token from Twitch...")
|
||||||
|
try:
|
||||||
|
access_token = self._get_app_access_token(client_id, client_secret)
|
||||||
|
self.stdout.write(self.style.SUCCESS("✓ Access token obtained successfully"))
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
msg = f"Failed to obtain access token: {e}"
|
||||||
|
raise CommandError(msg) from e
|
||||||
|
|
||||||
|
self.stdout.write("Fetching global chat badges from Twitch API...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
badges_data: GlobalChatBadgesResponse = self._fetch_global_chat_badges(
|
||||||
|
client_id=client_id,
|
||||||
|
access_token=access_token,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
msg: str = f"Failed to fetch chat badges from Twitch API: {e}"
|
||||||
|
raise CommandError(msg) from e
|
||||||
|
except ValidationError as e:
|
||||||
|
msg: str = f"Failed to validate chat badges response: {e}"
|
||||||
|
raise CommandError(msg) from e
|
||||||
|
|
||||||
|
self.stdout.write(f"Received {len(badges_data.data)} badge sets")
|
||||||
|
|
||||||
|
# Process and store badge data
|
||||||
|
created_sets = 0
|
||||||
|
updated_sets = 0
|
||||||
|
created_badges = 0
|
||||||
|
updated_badges = 0
|
||||||
|
|
||||||
|
for badge_set_schema in badges_data.data:
|
||||||
|
badge_set_obj, set_created = self._process_badge_set(badge_set_schema)
|
||||||
|
|
||||||
|
if set_created:
|
||||||
|
created_sets += 1
|
||||||
|
else:
|
||||||
|
updated_sets += 1
|
||||||
|
|
||||||
|
# Process each badge version in the set
|
||||||
|
for version_schema in badge_set_schema.versions:
|
||||||
|
badge_created: bool = self._process_badge_version(
|
||||||
|
badge_set_obj=badge_set_obj,
|
||||||
|
version_schema=version_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
if badge_created:
|
||||||
|
created_badges += 1
|
||||||
|
else:
|
||||||
|
updated_badges += 1
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
self.stdout.write("\n" + "=" * 50)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"✓ Created {created_sets} new badge sets, updated {updated_sets} existing",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"✓ Created {created_badges} new badges, updated {updated_badges} existing",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.stdout.write(f"Total badge sets: {created_sets + updated_sets}")
|
||||||
|
self.stdout.write(f"Total badges: {created_badges + updated_badges}")
|
||||||
|
self.stdout.write("=" * 50)
|
||||||
|
|
||||||
|
def _fetch_global_chat_badges(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
access_token: str,
|
||||||
|
) -> GlobalChatBadgesResponse:
|
||||||
|
"""Fetch global chat badges from Twitch Helix API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_id: Twitch Client ID
|
||||||
|
access_token: Twitch Access Token (app or user token)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated GlobalChatBadgesResponse
|
||||||
|
"""
|
||||||
|
url = "https://api.twitch.tv/helix/chat/badges/global"
|
||||||
|
headers: dict[str, str] = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Client-Id": client_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response: httpx.Response = client.get(url, headers=headers, timeout=30.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data: dict[str, Any] = response.json()
|
||||||
|
logger.debug("Received chat badges response: %s", data)
|
||||||
|
|
||||||
|
# Validate response with Pydantic
|
||||||
|
return GlobalChatBadgesResponse.model_validate(data)
|
||||||
|
|
||||||
|
def _process_badge_set(
|
||||||
|
self,
|
||||||
|
badge_set_schema: ChatBadgeSetSchema,
|
||||||
|
) -> tuple[ChatBadgeSet, bool]:
|
||||||
|
"""Get or create a ChatBadgeSet from the schema.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
badge_set_schema: Validated badge set schema
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (ChatBadgeSet instance, created flag)
|
||||||
|
"""
|
||||||
|
badge_set_obj, created = ChatBadgeSet.objects.get_or_create(
|
||||||
|
set_id=badge_set_schema.set_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} Created new badge set: {badge_set_schema.set_id}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
f"{Fore.YELLOW}→{Style.RESET_ALL} Badge set already exists: {badge_set_schema.set_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return badge_set_obj, created
|
||||||
|
|
||||||
|
def _get_app_access_token(self, client_id: str, client_secret: str) -> str:
|
||||||
|
"""Get an app access token from Twitch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_id: Twitch Client ID
|
||||||
|
client_secret: Twitch Client Secret
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Access token string
|
||||||
|
"""
|
||||||
|
url = "https://id.twitch.tv/oauth2/token"
|
||||||
|
params: dict[str, str] = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
}
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response: httpx.Response = client.post(url, params=params, timeout=30.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
data: dict[str, str] = response.json()
|
||||||
|
return data["access_token"]
|
||||||
|
|
||||||
|
def _process_badge_version(
|
||||||
|
self,
|
||||||
|
badge_set_obj: ChatBadgeSet,
|
||||||
|
version_schema: ChatBadgeVersionSchema,
|
||||||
|
) -> bool:
|
||||||
|
"""Get or create a ChatBadge from the schema.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
badge_set_obj: Parent ChatBadgeSet instance
|
||||||
|
version_schema: Validated badge version schema
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if created, False if updated
|
||||||
|
"""
|
||||||
|
defaults: dict[str, str | None] = {
|
||||||
|
"image_url_1x": version_schema.image_url_1x,
|
||||||
|
"image_url_2x": version_schema.image_url_2x,
|
||||||
|
"image_url_4x": version_schema.image_url_4x,
|
||||||
|
"title": version_schema.title,
|
||||||
|
"description": version_schema.description,
|
||||||
|
"click_action": version_schema.click_action,
|
||||||
|
"click_url": version_schema.click_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
_badge_obj, created = ChatBadge.objects.update_or_create(
|
||||||
|
badge_set=badge_set_obj,
|
||||||
|
badge_id=version_schema.badge_id,
|
||||||
|
defaults=defaults,
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
msg: str = (
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} Created badge: "
|
||||||
|
f"{badge_set_obj.set_id}/{version_schema.badge_id} - {version_schema.title}"
|
||||||
|
)
|
||||||
|
self.stdout.write(msg)
|
||||||
|
else:
|
||||||
|
msg: str = (
|
||||||
|
f"{Fore.YELLOW}→{Style.RESET_ALL} Updated badge: "
|
||||||
|
f"{badge_set_obj.set_id}/{version_schema.badge_id} - {version_schema.title}"
|
||||||
|
)
|
||||||
|
self.stdout.write(msg)
|
||||||
|
|
||||||
|
return created
|
||||||
153
twitch/migrations/0006_add_chat_badges.py
Normal file
153
twitch/migrations/0006_add_chat_badges.py
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
# Generated by Django 6.0.1 on 2026-01-15 21:57
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""Add ChatBadgeSet and ChatBadge models for Twitch chat badges."""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("twitch", "0005_add_reward_campaign"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ChatBadgeSet",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
(
|
||||||
|
"set_id",
|
||||||
|
models.TextField(
|
||||||
|
help_text="Identifier for this badge set (e.g., 'vip', 'subscriber', 'bits').",
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Set ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"added_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="Timestamp when this badge set record was created.",
|
||||||
|
verbose_name="Added At",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="Timestamp when this badge set record was last updated.",
|
||||||
|
verbose_name="Updated At",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["set_id"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["set_id"], name="twitch_chat_set_id_9319f2_idx"),
|
||||||
|
models.Index(fields=["added_at"], name="twitch_chat_added_a_b0023a_idx"),
|
||||||
|
models.Index(fields=["updated_at"], name="twitch_chat_updated_90afed_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ChatBadge",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
(
|
||||||
|
"badge_id",
|
||||||
|
models.TextField(
|
||||||
|
help_text="Version identifier for this badge (e.g., '1', 'Alliance', '10000').",
|
||||||
|
verbose_name="Badge ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image_url_1x",
|
||||||
|
models.URLField(
|
||||||
|
help_text="URL to the small version (18px x 18px) of the badge.",
|
||||||
|
max_length=500,
|
||||||
|
verbose_name="Image URL (18px)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image_url_2x",
|
||||||
|
models.URLField(
|
||||||
|
help_text="URL to the medium version (36px x 36px) of the badge.",
|
||||||
|
max_length=500,
|
||||||
|
verbose_name="Image URL (36px)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image_url_4x",
|
||||||
|
models.URLField(
|
||||||
|
help_text="URL to the large version (72px x 72px) of the badge.",
|
||||||
|
max_length=500,
|
||||||
|
verbose_name="Image URL (72px)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.TextField(help_text="The title of the badge (e.g., 'VIP').", verbose_name="Title")),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(help_text="The description of the badge.", verbose_name="Description"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"click_action",
|
||||||
|
models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The action to take when clicking on the badge (e.g., 'visit_url').",
|
||||||
|
null=True,
|
||||||
|
verbose_name="Click Action",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"click_url",
|
||||||
|
models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The URL to navigate to when clicking on the badge.",
|
||||||
|
max_length=500,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Click URL",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"added_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="Timestamp when this badge record was created.",
|
||||||
|
verbose_name="Added At",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="Timestamp when this badge record was last updated.",
|
||||||
|
verbose_name="Updated At",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"badge_set",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="The badge set this badge belongs to.",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="badges",
|
||||||
|
to="twitch.chatbadgeset",
|
||||||
|
verbose_name="Badge Set",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["badge_set", "badge_id"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["badge_set"], name="twitch_chat_badge_s_54f225_idx"),
|
||||||
|
models.Index(fields=["badge_id"], name="twitch_chat_badge_i_58a68a_idx"),
|
||||||
|
models.Index(fields=["title"], name="twitch_chat_title_0f42d2_idx"),
|
||||||
|
models.Index(fields=["added_at"], name="twitch_chat_added_a_9ba7dd_idx"),
|
||||||
|
models.Index(fields=["updated_at"], name="twitch_chat_updated_568ad1_idx"),
|
||||||
|
],
|
||||||
|
"constraints": [models.UniqueConstraint(fields=("badge_set", "badge_id"), name="unique_badge_set_id")],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
122
twitch/models.py
122
twitch/models.py
|
|
@ -764,3 +764,125 @@ class RewardCampaign(models.Model):
|
||||||
if self.starts_at is None or self.ends_at is None:
|
if self.starts_at is None or self.ends_at is None:
|
||||||
return False
|
return False
|
||||||
return self.starts_at <= now <= self.ends_at
|
return self.starts_at <= now <= self.ends_at
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: ChatBadgeSet
|
||||||
|
class ChatBadgeSet(models.Model):
|
||||||
|
"""Represents a set of Twitch global chat badges (e.g., VIP, Subscriber, Bits)."""
|
||||||
|
|
||||||
|
set_id = models.TextField(
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Set ID",
|
||||||
|
help_text="Identifier for this badge set (e.g., 'vip', 'subscriber', 'bits').",
|
||||||
|
)
|
||||||
|
|
||||||
|
added_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Added At",
|
||||||
|
editable=False,
|
||||||
|
help_text="Timestamp when this badge set record was created.",
|
||||||
|
)
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name="Updated At",
|
||||||
|
editable=False,
|
||||||
|
help_text="Timestamp when this badge set record was last updated.",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["set_id"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["set_id"]),
|
||||||
|
models.Index(fields=["added_at"]),
|
||||||
|
models.Index(fields=["updated_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Return a string representation of the badge set."""
|
||||||
|
return self.set_id
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: ChatBadge
|
||||||
|
class ChatBadge(models.Model):
|
||||||
|
"""Represents a specific version of a Twitch global chat badge."""
|
||||||
|
|
||||||
|
badge_set = models.ForeignKey(
|
||||||
|
ChatBadgeSet,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="badges",
|
||||||
|
verbose_name="Badge Set",
|
||||||
|
help_text="The badge set this badge belongs to.",
|
||||||
|
)
|
||||||
|
badge_id = models.TextField(
|
||||||
|
verbose_name="Badge ID",
|
||||||
|
help_text="Version identifier for this badge (e.g., '1', 'Alliance', '10000').",
|
||||||
|
)
|
||||||
|
image_url_1x = models.URLField(
|
||||||
|
max_length=500,
|
||||||
|
verbose_name="Image URL (18px)",
|
||||||
|
help_text="URL to the small version (18px x 18px) of the badge.",
|
||||||
|
)
|
||||||
|
image_url_2x = models.URLField(
|
||||||
|
max_length=500,
|
||||||
|
verbose_name="Image URL (36px)",
|
||||||
|
help_text="URL to the medium version (36px x 36px) of the badge.",
|
||||||
|
)
|
||||||
|
image_url_4x = models.URLField(
|
||||||
|
max_length=500,
|
||||||
|
verbose_name="Image URL (72px)",
|
||||||
|
help_text="URL to the large version (72px x 72px) of the badge.",
|
||||||
|
)
|
||||||
|
title = models.TextField(
|
||||||
|
verbose_name="Title",
|
||||||
|
help_text="The title of the badge (e.g., 'VIP').",
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
verbose_name="Description",
|
||||||
|
help_text="The description of the badge.",
|
||||||
|
)
|
||||||
|
click_action = models.TextField( # noqa: DJ001
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Click Action",
|
||||||
|
help_text="The action to take when clicking on the badge (e.g., 'visit_url').",
|
||||||
|
)
|
||||||
|
click_url = models.URLField( # noqa: DJ001
|
||||||
|
max_length=500,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Click URL",
|
||||||
|
help_text="The URL to navigate to when clicking on the badge.",
|
||||||
|
)
|
||||||
|
|
||||||
|
added_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Added At",
|
||||||
|
editable=False,
|
||||||
|
help_text="Timestamp when this badge record was created.",
|
||||||
|
)
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name="Updated At",
|
||||||
|
editable=False,
|
||||||
|
help_text="Timestamp when this badge record was last updated.",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["badge_set", "badge_id"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["badge_set", "badge_id"],
|
||||||
|
name="unique_badge_set_id",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["badge_set"]),
|
||||||
|
models.Index(fields=["badge_id"]),
|
||||||
|
models.Index(fields=["title"]),
|
||||||
|
models.Index(fields=["added_at"]),
|
||||||
|
models.Index(fields=["updated_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Return a string representation of the badge."""
|
||||||
|
return f"{self.badge_set.set_id}/{self.badge_id}: {self.title}"
|
||||||
|
|
|
||||||
|
|
@ -548,3 +548,51 @@ class BatchedGraphQLResponse(BaseModel):
|
||||||
"strict": True,
|
"strict": True,
|
||||||
"populate_by_name": True,
|
"populate_by_name": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: ChatBadge Schemas
|
||||||
|
class ChatBadgeVersionSchema(BaseModel):
|
||||||
|
"""Schema for a single chat badge version."""
|
||||||
|
|
||||||
|
badge_id: str = Field(alias="id")
|
||||||
|
image_url_1x: str = Field(alias="image_url_1x")
|
||||||
|
image_url_2x: str = Field(alias="image_url_2x")
|
||||||
|
image_url_4x: str = Field(alias="image_url_4x")
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
click_action: str | None = Field(default=None, alias="click_action")
|
||||||
|
click_url: str | None = Field(default=None, alias="click_url")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"strict": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ChatBadgeSetSchema(BaseModel):
|
||||||
|
"""Schema for a chat badge set containing multiple versions."""
|
||||||
|
|
||||||
|
set_id: str = Field(alias="set_id")
|
||||||
|
versions: list[ChatBadgeVersionSchema]
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"strict": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalChatBadgesResponse(BaseModel):
|
||||||
|
"""Schema for the Twitch global chat badges API response."""
|
||||||
|
|
||||||
|
data: list[ChatBadgeSetSchema]
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"extra": "forbid",
|
||||||
|
"validate_assignment": True,
|
||||||
|
"strict": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
}
|
||||||
|
|
|
||||||
199
twitch/tests/test_badge_views.py
Normal file
199
twitch/tests/test_badge_views.py
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
"""Tests for chat badge views."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from twitch.models import ChatBadge
|
||||||
|
from twitch.models import ChatBadgeSet
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBadgeListView:
|
||||||
|
"""Tests for the badge list view."""
|
||||||
|
|
||||||
|
def test_badge_list_empty(self, client: Client) -> None:
|
||||||
|
"""Test badge list view with no badges."""
|
||||||
|
response = client.get(reverse("twitch:badge_list"))
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "No badge sets found" in response.content.decode()
|
||||||
|
|
||||||
|
def test_badge_list_displays_sets(self, client: Client) -> None:
|
||||||
|
"""Test that badge sets are displayed."""
|
||||||
|
badge_set1 = ChatBadgeSet.objects.create(set_id="vip")
|
||||||
|
badge_set2 = ChatBadgeSet.objects.create(set_id="subscriber")
|
||||||
|
|
||||||
|
response = client.get(reverse("twitch:badge_list"))
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert badge_set1.set_id in content
|
||||||
|
assert badge_set2.set_id in content
|
||||||
|
|
||||||
|
def test_badge_list_displays_badge_count(self, client: Client) -> None:
|
||||||
|
"""Test that badge version count is displayed."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="bits")
|
||||||
|
ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="Bits 1",
|
||||||
|
description="1 Bit",
|
||||||
|
)
|
||||||
|
ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="100",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="Bits 100",
|
||||||
|
description="100 Bits",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(reverse("twitch:badge_list"))
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
# Should show version count (the template uses "versions" not "version")
|
||||||
|
assert "2" in content
|
||||||
|
assert "versions" in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBadgeSetDetailView:
|
||||||
|
"""Tests for the badge set detail view."""
|
||||||
|
|
||||||
|
def test_badge_set_detail_not_found(self, client: Client) -> None:
|
||||||
|
"""Test 404 when badge set doesn't exist."""
|
||||||
|
response = client.get(reverse("twitch:badge_set_detail", kwargs={"set_id": "nonexistent"}))
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_badge_set_detail_displays_badges(self, client: Client) -> None:
|
||||||
|
"""Test that badge versions are displayed."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="moderator")
|
||||||
|
badge = ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="Moderator",
|
||||||
|
description="Channel Moderator",
|
||||||
|
click_action="visit_url",
|
||||||
|
click_url="https://help.twitch.tv",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(reverse("twitch:badge_set_detail", kwargs={"set_id": "moderator"}))
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert badge.title in content
|
||||||
|
assert badge.description in content
|
||||||
|
assert badge.badge_id in content
|
||||||
|
assert badge.image_url_4x in content
|
||||||
|
|
||||||
|
def test_badge_set_detail_displays_metadata(self, client: Client) -> None:
|
||||||
|
"""Test that badge set metadata is displayed."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="vip")
|
||||||
|
ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="VIP",
|
||||||
|
description="VIP Badge",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(reverse("twitch:badge_set_detail", kwargs={"set_id": "vip"}))
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert "vip" in content
|
||||||
|
assert "Total Versions:" in content
|
||||||
|
assert "1" in content
|
||||||
|
|
||||||
|
def test_badge_set_detail_json_data(self, client: Client) -> None:
|
||||||
|
"""Test that JSON data is displayed."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="test_set")
|
||||||
|
ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="Test",
|
||||||
|
description="Test Badge",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(reverse("twitch:badge_set_detail", kwargs={"set_id": "test_set"}))
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
# Should include JSON data section
|
||||||
|
assert "Badge Set Data (JSON)" in content
|
||||||
|
assert "test_set" in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBadgeSearch:
|
||||||
|
"""Tests for badge search functionality."""
|
||||||
|
|
||||||
|
def test_search_finds_badge_sets(self, client: Client) -> None:
|
||||||
|
"""Test that search finds badge sets."""
|
||||||
|
ChatBadgeSet.objects.create(set_id="vip")
|
||||||
|
ChatBadgeSet.objects.create(set_id="subscriber")
|
||||||
|
|
||||||
|
response = client.get(reverse("twitch:search"), {"q": "vip"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert "Badge Sets" in content
|
||||||
|
assert "vip" in content
|
||||||
|
|
||||||
|
def test_search_finds_badges_by_title(self, client: Client) -> None:
|
||||||
|
"""Test that search finds badges by title."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="test")
|
||||||
|
ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="Moderator Badge",
|
||||||
|
description="Test description",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(reverse("twitch:search"), {"q": "Moderator"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert "Chat Badges" in content
|
||||||
|
assert "Moderator Badge" in content
|
||||||
|
|
||||||
|
def test_search_finds_badges_by_description(self, client: Client) -> None:
|
||||||
|
"""Test that search finds badges by description."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="test")
|
||||||
|
ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="Test Badge",
|
||||||
|
description="Unique description text",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(reverse("twitch:search"), {"q": "Unique description"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
|
||||||
|
assert "Chat Badges" in content or "Test Badge" in content
|
||||||
314
twitch/tests/test_chat_badges.py
Normal file
314
twitch/tests/test_chat_badges.py
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
"""Tests for chat badge models and functionality."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.db import IntegrityError
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from twitch.models import ChatBadge
|
||||||
|
from twitch.models import ChatBadgeSet
|
||||||
|
from twitch.schemas import ChatBadgeSetSchema
|
||||||
|
from twitch.schemas import ChatBadgeVersionSchema
|
||||||
|
from twitch.schemas import GlobalChatBadgesResponse
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestChatBadgeSetModel:
|
||||||
|
"""Tests for the ChatBadgeSet model."""
|
||||||
|
|
||||||
|
def test_create_badge_set(self) -> None:
|
||||||
|
"""Test creating a new badge set."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="vip")
|
||||||
|
assert badge_set.set_id == "vip"
|
||||||
|
assert badge_set.added_at is not None
|
||||||
|
assert badge_set.updated_at is not None
|
||||||
|
assert str(badge_set) == "vip"
|
||||||
|
|
||||||
|
def test_unique_set_id(self) -> None:
|
||||||
|
"""Test that set_id must be unique."""
|
||||||
|
ChatBadgeSet.objects.create(set_id="vip")
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
ChatBadgeSet.objects.create(set_id="vip")
|
||||||
|
|
||||||
|
def test_badge_set_ordering(self) -> None:
|
||||||
|
"""Test that badge sets are ordered by set_id."""
|
||||||
|
ChatBadgeSet.objects.create(set_id="subscriber")
|
||||||
|
ChatBadgeSet.objects.create(set_id="bits")
|
||||||
|
ChatBadgeSet.objects.create(set_id="vip")
|
||||||
|
|
||||||
|
badge_sets = list(ChatBadgeSet.objects.all())
|
||||||
|
assert badge_sets[0].set_id == "bits"
|
||||||
|
assert badge_sets[1].set_id == "subscriber"
|
||||||
|
assert badge_sets[2].set_id == "vip"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestChatBadgeModel:
|
||||||
|
"""Tests for the ChatBadge model."""
|
||||||
|
|
||||||
|
def test_create_badge(self) -> None:
|
||||||
|
"""Test creating a new badge."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="vip")
|
||||||
|
badge = ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="VIP",
|
||||||
|
description="VIP Badge",
|
||||||
|
click_action="visit_url",
|
||||||
|
click_url="https://help.twitch.tv",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert badge.badge_set == badge_set
|
||||||
|
assert badge.badge_id == "1"
|
||||||
|
assert badge.title == "VIP"
|
||||||
|
assert badge.description == "VIP Badge"
|
||||||
|
assert badge.click_action == "visit_url"
|
||||||
|
assert badge.click_url == "https://help.twitch.tv"
|
||||||
|
assert str(badge) == "vip/1: VIP"
|
||||||
|
|
||||||
|
def test_unique_badge_set_and_id(self) -> None:
|
||||||
|
"""Test that badge_set and badge_id combination must be unique."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="vip")
|
||||||
|
ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="VIP",
|
||||||
|
description="VIP Badge",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="VIP Duplicate",
|
||||||
|
description="Duplicate Badge",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_different_badge_ids_same_set(self) -> None:
|
||||||
|
"""Test that different badge_ids can exist in the same set."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="bits")
|
||||||
|
badge1 = ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="Bits 1",
|
||||||
|
description="1 Bit",
|
||||||
|
)
|
||||||
|
badge2 = ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="100",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="Bits 100",
|
||||||
|
description="100 Bits",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert badge1.badge_id == "1"
|
||||||
|
assert badge2.badge_id == "100"
|
||||||
|
assert badge1.badge_set == badge2.badge_set
|
||||||
|
|
||||||
|
def test_nullable_click_fields(self) -> None:
|
||||||
|
"""Test that click_action and click_url can be null."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="moderator")
|
||||||
|
badge = ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="Moderator",
|
||||||
|
description="Channel Moderator",
|
||||||
|
click_action=None,
|
||||||
|
click_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert badge.click_action is None
|
||||||
|
assert badge.click_url is None
|
||||||
|
|
||||||
|
def test_badge_cascade_delete(self) -> None:
|
||||||
|
"""Test that badges are deleted when their badge set is deleted."""
|
||||||
|
badge_set = ChatBadgeSet.objects.create(set_id="test_set")
|
||||||
|
ChatBadge.objects.create(
|
||||||
|
badge_set=badge_set,
|
||||||
|
badge_id="1",
|
||||||
|
image_url_1x="https://example.com/1x.png",
|
||||||
|
image_url_2x="https://example.com/2x.png",
|
||||||
|
image_url_4x="https://example.com/4x.png",
|
||||||
|
title="Test Badge",
|
||||||
|
description="Test Description",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ChatBadge.objects.filter(badge_set=badge_set).count() == 1
|
||||||
|
badge_set.delete()
|
||||||
|
assert ChatBadge.objects.filter(badge_set__set_id="test_set").count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestChatBadgeSchemas:
|
||||||
|
"""Tests for chat badge Pydantic schemas."""
|
||||||
|
|
||||||
|
def test_chat_badge_version_schema_valid(self) -> None:
|
||||||
|
"""Test that ChatBadgeVersionSchema validates correct data."""
|
||||||
|
data = {
|
||||||
|
"id": "1",
|
||||||
|
"image_url_1x": "https://static-cdn.jtvnw.net/badges/v1/example/1",
|
||||||
|
"image_url_2x": "https://static-cdn.jtvnw.net/badges/v1/example/2",
|
||||||
|
"image_url_4x": "https://static-cdn.jtvnw.net/badges/v1/example/3",
|
||||||
|
"title": "VIP",
|
||||||
|
"description": "VIP Badge",
|
||||||
|
"click_action": "visit_url",
|
||||||
|
"click_url": "https://help.twitch.tv",
|
||||||
|
}
|
||||||
|
|
||||||
|
schema = ChatBadgeVersionSchema.model_validate(data)
|
||||||
|
assert schema.badge_id == "1"
|
||||||
|
assert schema.title == "VIP"
|
||||||
|
assert schema.click_action == "visit_url"
|
||||||
|
|
||||||
|
def test_chat_badge_version_schema_nullable_fields(self) -> None:
|
||||||
|
"""Test that nullable fields in ChatBadgeVersionSchema work correctly."""
|
||||||
|
data = {
|
||||||
|
"id": "1",
|
||||||
|
"image_url_1x": "https://static-cdn.jtvnw.net/badges/v1/example/1",
|
||||||
|
"image_url_2x": "https://static-cdn.jtvnw.net/badges/v1/example/2",
|
||||||
|
"image_url_4x": "https://static-cdn.jtvnw.net/badges/v1/example/3",
|
||||||
|
"title": "VIP",
|
||||||
|
"description": "VIP Badge",
|
||||||
|
"click_action": None,
|
||||||
|
"click_url": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
schema = ChatBadgeVersionSchema.model_validate(data)
|
||||||
|
assert schema.click_action is None
|
||||||
|
assert schema.click_url is None
|
||||||
|
|
||||||
|
def test_chat_badge_version_schema_missing_required(self) -> None:
|
||||||
|
"""Test that ChatBadgeVersionSchema raises error on missing required fields."""
|
||||||
|
data = {
|
||||||
|
"id": "1",
|
||||||
|
"title": "VIP",
|
||||||
|
# Missing required image URLs and description
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ChatBadgeVersionSchema.model_validate(data)
|
||||||
|
|
||||||
|
def test_chat_badge_set_schema_valid(self) -> None:
|
||||||
|
"""Test that ChatBadgeSetSchema validates correct data."""
|
||||||
|
data = {
|
||||||
|
"set_id": "vip",
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"image_url_1x": "https://static-cdn.jtvnw.net/badges/v1/example/1",
|
||||||
|
"image_url_2x": "https://static-cdn.jtvnw.net/badges/v1/example/2",
|
||||||
|
"image_url_4x": "https://static-cdn.jtvnw.net/badges/v1/example/3",
|
||||||
|
"title": "VIP",
|
||||||
|
"description": "VIP Badge",
|
||||||
|
"click_action": "visit_url",
|
||||||
|
"click_url": "https://help.twitch.tv",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
schema = ChatBadgeSetSchema.model_validate(data)
|
||||||
|
assert schema.set_id == "vip"
|
||||||
|
assert len(schema.versions) == 1
|
||||||
|
assert schema.versions[0].badge_id == "1"
|
||||||
|
|
||||||
|
def test_chat_badge_set_schema_multiple_versions(self) -> None:
|
||||||
|
"""Test that ChatBadgeSetSchema handles multiple badge versions."""
|
||||||
|
data = {
|
||||||
|
"set_id": "bits",
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"image_url_1x": "https://example.com/1/1x.png",
|
||||||
|
"image_url_2x": "https://example.com/1/2x.png",
|
||||||
|
"image_url_4x": "https://example.com/1/4x.png",
|
||||||
|
"title": "Bits 1",
|
||||||
|
"description": "1 Bit",
|
||||||
|
"click_action": None,
|
||||||
|
"click_url": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "100",
|
||||||
|
"image_url_1x": "https://example.com/100/1x.png",
|
||||||
|
"image_url_2x": "https://example.com/100/2x.png",
|
||||||
|
"image_url_4x": "https://example.com/100/4x.png",
|
||||||
|
"title": "Bits 100",
|
||||||
|
"description": "100 Bits",
|
||||||
|
"click_action": None,
|
||||||
|
"click_url": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
schema = ChatBadgeSetSchema.model_validate(data)
|
||||||
|
assert schema.set_id == "bits"
|
||||||
|
assert len(schema.versions) == 2
|
||||||
|
assert schema.versions[0].badge_id == "1"
|
||||||
|
assert schema.versions[1].badge_id == "100"
|
||||||
|
|
||||||
|
def test_global_chat_badges_response_valid(self) -> None:
|
||||||
|
"""Test that GlobalChatBadgesResponse validates correct API response."""
|
||||||
|
data = {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"set_id": "vip",
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"image_url_1x": "https://static-cdn.jtvnw.net/badges/v1/example/1",
|
||||||
|
"image_url_2x": "https://static-cdn.jtvnw.net/badges/v1/example/2",
|
||||||
|
"image_url_4x": "https://static-cdn.jtvnw.net/badges/v1/example/3",
|
||||||
|
"title": "VIP",
|
||||||
|
"description": "VIP Badge",
|
||||||
|
"click_action": "visit_url",
|
||||||
|
"click_url": "https://help.twitch.tv",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = GlobalChatBadgesResponse.model_validate(data)
|
||||||
|
assert len(response.data) == 1
|
||||||
|
assert response.data[0].set_id == "vip"
|
||||||
|
|
||||||
|
def test_global_chat_badges_response_empty(self) -> None:
|
||||||
|
"""Test that GlobalChatBadgesResponse validates empty response."""
|
||||||
|
data = {"data": []}
|
||||||
|
|
||||||
|
response = GlobalChatBadgesResponse.model_validate(data)
|
||||||
|
assert len(response.data) == 0
|
||||||
|
|
||||||
|
def test_chat_badge_schema_extra_forbidden(self) -> None:
|
||||||
|
"""Test that extra fields are forbidden in schemas."""
|
||||||
|
data = {
|
||||||
|
"id": "1",
|
||||||
|
"image_url_1x": "https://example.com/1x.png",
|
||||||
|
"image_url_2x": "https://example.com/2x.png",
|
||||||
|
"image_url_4x": "https://example.com/4x.png",
|
||||||
|
"title": "VIP",
|
||||||
|
"description": "VIP Badge",
|
||||||
|
"extra_field": "should_fail", # This should cause validation to fail
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
ChatBadgeVersionSchema.model_validate(data)
|
||||||
|
|
||||||
|
# Check that the error is about the extra field
|
||||||
|
assert "extra_field" in str(exc_info.value)
|
||||||
|
|
@ -19,6 +19,8 @@ app_name = "twitch"
|
||||||
|
|
||||||
urlpatterns: list[URLPattern] = [
|
urlpatterns: list[URLPattern] = [
|
||||||
path("", views.dashboard, name="dashboard"),
|
path("", views.dashboard, name="dashboard"),
|
||||||
|
path("badges/", views.badge_list_view, name="badge_list"),
|
||||||
|
path("badges/<str:set_id>/", views.badge_set_detail_view, name="badge_set_detail"),
|
||||||
path("campaigns/", views.drop_campaign_list_view, name="campaign_list"),
|
path("campaigns/", views.drop_campaign_list_view, name="campaign_list"),
|
||||||
path("campaigns/<str:twitch_id>/", views.drop_campaign_detail_view, name="campaign_detail"),
|
path("campaigns/<str:twitch_id>/", views.drop_campaign_detail_view, name="campaign_detail"),
|
||||||
path("channels/", views.ChannelListView.as_view(), name="channel_list"),
|
path("channels/", views.ChannelListView.as_view(), name="channel_list"),
|
||||||
|
|
|
||||||
117
twitch/views.py
117
twitch/views.py
|
|
@ -32,6 +32,8 @@ from pygments.formatters import HtmlFormatter
|
||||||
from pygments.lexers.data import JsonLexer
|
from pygments.lexers.data import JsonLexer
|
||||||
|
|
||||||
from twitch.models import Channel
|
from twitch.models import Channel
|
||||||
|
from twitch.models import ChatBadge
|
||||||
|
from twitch.models import ChatBadgeSet
|
||||||
from twitch.models import DropBenefit
|
from twitch.models import DropBenefit
|
||||||
from twitch.models import DropCampaign
|
from twitch.models import DropCampaign
|
||||||
from twitch.models import Game
|
from twitch.models import Game
|
||||||
|
|
@ -113,6 +115,10 @@ def search_view(request: HttpRequest) -> HttpResponse:
|
||||||
results["reward_campaigns"] = RewardCampaign.objects.filter(
|
results["reward_campaigns"] = RewardCampaign.objects.filter(
|
||||||
Q(name__istartswith=query) | Q(brand__istartswith=query) | Q(summary__icontains=query),
|
Q(name__istartswith=query) | Q(brand__istartswith=query) | Q(summary__icontains=query),
|
||||||
).select_related("game")
|
).select_related("game")
|
||||||
|
results["badge_sets"] = ChatBadgeSet.objects.filter(set_id__istartswith=query)
|
||||||
|
results["badges"] = ChatBadge.objects.filter(
|
||||||
|
Q(title__istartswith=query) | Q(description__icontains=query),
|
||||||
|
).select_related("badge_set")
|
||||||
else:
|
else:
|
||||||
# SQLite-compatible text search using icontains
|
# SQLite-compatible text search using icontains
|
||||||
results["organizations"] = Organization.objects.filter(
|
results["organizations"] = Organization.objects.filter(
|
||||||
|
|
@ -133,6 +139,10 @@ def search_view(request: HttpRequest) -> HttpResponse:
|
||||||
results["reward_campaigns"] = RewardCampaign.objects.filter(
|
results["reward_campaigns"] = RewardCampaign.objects.filter(
|
||||||
Q(name__icontains=query) | Q(brand__icontains=query) | Q(summary__icontains=query),
|
Q(name__icontains=query) | Q(brand__icontains=query) | Q(summary__icontains=query),
|
||||||
).select_related("game")
|
).select_related("game")
|
||||||
|
results["badge_sets"] = ChatBadgeSet.objects.filter(set_id__icontains=query)
|
||||||
|
results["badges"] = ChatBadge.objects.filter(
|
||||||
|
Q(title__icontains=query) | Q(description__icontains=query),
|
||||||
|
).select_related("badge_set")
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
|
|
@ -1143,3 +1153,110 @@ class ChannelDetailView(DetailView):
|
||||||
)
|
)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: /badges/
|
||||||
|
def badge_list_view(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""List view for chat badge sets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The HTTP request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HttpResponse: The rendered badge list page.
|
||||||
|
"""
|
||||||
|
badge_sets: QuerySet[ChatBadgeSet] = (
|
||||||
|
ChatBadgeSet.objects
|
||||||
|
.all()
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"badges",
|
||||||
|
queryset=ChatBadge.objects.order_by("badge_id"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by("set_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group badges by set for easier display
|
||||||
|
badge_data: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"set": badge_set,
|
||||||
|
"badges": list(badge_set.badges.all()), # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
}
|
||||||
|
for badge_set in badge_sets
|
||||||
|
]
|
||||||
|
|
||||||
|
context: dict[str, Any] = {
|
||||||
|
"badge_sets": badge_sets,
|
||||||
|
"badge_data": badge_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, "twitch/badge_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: /badges/<set_id>/
|
||||||
|
def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse:
|
||||||
|
"""Detail view for a specific badge set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The HTTP request.
|
||||||
|
set_id: The ID of the badge set.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HttpResponse: The rendered badge set detail page.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Http404: If the badge set is not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
badge_set: ChatBadgeSet = ChatBadgeSet.objects.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"badges",
|
||||||
|
queryset=ChatBadge.objects.order_by("badge_id"),
|
||||||
|
),
|
||||||
|
).get(set_id=set_id)
|
||||||
|
except ChatBadgeSet.DoesNotExist as exc:
|
||||||
|
msg = "No badge set found matching the query"
|
||||||
|
raise Http404(msg) from exc
|
||||||
|
|
||||||
|
badges: QuerySet[ChatBadge] = badge_set.badges.all() # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
|
# Serialize for JSON display
|
||||||
|
serialized_set = serialize(
|
||||||
|
"json",
|
||||||
|
[badge_set],
|
||||||
|
fields=(
|
||||||
|
"set_id",
|
||||||
|
"added_at",
|
||||||
|
"updated_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
set_data: list[dict[str, Any]] = json.loads(serialized_set)
|
||||||
|
|
||||||
|
if badges.exists():
|
||||||
|
serialized_badges = serialize(
|
||||||
|
"json",
|
||||||
|
badges,
|
||||||
|
fields=(
|
||||||
|
"badge_id",
|
||||||
|
"image_url_1x",
|
||||||
|
"image_url_2x",
|
||||||
|
"image_url_4x",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"click_action",
|
||||||
|
"click_url",
|
||||||
|
"added_at",
|
||||||
|
"updated_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
badges_data: list[dict[str, Any]] = json.loads(serialized_badges)
|
||||||
|
set_data[0]["fields"]["badges"] = badges_data
|
||||||
|
|
||||||
|
context: dict[str, Any] = {
|
||||||
|
"badge_set": badge_set,
|
||||||
|
"badges": badges,
|
||||||
|
"set_data": format_and_color_json(set_data[0]),
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, "twitch/badge_set_detail.html", context)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue