Add Redis configuration, integrate Celery, and sort fields in models
All checks were successful
Deploy to Server / deploy (push) Successful in 49s

This commit is contained in:
Joakim Hellsén 2026-03-21 19:12:47 +01:00
commit d99579ed2b
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
15 changed files with 451 additions and 253 deletions

View file

@ -35,3 +35,7 @@ EMAIL_TIMEOUT=10
# Where to store Twitch API responses # Where to store Twitch API responses
TTVDROPS_IMPORTED_DIR=/mnt/fourteen/Data/Responses/imported TTVDROPS_IMPORTED_DIR=/mnt/fourteen/Data/Responses/imported
TTVDROPS_BROKEN_DIR=/mnt/fourteen/Data/Responses/broken TTVDROPS_BROKEN_DIR=/mnt/fourteen/Data/Responses/broken
# Redis Configuration
REDIS_URL_CACHE=unix:///var/run/redis/redis.sock?db=0
REDIS_URL_CELERY=unix:///var/run/redis/redis.sock?db=1

View file

@ -64,11 +64,12 @@
- Use settings modules and environment variables to configure behavior, not hardcoded constants - Use settings modules and environment variables to configure behavior, not hardcoded constants
## Technology Stack ## Technology Stack
- Python 3, Django, SQLite - Python 3, Django, PostgreSQL, Redis (Valkey), Celery for background tasks
- HTML templates with Django templating; static assets served from `static/` and collected to `staticfiles/` - HTML templates with Django templating; static assets served from `static/` and collected to `staticfiles/`
- 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
- Use `uv run pytest` to run tests
## Documentation & Project Organization ## Documentation & Project Organization
- Only create documentation files when explicitly requested by the user - Only create documentation files when explicitly requested by the user

View file

@ -81,6 +81,19 @@ uv run python manage.py runserver
uv run pytest uv run pytest
``` ```
## Celery
```bash
```
Start a worker:
```bash
uv run celery -A config worker --loglevel=info
uv run celery -A config beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseScheduler
```
## Import Drops ## Import Drops
```bash ```bash

View file

@ -0,0 +1,5 @@
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ("celery_app",)

15
config/celery.py Normal file
View file

@ -0,0 +1,15 @@
import os
from celery import Celery
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery("config")
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
app.config_from_object("django.conf:settings", namespace="CELERY")
# Load task modules from all registered Django apps.
app.autodiscover_tasks()

View file

@ -135,14 +135,19 @@ LOGGING: dict[str, Any] = {
} }
INSTALLED_APPS: list[str] = [ INSTALLED_APPS: list[str] = [
# Django built-in apps
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.postgres", "django.contrib.postgres",
# Internal apps
"twitch.apps.TwitchConfig", "twitch.apps.TwitchConfig",
"kick.apps.KickConfig", "kick.apps.KickConfig",
"youtube.apps.YoutubeConfig", "youtube.apps.YoutubeConfig",
"core.apps.CoreConfig", "core.apps.CoreConfig",
# Third-party apps
"django_celery_results",
"django_celery_beat",
] ]
MIDDLEWARE: list[str] = [ MIDDLEWARE: list[str] = [
@ -202,3 +207,24 @@ if not TESTING:
profile_session_sample_rate=1.0, profile_session_sample_rate=1.0,
profile_lifecycle="trace", profile_lifecycle="trace",
) )
REDIS_URL_CACHE: str = os.getenv(
key="REDIS_URL_CACHE",
default="redis://localhost:6379/0",
)
REDIS_URL_CELERY: str = os.getenv(
key="REDIS_URL_CELERY",
default="redis://localhost:6379/1",
)
CACHES: dict[str, dict[str, str]] = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": REDIS_URL_CACHE,
},
}
CELERY_BROKER_URL: str = REDIS_URL_CELERY
CELERY_RESULT_BACKEND = "django-db"
CELERY_RESULT_EXTENDED = True
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"

View file

@ -0,0 +1,41 @@
from typing import TYPE_CHECKING
from typing import Any
from unittest.mock import patch
import pytest
from celery import Celery
if TYPE_CHECKING:
from collections.abc import Generator
@pytest.fixture
def celery_app() -> Generator[Celery, Any]:
"""Fixture to create a Celery app instance for testing.
Yields:
Celery: A Celery app instance configured for testing.
"""
with patch("os.environ.setdefault") as mock_setenv: # noqa: F841
app = Celery("config")
app.config_from_object("django.conf:settings", namespace="CELERY")
yield app
def test_celery_app_initialization(celery_app: Celery) -> None:
"""Test that the Celery app is initialized with the correct main module."""
assert celery_app.main == "config"
def test_celery_app_config(celery_app: Celery) -> None:
"""Test that the Celery app is configured with the correct settings."""
with patch("celery.Celery.config_from_object") as mock_config:
celery_app.config_from_object("django.conf:settings", namespace="CELERY")
mock_config.assert_called_once_with("django.conf:settings", namespace="CELERY")
def test_celery_task_discovery(celery_app: Celery) -> None:
"""Test that the Celery app discovers tasks correctly."""
with patch("celery.Celery.autodiscover_tasks") as mock_discover:
celery_app.autodiscover_tasks()
mock_discover.assert_called_once()

