From d99579ed2bec4cc7470dbc55bf6ca7c3dd0fdf6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Helle=C5=9Ben?= Date: Sat, 21 Mar 2026 19:12:47 +0100 Subject: [PATCH] Add Redis configuration, integrate Celery, and sort fields in models --- .env.example | 4 + .github/copilot-instructions.md | 3 +- README.md | 13 + config/__init__.py | 5 + config/celery.py | 15 + config/settings.py | 26 + config/tests/test_celery.py | 41 ++ config/tests/test_urls.py | 45 ++ core/admin.py | 1 - core/models.py | 1 - pyproject.toml | 7 + templates/base.html | 57 -- .../commands/download_campaign_images.py | 12 +- twitch/models.py | 629 ++++++++++-------- twitch/tests/test_views.py | 3 +- 15 files changed, 530 insertions(+), 332 deletions(-) create mode 100644 config/celery.py create mode 100644 config/tests/test_celery.py delete mode 100644 core/admin.py delete mode 100644 core/models.py diff --git a/.env.example b/.env.example index 0e6117f..e1a36f1 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,7 @@ EMAIL_TIMEOUT=10 # Where to store Twitch API responses TTVDROPS_IMPORTED_DIR=/mnt/fourteen/Data/Responses/imported 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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5b441f8..182ab46 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -64,11 +64,12 @@ - Use settings modules and environment variables to configure behavior, not hardcoded constants ## 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/` - Management commands in `twitch/management/commands/` for data import and maintenance tasks - Use `pyproject.toml` + uv for dependency and environment management - Use `uv run python manage.py ` to run Django management commands +- Use `uv run pytest` to run tests ## Documentation & Project Organization - Only create documentation files when explicitly requested by the user diff --git a/README.md b/README.md index 6622af1..2bf1fbe 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,19 @@ uv run python manage.py runserver 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 ```bash diff --git a/config/__init__.py b/config/__init__.py index e69de29..5568b6d 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -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",) diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..68e4383 --- /dev/null +++ b/config/celery.py @@ -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() diff --git a/config/settings.py b/config/settings.py index da3fb63..0e7026f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -135,14 +135,19 @@ LOGGING: dict[str, Any] = { } INSTALLED_APPS: list[str] = [ + # Django built-in apps "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.staticfiles", "django.contrib.postgres", + # Internal apps "twitch.apps.TwitchConfig", "kick.apps.KickConfig", "youtube.apps.YoutubeConfig", "core.apps.CoreConfig", + # Third-party apps + "django_celery_results", + "django_celery_beat", ] MIDDLEWARE: list[str] = [ @@ -202,3 +207,24 @@ if not TESTING: profile_session_sample_rate=1.0, 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" diff --git a/config/tests/test_celery.py b/config/tests/test_celery.py new file mode 100644 index 0000000..edb0a3d --- /dev/null +++ b/config/tests/test_celery.py @@ -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() diff --git a/config/tests/test_urls.py b/config/tests/test_urls.py index 5e6a482..e51c595 100644 --- a/config/tests/test_urls.py +++ b/config/tests/test_urls.py @@ -1,9 +1,14 @@ import importlib from typing import TYPE_CHECKING +import pytest +from django.conf import settings from django.test.utils import override_settings +from django.urls import resolve from django.urls import reverse +from config.urls import urlpatterns + if TYPE_CHECKING: from collections.abc import Iterable from types import ModuleType @@ -50,3 +55,43 @@ def test_debug_tools_not_present_while_testing() -> None: patterns = list(_pattern_strings(mod)) assert not any("silk" 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) diff --git a/core/admin.py b/core/admin.py deleted file mode 100644 index 846f6b4..0000000 --- a/core/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/core/models.py b/core/models.py deleted file mode 100644 index 6b20219..0000000 --- a/core/models.py +++ /dev/null @@ -1 +0,0 @@ -# Create your models here. diff --git a/pyproject.toml b/pyproject.toml index 5bd788d..8a42e43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,15 @@ dependencies = [ "tqdm", "index-now-for-python", "sitemap-parser", + "celery[redis]", + "django-celery-results", + "django-celery-beat", + "flower", + "hiredis", + "redis", ] + [dependency-groups] dev = [ "django-stubs", diff --git a/templates/base.html b/templates/base.html index d274505..79611af 100644 --- a/templates/base.html +++ b/templates/base.html @@ -119,7 +119,6 @@ type="application/atom+xml" title="All Kick organizations (Discord)" href="{% url 'kick:organization_feed_discord' %}" /> - {# Allow child templates to inject page-specific alternates into the head #} {% block extra_head %} {% endblock extra_head %} diff --git a/twitch/management/commands/download_campaign_images.py b/twitch/management/commands/download_campaign_images.py index 8e13f4e..242d651 100644 --- a/twitch/management/commands/download_campaign_images.py +++ b/twitch/management/commands/download_campaign_images.py @@ -345,8 +345,12 @@ class Command(BaseCommand): if img.mode in {"RGBA", "LA"} or ( img.mode == "P" and "transparency" in img.info ): - background: Image = Image.new("RGB", img.size, (255, 255, 255)) - rgba_img: Image | ImageFile = ( + background: Image.Image = Image.new( + "RGB", + img.size, + (255, 255, 255), + ) + rgba_img: Image.Image | ImageFile = ( img.convert("RGBA") if img.mode == "P" else img ) background.paste( @@ -357,9 +361,9 @@ class Command(BaseCommand): ) rgb_img = background elif img.mode != "RGB": - rgb_img: Image = img.convert("RGB") + rgb_img: Image.Image = img.convert("RGB") else: - rgb_img: ImageFile = img + rgb_img: Image.Image = img # Save WebP rgb_img.save(webp_path, "WEBP", quality=85, method=6) diff --git a/twitch/models.py b/twitch/models.py index c148423..6e85870 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -22,28 +22,30 @@ class Organization(auto_prefetch.Model): """Represents an organization on Twitch that can own drop campaigns.""" twitch_id = models.TextField( - unique=True, + help_text="The unique Twitch identifier for the organization.", verbose_name="Organization ID", editable=False, - help_text="The unique Twitch identifier for the organization.", - ) - name = models.TextField( unique=True, - verbose_name="Name", + ) + + name = models.TextField( help_text="Display name of the organization.", + verbose_name="Name", + unique=True, ) added_at = models.DateTimeField( - auto_now_add=True, - verbose_name="Added At", - editable=False, help_text="Timestamp when this organization record was created.", + verbose_name="Added At", + auto_now_add=True, + editable=False, ) + updated_at = models.DateTimeField( - auto_now=True, + help_text="Timestamp when this organization record was last updated.", verbose_name="Updated At", editable=False, - help_text="Timestamp when this organization record was last updated.", + auto_now=True, ) 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) slug = models.TextField( + help_text="Short unique identifier for the game.", + verbose_name="Slug", max_length=200, blank=True, 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 + verbose_name="Box art URL", max_length=500, blank=True, - null=True, default="", - verbose_name="Box art URL", + null=True, ) 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/", blank=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( - null=True, - blank=True, - editable=False, help_text="Width of cached box art image in pixels.", + editable=False, + blank=True, + null=True, ) + box_art_height = models.PositiveIntegerField( - null=True, - blank=True, - editable=False, help_text="Height of cached box art image in pixels.", - ) - box_art_size_bytes = models.PositiveIntegerField( - null=True, - blank=True, 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( + help_text="MIME type of the cached box art image (e.g., 'image/png').", + editable=False, max_length=50, blank=True, default="", - editable=False, - help_text="MIME type of the cached box art image (e.g., 'image/png').", ) owners = models.ManyToManyField( - Organization, - related_name="games", - blank=True, - verbose_name="Organizations", help_text="Organizations that own this game.", + verbose_name="Organizations", + related_name="games", + to=Organization, + blank=True, ) added_at = models.DateTimeField( - auto_now_add=True, help_text="Timestamp when this game record was created.", + auto_now_add=True, ) + updated_at = models.DateTimeField( - auto_now=True, help_text="Timestamp when this game record was last updated.", + auto_now=True, ) class Meta(auto_prefetch.Model.Meta): @@ -229,39 +247,49 @@ class TwitchGameData(auto_prefetch.Model): """ twitch_id = models.TextField( + help_text="The Twitch ID for this game.", verbose_name="Twitch Game ID", 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") - box_art_url = models.URLField( - max_length=500, + game = auto_prefetch.ForeignKey( + help_text="Optional link to the local Game record for this Twitch game.", + 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, 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( - auto_now_add=True, help_text="Record creation time.", + auto_now_add=True, ) + updated_at = models.DateTimeField( - auto_now=True, help_text="Record last update time.", + auto_now=True, ) 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.""" twitch_id = models.TextField( - verbose_name="Channel ID", help_text="The unique Twitch identifier for the channel.", + verbose_name="Channel ID", unique=True, ) + name = models.TextField( - verbose_name="Username", help_text="The lowercase username of the channel.", + verbose_name="Username", ) + display_name = models.TextField( - verbose_name="Display Name", help_text="The display name of the channel (with proper capitalization).", + verbose_name="Display Name", ) added_at = models.DateTimeField( - auto_now_add=True, help_text="Timestamp when this channel record was created.", + auto_now_add=True, ) + updated_at = models.DateTimeField( - auto_now=True, help_text="Timestamp when this channel record was last updated.", + auto_now=True, ) class Meta(auto_prefetch.Model.Meta): @@ -326,112 +357,130 @@ class DropCampaign(auto_prefetch.Model): """Represents a Twitch drop campaign.""" twitch_id = models.TextField( - unique=True, - editable=False, 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( - blank=True, help_text="Detailed description of the campaign.", + blank=True, ) + details_url = models.URLField( - max_length=500, - blank=True, - default="", help_text="URL with campaign details.", + max_length=500, + blank=True, + default="", ) + account_link_url = models.URLField( - max_length=500, - blank=True, - default="", help_text="URL to link a Twitch account for the campaign.", - ) - image_url = models.URLField( max_length=500, blank=True, default="", + ) + + image_url = models.URLField( help_text="URL to an image representing the campaign.", + max_length=500, + blank=True, + default="", ) + 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.", + upload_to="campaigns/images/", + height_field="image_height", + width_field="image_width", + blank=True, + null=True, ) + image_width = models.PositiveIntegerField( - null=True, - blank=True, - editable=False, help_text="Width of cached image in pixels.", + editable=False, + blank=True, + null=True, ) + image_height = models.PositiveIntegerField( - null=True, - blank=True, - editable=False, help_text="Height of cached image in pixels.", - ) - image_size_bytes = models.PositiveIntegerField( - null=True, - blank=True, 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( + help_text="MIME type of the cached campaign image (e.g., 'image/png').", + editable=False, max_length=50, blank=True, default="", - editable=False, - help_text="MIME type of the cached campaign image (e.g., 'image/png').", ) + start_at = models.DateTimeField( - null=True, - blank=True, help_text="Datetime when the campaign starts.", - ) - end_at = models.DateTimeField( + blank=True, null=True, - blank=True, + ) + + end_at = models.DateTimeField( 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, - 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.", + related_name="allowed_campaigns", + to=Channel, + blank=True, ) game = auto_prefetch.ForeignKey( - Game, - on_delete=models.CASCADE, - related_name="drop_campaigns", - verbose_name="Game", help_text="Game associated with this campaign.", + related_name="drop_campaigns", + on_delete=models.CASCADE, + verbose_name="Game", + to=Game, ) operation_names = models.JSONField( + help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).", default=list, blank=True, - help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).", ) added_at = models.DateTimeField( - auto_now_add=True, help_text="Timestamp when this campaign record was created.", + auto_now_add=True, ) + updated_at = models.DateTimeField( - auto_now=True, help_text="Timestamp when this campaign record was last updated.", + auto_now=True, ) + is_fully_imported = models.BooleanField( - default=False, help_text="True if all images and formats are imported and ready for display.", + default=False, ) 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.""" twitch_id = models.TextField( - unique=True, help_text="The Twitch ID for this benefit.", editable=False, + unique=True, ) + name = models.TextField( - blank=True, - default="N/A", help_text="Name of the drop benefit.", + default="N/A", + blank=True, ) + image_asset_url = models.URLField( + help_text="URL to the benefit's image asset.", max_length=500, blank=True, 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. - is_ios_available = models.BooleanField( - default=False, - help_text="Whether the benefit is available on iOS.", + image_file = models.ImageField( + help_text="Locally cached benefit image served from this site.", + upload_to="benefits/images/", + 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( + help_text="Type of distribution for this benefit.", max_length=50, blank=True, default="", - help_text="Type of distribution for this benefit.", ) added_at = models.DateTimeField( - auto_now_add=True, help_text="Timestamp when this benefit record was created.", + auto_now_add=True, ) + updated_at = models.DateTimeField( - auto_now=True, help_text="Timestamp when this benefit record was last updated.", + auto_now=True, ) class Meta(auto_prefetch.Model.Meta): @@ -674,27 +729,30 @@ class DropBenefitEdge(auto_prefetch.Model): """Link a TimeBasedDrop to a DropBenefit.""" drop = auto_prefetch.ForeignKey( + help_text="The time-based drop in this relationship.", to="twitch.TimeBasedDrop", on_delete=models.CASCADE, - help_text="The time-based drop in this relationship.", ) + benefit = auto_prefetch.ForeignKey( - DropBenefit, - on_delete=models.CASCADE, help_text="The benefit in this relationship.", + on_delete=models.CASCADE, + to=DropBenefit, ) + entitlement_limit = models.PositiveIntegerField( - default=1, help_text="Max times this benefit can be claimed for this drop.", + default=1, ) added_at = models.DateTimeField( - auto_now_add=True, help_text="Timestamp when this drop-benefit edge was created.", + auto_now_add=True, ) + updated_at = models.DateTimeField( - auto_now=True, help_text="Timestamp when this drop-benefit edge was last updated.", + auto_now=True, ) class Meta(auto_prefetch.Model.Meta): @@ -722,52 +780,60 @@ class TimeBasedDrop(auto_prefetch.Model): """Represents a time-based drop in a drop campaign.""" twitch_id = models.TextField( - unique=True, - editable=False, help_text="The Twitch ID for this time-based drop.", - ) - name = models.TextField(help_text="Name of the time-based drop.") - 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.", + editable=False, + unique=True, ) - # Foreign keys - campaign = auto_prefetch.ForeignKey( - DropCampaign, - on_delete=models.CASCADE, - related_name="time_based_drops", - help_text="The campaign this drop belongs to.", + name = models.TextField( + help_text="Name of the time-based drop.", ) + + 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( - DropBenefit, + help_text="Benefits unlocked by this drop.", through=DropBenefitEdge, related_name="drops", - help_text="Benefits unlocked by this drop.", + to=DropBenefit, ) added_at = models.DateTimeField( - auto_now_add=True, help_text="Timestamp when this time-based drop record was created.", + auto_now_add=True, ) + updated_at = models.DateTimeField( + help_text="Timestamp when this time-based drop record was last updated.", auto_now=True, - help_text=("Timestamp when this time-based drop record was last updated."), ) class Meta(auto_prefetch.Model.Meta): @@ -798,104 +864,123 @@ class RewardCampaign(auto_prefetch.Model): """Represents a Twitch reward campaign (Quest rewards).""" twitch_id = models.TextField( - unique=True, - editable=False, 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( + help_text="Brand associated with the reward campaign.", blank=True, default="", - help_text="Brand associated with the reward campaign.", ) + starts_at = models.DateTimeField( - null=True, - blank=True, help_text="Datetime when the reward campaign starts.", - ) - ends_at = models.DateTimeField( null=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( max_length=50, - default="UNKNOWN", help_text="Status of the reward campaign.", + default="UNKNOWN", ) + summary = models.TextField( - blank=True, - default="", help_text="Summary description of the reward campaign.", - ) - instructions = models.TextField( blank=True, 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( max_length=500, - blank=True, - default="", help_text="External URL for the reward campaign.", - ) - reward_value_url_param = models.TextField( blank=True, 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( max_length=500, + help_text="About URL for the reward campaign.", blank=True, default="", - help_text="About URL for the reward campaign.", ) + image_url = models.URLField( max_length=500, + help_text="URL to an image representing the reward campaign.", blank=True, default="", - help_text="URL to an image representing the reward campaign.", ) + image_file = models.ImageField( + help_text="Locally cached reward campaign image served from this site.", upload_to="reward_campaigns/images/", - blank=True, - null=True, width_field="image_width", height_field="image_height", - help_text="Locally cached reward campaign image served from this site.", + blank=True, + null=True, ) + 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.", - ) - 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, - 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).", + related_name="reward_campaigns", + on_delete=models.SET_NULL, + blank=True, + null=True, + to=Game, ) added_at = models.DateTimeField( - auto_now_add=True, help_text="Timestamp when this reward campaign record was created.", + auto_now_add=True, ) + updated_at = models.DateTimeField( - auto_now=True, help_text="Timestamp when this reward campaign record was last updated.", + auto_now=True, ) 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).""" set_id = models.TextField( - unique=True, - verbose_name="Set ID", help_text="Identifier for this badge set (e.g., 'vip', 'subscriber', 'bits').", + verbose_name="Set ID", + unique=True, ) added_at = models.DateTimeField( - auto_now_add=True, - verbose_name="Added At", - editable=False, help_text="Timestamp when this badge set record was created.", + verbose_name="Added At", + auto_now_add=True, + editable=False, ) + updated_at = models.DateTimeField( - auto_now=True, + help_text="Timestamp when this badge set record was last updated.", verbose_name="Updated At", editable=False, - help_text="Timestamp when this badge set record was last updated.", + auto_now=True, ) 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.""" 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.", + on_delete=models.CASCADE, + verbose_name="Badge Set", + related_name="badges", + to=ChatBadgeSet, ) + badge_id = models.TextField( - verbose_name="Badge ID", help_text="Version identifier for this badge (e.g., '1', 'Alliance', '10000').", + verbose_name="Badge ID", ) + 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.", + verbose_name="Image URL (18px)", + max_length=500, ) + 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.", + verbose_name="Image URL (36px)", + max_length=500, ) + 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.", + verbose_name="Image URL (72px)", + max_length=500, ) + title = models.TextField( - verbose_name="Title", help_text="The title of the badge (e.g., 'VIP').", + verbose_name="Title", ) + description = models.TextField( - verbose_name="Description", help_text="The description of the badge.", + verbose_name="Description", ) + 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, 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 + help_text="The URL to navigate to when clicking on the badge.", + verbose_name="Click URL", 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.", + verbose_name="Added At", + auto_now_add=True, + editable=False, ) + updated_at = models.DateTimeField( - auto_now=True, + help_text="Timestamp when this badge record was last updated.", verbose_name="Updated At", editable=False, - help_text="Timestamp when this badge record was last updated.", + auto_now=True, ) class Meta(auto_prefetch.Model.Meta): diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 159a8a4..6186662 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -1342,7 +1342,8 @@ class TestSitemapView: name="ch1", display_name="Channel 1", ) - now: datetime = timezone.now() + + now: datetime.datetime = timezone.now() campaign: DropCampaign = DropCampaign.objects.create( twitch_id="camp1", name="Test Campaign",