View file

@ -1,9 +1,14 @@
import importlib import importlib
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest
from django.conf import settings
from django.test.utils import override_settings from django.test.utils import override_settings
from django.urls import resolve
from django.urls import reverse from django.urls import reverse
from config.urls import urlpatterns
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable from collections.abc import Iterable
from types import ModuleType from types import ModuleType
@ -50,3 +55,43 @@ def test_debug_tools_not_present_while_testing() -> None:
patterns = list(_pattern_strings(mod)) patterns = list(_pattern_strings(mod))
assert not any("silk" in p for p in patterns) assert not any("silk" in p for p in patterns)
assert not any("__debug__" in p or "debug" in p for p in patterns) assert not any("__debug__" in p or "debug" in p for p in patterns)
@pytest.mark.parametrize(
"url_name",
[
"sitemap",
"sitemap-static",
"sitemap-twitch-channels",
"sitemap-twitch-drops",
"sitemap-twitch-others",
"sitemap-kick",
"sitemap-youtube",
],
)
def test_static_sitemap_urls(url_name: str) -> None:
"""Test that static sitemap URLs resolve correctly."""
url: str = reverse(url_name)
resolved_view: str = resolve(url).view_name
assert resolved_view == url_name
def test_app_url_inclusion() -> None:
"""Test that app-specific URLs are included in urlpatterns."""
assert any("core.urls" in str(pattern) for pattern in urlpatterns)
assert any("twitch.urls" in str(pattern) for pattern in urlpatterns)
assert any("kick.urls" in str(pattern) for pattern in urlpatterns)
assert any("youtube.urls" in str(pattern) for pattern in urlpatterns)
def test_media_serving_in_development() -> None:
"""Test that media URLs are served in development mode."""
if settings.DEBUG:
assert any("MEDIA_URL" in str(pattern) for pattern in urlpatterns)
def test_debug_toolbar_and_silk_urls() -> None:
"""Test that debug toolbar and Silk URLs are included when appropriate."""
if not settings.TESTING:
assert any("silk.urls" in str(pattern) for pattern in urlpatterns)
assert any("debug_toolbar" in str(pattern) for pattern in urlpatterns)

View file

@ -1 +0,0 @@
# Register your models here.

View file

@ -1 +0,0 @@
# Create your models here.

View file

@ -25,8 +25,15 @@ dependencies = [
"tqdm", "tqdm",
"index-now-for-python", "index-now-for-python",
"sitemap-parser", "sitemap-parser",
"celery[redis]",
"django-celery-results",
"django-celery-beat",
"flower",
"hiredis",
"redis",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"django-stubs", "django-stubs",

View file

@ -119,7 +119,6 @@
type="application/atom+xml" type="application/atom+xml"
title="All Kick organizations (Discord)" title="All Kick organizations (Discord)"
href="{% url 'kick:organization_feed_discord' %}" /> href="{% url 'kick:organization_feed_discord' %}" />
{# Allow child templates to inject page-specific alternates into the head #}
{% block extra_head %} {% block extra_head %}
{% endblock extra_head %} {% endblock extra_head %}
<style> <style>
@ -194,62 +193,6 @@
margin-right: 4px; margin-right: 4px;
border-radius: 2px; border-radius: 2px;
} }
@media (prefers-color-scheme: dark) {
.highlight {
background: #0d1117;
color: #E6EDF3;
}
.highlight .p {
color: #E6EDF3;
}
.highlight .nt {
color: #7EE787;
}
.highlight .s2,
.highlight .mi {
color: #A5D6FF;
}
.highlight .kc {
color: #79C0FF;
}
.highlight .w {
color: #6E7681;
}
}
@media (prefers-color-scheme: light) {
.highlight {
background: #f6f8fa;
color: #24292e;
}
.highlight .p {
color: #24292e;
}
.highlight .nt {
color: #005cc5;
}
.highlight .s2,
.highlight .mi {
color: #032f62;
}
.highlight .kc {
color: #d73a49;
}
.highlight .w {
color: #6a737d;
}
}
</style> </style>
</head> </head>
<body> <body>

View file

@ -345,8 +345,12 @@ class Command(BaseCommand):
if img.mode in {"RGBA", "LA"} or ( if img.mode in {"RGBA", "LA"} or (
img.mode == "P" and "transparency" in img.info img.mode == "P" and "transparency" in img.info
): ):
background: Image = Image.new("RGB", img.size, (255, 255, 255)) background: Image.Image = Image.new(
rgba_img: Image | ImageFile = ( "RGB",
img.size,
(255, 255, 255),
)
rgba_img: Image.Image | ImageFile = (
img.convert("RGBA") if img.mode == "P" else img img.convert("RGBA") if img.mode == "P" else img
) )
background.paste( background.paste(
@ -357,9 +361,9 @@ class Command(BaseCommand):
) )
rgb_img = background rgb_img = background
elif img.mode != "RGB": elif img.mode != "RGB":
rgb_img: Image = img.convert("RGB") rgb_img: Image.Image = img.convert("RGB")
else: else:
rgb_img: ImageFile = img rgb_img: Image.Image = img
# Save WebP # Save WebP
rgb_img.save(webp_path, "WEBP", quality=85, method=6) rgb_img.save(webp_path, "WEBP", quality=85, method=6)

View file

@ -22,28 +22,30 @@ class Organization(auto_prefetch.Model):
"""Represents an organization on Twitch that can own drop campaigns.""" """Represents an organization on Twitch that can own drop campaigns."""
twitch_id = models.TextField( twitch_id = models.TextField(
unique=True, help_text="The unique Twitch identifier for the organization.",
verbose_name="Organization ID", verbose_name="Organization ID",
editable=False, editable=False,
help_text="The unique Twitch identifier for the organization.",
)
name = models.TextField(
unique=True, unique=True,
verbose_name="Name", )
name = models.TextField(
help_text="Display name of the organization.", help_text="Display name of the organization.",
verbose_name="Name",
unique=True,
) )
added_at = models.DateTimeField( added_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Added At",
editable=False,
help_text="Timestamp when this organization record was created.", help_text="Timestamp when this organization record was created.",
verbose_name="Added At",
auto_now_add=True,
editable=False,
) )
updated_at = models.DateTimeField( updated_at = models.DateTimeField(
auto_now=True, help_text="Timestamp when this organization record was last updated.",
verbose_name="Updated At", verbose_name="Updated At",
editable=False, editable=False,
help_text="Timestamp when this organization record was last updated.", auto_now=True,
) )
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
@ -77,71 +79,87 @@ class Game(auto_prefetch.Model):
twitch_id = models.TextField(verbose_name="Twitch game ID", unique=True) twitch_id = models.TextField(verbose_name="Twitch game ID", unique=True)
slug = models.TextField( slug = models.TextField(
help_text="Short unique identifier for the game.",
verbose_name="Slug",
max_length=200, max_length=200,
blank=True, blank=True,
default="", default="",
verbose_name="Slug",
help_text="Short unique identifier for the game.",
) )
name = models.TextField(blank=True, default="", verbose_name="Name")
display_name = models.TextField(blank=True, default="", verbose_name="Display name") name = models.TextField(
verbose_name="Name",
blank=True,
default="",
)
display_name = models.TextField(
verbose_name="Display name",
blank=True,
default="",
)
box_art = models.URLField( # noqa: DJ001 box_art = models.URLField( # noqa: DJ001
verbose_name="Box art URL",
max_length=500, max_length=500,
blank=True, blank=True,
null=True,
default="", default="",
verbose_name="Box art URL", null=True,
) )
box_art_file = models.ImageField( box_art_file = models.ImageField(
help_text="Locally cached box art image served from this site.",
height_field="box_art_height",
width_field="box_art_width",
upload_to="games/box_art/", upload_to="games/box_art/",
blank=True, blank=True,
null=True, null=True,
width_field="box_art_width",
height_field="box_art_height",
help_text="Locally cached box art image served from this site.",
) )
box_art_width = models.PositiveIntegerField( box_art_width = models.PositiveIntegerField(
null=True,
blank=True,
editable=False,
help_text="Width of cached box art image in pixels.", help_text="Width of cached box art image in pixels.",
editable=False,
blank=True,
null=True,
) )
box_art_height = models.PositiveIntegerField( box_art_height = models.PositiveIntegerField(
null=True,
blank=True,
editable=False,
help_text="Height of cached box art image in pixels.", help_text="Height of cached box art image in pixels.",
)
box_art_size_bytes = models.PositiveIntegerField(
null=True,
blank=True,
editable=False, editable=False,
help_text="File size of the cached box art image in bytes.", blank=True,
null=True,
) )
box_art_size_bytes = models.PositiveIntegerField(
help_text="File size of the cached box art image in bytes.",
editable=False,
blank=True,
null=True,
)
box_art_mime_type = models.CharField( box_art_mime_type = models.CharField(
help_text="MIME type of the cached box art image (e.g., 'image/png').",
editable=False,
max_length=50, max_length=50,
blank=True, blank=True,
default="", default="",
editable=False,
help_text="MIME type of the cached box art image (e.g., 'image/png').",
) )
owners = models.ManyToManyField( owners = models.ManyToManyField(
Organization,
related_name="games",
blank=True,
verbose_name="Organizations",
help_text="Organizations that own this game.", help_text="Organizations that own this game.",
verbose_name="Organizations",
related_name="games",
to=Organization,
blank=True,
) )
added_at = models.DateTimeField( added_at = models.DateTimeField(
auto_now_add=True,
help_text="Timestamp when this game record was created.", help_text="Timestamp when this game record was created.",
auto_now_add=True,
) )
updated_at = models.DateTimeField( updated_at = models.DateTimeField(
auto_now=True,
help_text="Timestamp when this game record was last updated.", help_text="Timestamp when this game record was last updated.",
auto_now=True,
) )
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
@ -229,39 +247,49 @@ class TwitchGameData(auto_prefetch.Model):
""" """
twitch_id = models.TextField( twitch_id = models.TextField(
help_text="The Twitch ID for this game.",
verbose_name="Twitch Game ID", verbose_name="Twitch Game ID",
unique=True, unique=True,
help_text="The Twitch ID for this game.",
)
game = auto_prefetch.ForeignKey(
Game,
on_delete=models.SET_NULL,
related_name="twitch_game_data",
null=True,
blank=True,
verbose_name="Game",
help_text=("Optional link to the local Game record for this Twitch game."),
) )
name = models.TextField(blank=True, default="", verbose_name="Name") game = auto_prefetch.ForeignKey(
box_art_url = models.URLField( help_text="Optional link to the local Game record for this Twitch game.",
max_length=500, related_name="twitch_game_data",
on_delete=models.SET_NULL,
verbose_name="Game",
blank=True,
null=True,
to=Game,
)
name = models.TextField(
verbose_name="Name",
blank=True,
default="",
)
box_art_url = models.URLField(
help_text="URL template with {width}x{height} placeholders for the box art image.",
verbose_name="Box art URL",
blank=True,
default="",
max_length=500,
)
igdb_id = models.TextField(
verbose_name="IGDB ID",
blank=True, blank=True,
default="", default="",
verbose_name="Box art URL",
help_text=(
"URL template with {width}x{height} placeholders for the box art image."
),
) )
igdb_id = models.TextField(blank=True, default="", verbose_name="IGDB ID")
added_at = models.DateTimeField( added_at = models.DateTimeField(
auto_now_add=True,
help_text="Record creation time.", help_text="Record creation time.",
auto_now_add=True,
) )
updated_at = models.DateTimeField( updated_at = models.DateTimeField(
auto_now=True,
help_text="Record last update time.", help_text="Record last update time.",
auto_now=True,
) )
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
@ -284,26 +312,29 @@ class Channel(auto_prefetch.Model):
"""Represents a Twitch channel that can participate in drop campaigns.""" """Represents a Twitch channel that can participate in drop campaigns."""
twitch_id = models.TextField( twitch_id = models.TextField(
verbose_name="Channel ID",
help_text="The unique Twitch identifier for the channel.", help_text="The unique Twitch identifier for the channel.",
verbose_name="Channel ID",
unique=True, unique=True,
) )
name = models.TextField( name = models.TextField(
verbose_name="Username",
help_text="The lowercase username of the channel.", help_text="The lowercase username of the channel.",
verbose_name="Username",
) )
display_name = models.TextField( display_name = models.TextField(
verbose_name="Display Name",
help_text="The display name of the channel (with proper capitalization).", help_text="The display name of the channel (with proper capitalization).",
verbose_name="Display Name",
) )
added_at = models.DateTimeField( added_at = models.DateTimeField(
auto_now_add=True,
help_text="Timestamp when this channel record was created.", help_text="Timestamp when this channel record was created.",
auto_now_add=True,
) )
updated_at = models.DateTimeField( updated_at = models.DateTimeField(
auto_now=True,
help_text="Timestamp when this channel record was last updated.", help_text="Timestamp when this channel record was last updated.",
auto_now=True,
) )
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
@ -326,112 +357,130 @@ class DropCampaign(auto_prefetch.Model):
"""Represents a Twitch drop campaign.""" """Represents a Twitch drop campaign."""
twitch_id = models.TextField( twitch_id = models.TextField(
unique=True,
editable=False,
help_text="The Twitch ID for this campaign.", help_text="The Twitch ID for this campaign.",
editable=False,
unique=True,
) )
name = models.TextField(help_text="Name of the drop campaign.")
name = models.TextField(
help_text="Name of the drop campaign.",
)
description = models.TextField( description = models.TextField(
blank=True,
help_text="Detailed description of the campaign.", help_text="Detailed description of the campaign.",
blank=True,
) )
details_url = models.URLField( details_url = models.URLField(
max_length=500,
blank=True,
default="",
help_text="URL with campaign details.", help_text="URL with campaign details.",
max_length=500,
blank=True,
default="",
) )
account_link_url = models.URLField( account_link_url = models.URLField(
max_length=500,
blank=True,
default="",
help_text="URL to link a Twitch account for the campaign.", help_text="URL to link a Twitch account for the campaign.",
)
image_url = models.URLField(
max_length=500, max_length=500,
blank=True, blank=True,
default="", default="",
)
image_url = models.URLField(
help_text="URL to an image representing the campaign.", help_text="URL to an image representing the campaign.",
max_length=500,
blank=True,
default="",
) )
image_file = models.ImageField( image_file = models.ImageField(
upload_to="campaigns/images/",
blank=True,
null=True,
width_field="image_width",
height_field="image_height",
help_text="Locally cached campaign image served from this site.", help_text="Locally cached campaign image served from this site.",
upload_to="campaigns/images/",
height_field="image_height",
width_field="image_width",
blank=True,
null=True,
) )
image_width = models.PositiveIntegerField( image_width = models.PositiveIntegerField(
null=True,
blank=True,
editable=False,
help_text="Width of cached image in pixels.", help_text="Width of cached image in pixels.",
editable=False,
blank=True,
null=True,
) )
image_height = models.PositiveIntegerField( image_height = models.PositiveIntegerField(
null=True,
blank=True,
editable=False,
help_text="Height of cached image in pixels.", help_text="Height of cached image in pixels.",
)
image_size_bytes = models.PositiveIntegerField(
null=True,
blank=True,
editable=False, editable=False,
help_text="File size of the cached campaign image in bytes.", blank=True,
null=True,
) )
image_size_bytes = models.PositiveIntegerField(
help_text="File size of the cached campaign image in bytes.",
editable=False,
blank=True,
null=True,
)
image_mime_type = models.CharField( image_mime_type = models.CharField(
help_text="MIME type of the cached campaign image (e.g., 'image/png').",
editable=False,
max_length=50, max_length=50,
blank=True, blank=True,
default="", default="",
editable=False,
help_text="MIME type of the cached campaign image (e.g., 'image/png').",
) )
start_at = models.DateTimeField( start_at = models.DateTimeField(
null=True,
blank=True,
help_text="Datetime when the campaign starts.", help_text="Datetime when the campaign starts.",
) blank=True,
end_at = models.DateTimeField(
null=True, null=True,
blank=True, )
end_at = models.DateTimeField(
help_text="Datetime when the campaign ends.", help_text="Datetime when the campaign ends.",
)
allow_is_enabled = models.BooleanField(
default=True,
help_text="Whether the campaign allows participation.",
)
allow_channels = models.ManyToManyField(
Channel,
blank=True, blank=True,
related_name="allowed_campaigns", null=True,
)
allow_is_enabled = models.BooleanField(
help_text="Whether the campaign allows participation.",
default=True,
)
allow_channels = models.ManyToManyField(
help_text="Channels that are allowed to participate in this campaign.", help_text="Channels that are allowed to participate in this campaign.",
related_name="allowed_campaigns",
to=Channel,
blank=True,
) )
game = auto_prefetch.ForeignKey( game = auto_prefetch.ForeignKey(
Game,
on_delete=models.CASCADE,
related_name="drop_campaigns",
verbose_name="Game",
help_text="Game associated with this campaign.", help_text="Game associated with this campaign.",
related_name="drop_campaigns",
on_delete=models.CASCADE,
verbose_name="Game",
to=Game,
) )
operation_names = models.JSONField( operation_names = models.JSONField(
help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).",
default=list, default=list,
blank=True, blank=True,
help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).",
) )
added_at = models.DateTimeField( added_at = models.DateTimeField(
auto_now_add=True,
help_text="Timestamp when this campaign record was created.", help_text="Timestamp when this campaign record was created.",
auto_now_add=True,
) )
updated_at = models.DateTimeField( updated_at = models.DateTimeField(
auto_now=True,
help_text="Timestamp when this campaign record was last updated.", help_text="Timestamp when this campaign record was last updated.",
auto_now=True,
) )
is_fully_imported = models.BooleanField( is_fully_imported = models.BooleanField(
default=False,
help_text="True if all images and formats are imported and ready for display.", help_text="True if all images and formats are imported and ready for display.",
default=False,
) )
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
@ -575,71 +624,77 @@ class DropBenefit(auto_prefetch.Model):
"""Represents a benefit that can be earned from a drop.""" """Represents a benefit that can be earned from a drop."""
twitch_id = models.TextField( twitch_id = models.TextField(
unique=True,
help_text="The Twitch ID for this benefit.", help_text="The Twitch ID for this benefit.",
editable=False, editable=False,
unique=True,
) )
name = models.TextField( name = models.TextField(
blank=True,
default="N/A",
help_text="Name of the drop benefit.", help_text="Name of the drop benefit.",
default="N/A",
blank=True,
) )
image_asset_url = models.URLField( image_asset_url = models.URLField(
help_text="URL to the benefit's image asset.",
max_length=500, max_length=500,
blank=True, blank=True,
default="", default="",
help_text="URL to the benefit's image asset.",
)
image_file = models.ImageField(
upload_to="benefits/images/",
blank=True,
null=True,
width_field="image_width",
height_field="image_height",
help_text="Locally cached benefit image served from this site.",
)
image_width = models.PositiveIntegerField(
null=True,
blank=True,
editable=False,
help_text="Width of cached image in pixels.",
)
image_height = models.PositiveIntegerField(
null=True,
blank=True,
editable=False,
help_text="Height of cached image in pixels.",
)
created_at = models.DateTimeField(
null=True,
help_text=(
"Timestamp when the benefit was created. This is from Twitch API and not auto-generated."
),
)
entitlement_limit = models.PositiveIntegerField(
default=1,
help_text="Maximum number of times this benefit can be earned.",
) )
# NOTE: Default may need revisiting once requirements are confirmed. image_file = models.ImageField(
is_ios_available = models.BooleanField( help_text="Locally cached benefit image served from this site.",
default=False, upload_to="benefits/images/",
help_text="Whether the benefit is available on iOS.", height_field="image_height",
width_field="image_width",
blank=True,
null=True,
) )
image_width = models.PositiveIntegerField(
help_text="Width of cached image in pixels.",
editable=False,
blank=True,
null=True,
)
image_height = models.PositiveIntegerField(
help_text="Height of cached image in pixels.",
editable=False,
blank=True,
null=True,
)
created_at = models.DateTimeField(
help_text="Timestamp when the benefit was created. This is from Twitch API and not auto-generated.",
null=True,
)
entitlement_limit = models.PositiveIntegerField(
help_text="Maximum number of times this benefit can be earned.",
default=1,
)
is_ios_available = models.BooleanField(
help_text="Whether the benefit is available on iOS.",
default=False,
)
distribution_type = models.TextField( distribution_type = models.TextField(
help_text="Type of distribution for this benefit.",
max_length=50, max_length=50,
blank=True, blank=True,
default="", default="",
help_text="Type of distribution for this benefit.",
) )
added_at = models.DateTimeField( added_at = models.DateTimeField(
auto_now_add=True,
help_text="Timestamp when this benefit record was created.", help_text="Timestamp when this benefit record was created.",
auto_now_add=True,
) )
updated_at = models.DateTimeField( updated_at = models.DateTimeField(
auto_now=True,
help_text="Timestamp when this benefit record was last updated.", help_text="Timestamp when this benefit record was last updated.",
auto_now=True,
) )
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
@ -674,27 +729,30 @@ class DropBenefitEdge(auto_prefetch.Model):
"""Link a TimeBasedDrop to a DropBenefit.""" """Link a TimeBasedDrop to a DropBenefit."""
drop = auto_prefetch.ForeignKey( drop = auto_prefetch.ForeignKey(
help_text="The time-based drop in this relationship.",
to="twitch.TimeBasedDrop", to="twitch.TimeBasedDrop",
on_delete=models.CASCADE, on_delete=models.CASCADE,
help_text="The time-based drop in this relationship.",
) )
benefit = auto_prefetch.ForeignKey( benefit = auto_prefetch.ForeignKey(
DropBenefit,
on_delete=models.CASCADE,
help_text="The benefit in this relationship.", help_text="The benefit in this relationship.",
on_delete=models.CASCADE,
to=DropBenefit,
) )
entitlement_limit = models.PositiveIntegerField( entitlement_limit = models.PositiveIntegerField(
default=1,
help_text="Max times this benefit can be claimed for this drop.", help_text="Max times this benefit can be claimed for this drop.",
default=1,
) )
added_at = models.DateTimeField( added_at = models.DateTimeField(
auto_now_add=True,
help_text="Timestamp when this drop-benefit edge was created.", help_text="Timestamp when this drop-benefit edge was created.",
auto_now_add=True,
) )
updated_at = models.DateTimeField( updated_at = models.DateTimeField(
auto_now=True,
help_text="Timestamp when this drop-benefit edge was last updated.", help_text="Timestamp when this drop-benefit edge was last updated.",
auto_now=True,
) )
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
@ -722,52 +780,60 @@ class TimeBasedDrop(auto_prefetch.Model):
"""Represents a time-based drop in a drop campaign.""" """Represents a time-based drop in a drop campaign."""
twitch_id = models.TextField( twitch_id = models.TextField(
unique=True,
editable=False,
help_text="The Twitch ID for this time-based drop.", help_text="The Twitch ID for this time-based drop.",
) editable=False,
name = models.TextField(help_text="Name of the time-based drop.") unique=True,
required_minutes_watched = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Minutes required to watch before earning this drop.",
)
required_subs = models.PositiveIntegerField(
default=0,
help_text="Number of subscriptions required to unlock this drop.",
)
start_at = models.DateTimeField(
null=True,
blank=True,
help_text="Datetime when this drop becomes available.",
)
end_at = models.DateTimeField(
null=True,
blank=True,
help_text="Datetime when this drop expires.",
) )
# Foreign keys name = models.TextField(
campaign = auto_prefetch.ForeignKey( help_text="Name of the time-based drop.",
DropCampaign,
on_delete=models.CASCADE,
related_name="time_based_drops",
help_text="The campaign this drop belongs to.",
) )
required_minutes_watched = models.PositiveIntegerField(
help_text="Minutes required to watch before earning this drop.",
blank=True,
null=True,
)
required_subs = models.PositiveIntegerField(
help_text="Number of subscriptions required to unlock this drop.",
default=0,
)
start_at = models.DateTimeField(
help_text="Datetime when this drop becomes available.",
blank=True,
null=True,
)
end_at = models.DateTimeField(
help_text="Datetime when this drop expires.",
blank=True,
null=True,
)
campaign = auto_prefetch.ForeignKey(
help_text="The campaign this drop belongs to.",
related_name="time_based_drops",
on_delete=models.CASCADE,
to=DropCampaign,
)
benefits = models.ManyToManyField( benefits = models.ManyToManyField(
DropBenefit, help_text="Benefits unlocked by this drop.",
through=DropBenefitEdge, through=DropBenefitEdge,
related_name="drops", related_name="drops",
help_text="Benefits unlocked by this drop.", to=DropBenefit,
) )
added_at = models.DateTimeField( added_at = models.DateTimeField(
auto_now_add=True,
help_text="Timestamp when this time-based drop record was created.", help_text="Timestamp when this time-based drop record was created.",
auto_now_add=True,
) )
updated_at = models.DateTimeField( updated_at = models.DateTimeField(
help_text="Timestamp when this time-based drop record was last updated.",
auto_now=True, auto_now=True,
help_text=("Timestamp when this time-based drop record was last updated."),
) )
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
@ -798,104 +864,123 @@ class RewardCampaign(auto_prefetch.Model):
"""Represents a Twitch reward campaign (Quest rewards).""" """Represents a Twitch reward campaign (Quest rewards)."""
twitch_id = models.TextField( twitch_id = models.TextField(
unique=True,
editable=False,
help_text="The Twitch ID for this reward campaign.", help_text="The Twitch ID for this reward campaign.",
editable=False,
unique=True,
) )
name = models.TextField(help_text="Name of the reward campaign.")
name = models.TextField(
help_text="Name of the reward campaign.",
)
brand = models.TextField( brand = models.TextField(
help_text="Brand associated with the reward campaign.",
blank=True, blank=True,
default="", default="",
help_text="Brand associated with the reward campaign.",
) )
starts_at = models.DateTimeField( starts_at = models.DateTimeField(
null=True,
blank=True,
help_text="Datetime when the reward campaign starts.", help_text="Datetime when the reward campaign starts.",
)
ends_at = models.DateTimeField(
null=True, null=True,
blank=True, blank=True,
help_text="Datetime when the reward campaign ends.",
) )
ends_at = models.DateTimeField(
help_text="Datetime when the reward campaign ends.",
null=True,
blank=True,
)
status = models.TextField( status = models.TextField(
max_length=50, max_length=50,
default="UNKNOWN",
help_text="Status of the reward campaign.", help_text="Status of the reward campaign.",
default="UNKNOWN",
) )
summary = models.TextField( summary = models.TextField(
blank=True,
default="",
help_text="Summary description of the reward campaign.", help_text="Summary description of the reward campaign.",
)
instructions = models.TextField(
blank=True, blank=True,
default="", default="",
help_text="Instructions for the reward campaign.",
) )
instructions = models.TextField(
help_text="Instructions for the reward campaign.",
blank=True,
default="",
)
external_url = models.URLField( external_url = models.URLField(
max_length=500, max_length=500,
blank=True,
default="",
help_text="External URL for the reward campaign.", help_text="External URL for the reward campaign.",
)
reward_value_url_param = models.TextField(
blank=True, blank=True,
default="", default="",
help_text="URL parameter for reward value.",
) )
reward_value_url_param = models.TextField(
help_text="URL parameter for reward value.",
blank=True,
default="",
)
about_url = models.URLField( about_url = models.URLField(
max_length=500, max_length=500,
help_text="About URL for the reward campaign.",
blank=True, blank=True,
default="", default="",
help_text="About URL for the reward campaign.",
) )
image_url = models.URLField( image_url = models.URLField(
max_length=500, max_length=500,
help_text="URL to an image representing the reward campaign.",
blank=True, blank=True,
default="", default="",
help_text="URL to an image representing the reward campaign.",
) )
image_file = models.ImageField( image_file = models.ImageField(
help_text="Locally cached reward campaign image served from this site.",
upload_to="reward_campaigns/images/", upload_to="reward_campaigns/images/",
blank=True,
null=True,
width_field="image_width", width_field="image_width",
height_field="image_height", height_field="image_height",
help_text="Locally cached reward campaign image served from this site.", blank=True,
null=True,
) )
image_width = models.PositiveIntegerField( image_width = models.PositiveIntegerField(
null=True,
blank=True,
editable=False,
help_text="Width of cached image in pixels.", help_text="Width of cached image in pixels.",
)
image_height = models.PositiveIntegerField(
null=True,
blank=True,
editable=False, editable=False,
help_text="Height of cached image in pixels.",
)
is_sitewide = models.BooleanField(
default=False,
help_text="Whether the reward campaign is sitewide.",
)
game = auto_prefetch.ForeignKey(
Game,
on_delete=models.SET_NULL,
null=True,
blank=True, blank=True,
related_name="reward_campaigns", null=True,
)
image_height = models.PositiveIntegerField(
help_text="Height of cached image in pixels.",
editable=False,
blank=True,
null=True,
)
is_sitewide = models.BooleanField(
help_text="Whether the reward campaign is sitewide.",
default=False,
)
game = auto_prefetch.ForeignKey(
help_text="Game associated with this reward campaign (if any).", help_text="Game associated with this reward campaign (if any).",
related_name="reward_campaigns",
on_delete=models.SET_NULL,
blank=True,
null=True,
to=Game,
) )
added_at = models.DateTimeField( added_at = models.DateTimeField(
auto_now_add=True,
help_text="Timestamp when this reward campaign record was created.", help_text="Timestamp when this reward campaign record was created.",
auto_now_add=True,
) )
updated_at = models.DateTimeField( updated_at = models.DateTimeField(
auto_now=True,
help_text="Timestamp when this reward campaign record was last updated.", help_text="Timestamp when this reward campaign record was last updated.",
auto_now=True,
) )
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
@ -944,22 +1029,23 @@ class ChatBadgeSet(auto_prefetch.Model):
"""Represents a set of Twitch global chat badges (e.g., VIP, Subscriber, Bits).""" """Represents a set of Twitch global chat badges (e.g., VIP, Subscriber, Bits)."""
set_id = models.TextField( set_id = models.TextField(
unique=True,
verbose_name="Set ID",
help_text="Identifier for this badge set (e.g., 'vip', 'subscriber', 'bits').", help_text="Identifier for this badge set (e.g., 'vip', 'subscriber', 'bits').",
verbose_name="Set ID",
unique=True,
) )
added_at = models.DateTimeField( added_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Added At",
editable=False,
help_text="Timestamp when this badge set record was created.", help_text="Timestamp when this badge set record was created.",
verbose_name="Added At",
auto_now_add=True,
editable=False,
) )
updated_at = models.DateTimeField( updated_at = models.DateTimeField(
auto_now=True, help_text="Timestamp when this badge set record was last updated.",
verbose_name="Updated At", verbose_name="Updated At",
editable=False, editable=False,
help_text="Timestamp when this badge set record was last updated.", auto_now=True,
) )
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
@ -980,64 +1066,73 @@ class ChatBadge(auto_prefetch.Model):
"""Represents a specific version of a Twitch global chat badge.""" """Represents a specific version of a Twitch global chat badge."""
badge_set = auto_prefetch.ForeignKey( badge_set = auto_prefetch.ForeignKey(
ChatBadgeSet,
on_delete=models.CASCADE,
related_name="badges",
verbose_name="Badge Set",
help_text="The badge set this badge belongs to.", help_text="The badge set this badge belongs to.",
on_delete=models.CASCADE,
verbose_name="Badge Set",
related_name="badges",
to=ChatBadgeSet,
) )
badge_id = models.TextField( badge_id = models.TextField(
verbose_name="Badge ID",
help_text="Version identifier for this badge (e.g., '1', 'Alliance', '10000').", help_text="Version identifier for this badge (e.g., '1', 'Alliance', '10000').",
verbose_name="Badge ID",
) )
image_url_1x = models.URLField( 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.", help_text="URL to the small version (18px x 18px) of the badge.",
verbose_name="Image URL (18px)",
max_length=500,
) )
image_url_2x = models.URLField( 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.", help_text="URL to the medium version (36px x 36px) of the badge.",
verbose_name="Image URL (36px)",
max_length=500,
) )
image_url_4x = models.URLField( 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.", help_text="URL to the large version (72px x 72px) of the badge.",
verbose_name="Image URL (72px)",
max_length=500,
) )
title = models.TextField( title = models.TextField(
verbose_name="Title",
help_text="The title of the badge (e.g., 'VIP').", help_text="The title of the badge (e.g., 'VIP').",
verbose_name="Title",
) )
description = models.TextField( description = models.TextField(
verbose_name="Description",
help_text="The description of the badge.", help_text="The description of the badge.",
verbose_name="Description",
) )
click_action = models.TextField( # noqa: DJ001 click_action = models.TextField( # noqa: DJ001
help_text="The action to take when clicking on the badge (e.g., 'visit_url').",
verbose_name="Click Action",
blank=True, blank=True,
null=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 click_url = models.URLField( # noqa: DJ001
help_text="The URL to navigate to when clicking on the badge.",
verbose_name="Click URL",
max_length=500, max_length=500,
blank=True, blank=True,
null=True, null=True,
verbose_name="Click URL",
help_text="The URL to navigate to when clicking on the badge.",
) )
added_at = models.DateTimeField( added_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Added At",
editable=False,
help_text="Timestamp when this badge record was created.", help_text="Timestamp when this badge record was created.",
verbose_name="Added At",
auto_now_add=True,
editable=False,
) )
updated_at = models.DateTimeField( updated_at = models.DateTimeField(
auto_now=True, help_text="Timestamp when this badge record was last updated.",
verbose_name="Updated At", verbose_name="Updated At",
editable=False, editable=False,
help_text="Timestamp when this badge record was last updated.", auto_now=True,
) )
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):

View file

@ -1342,7 +1342,8 @@ class TestSitemapView:
name="ch1", name="ch1",
display_name="Channel 1", display_name="Channel 1",
) )
now: datetime = timezone.now()
now: datetime.datetime = timezone.now()
campaign: DropCampaign = DropCampaign.objects.create( campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="camp1", twitch_id="camp1",
name="Test Campaign", name="Test Campaign",