From dcc4cecb8da559fedb4af94a5bb4949bd8a7450f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Helle=C5=9Ben?= Date: Mon, 9 Mar 2026 04:36:56 +0100 Subject: [PATCH 1/3] Update pre-commit hook versions for ruff and actionlint --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 14671bd..5d7ad77 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.0 + rev: v0.15.5 hooks: - id: ruff-check args: ["--fix", "--exit-non-zero-on-fix"] @@ -34,6 +34,6 @@ repos: args: ["--py311-plus"] - repo: https://github.com/rhysd/actionlint - rev: v1.7.10 + rev: v1.7.11 hooks: - id: actionlint From 1118c03c1b21e217bb66ee2811c423fe3624d546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Helle=C5=9Ben?= Date: Mon, 9 Mar 2026 04:37:54 +0100 Subject: [PATCH 2/3] Lower line-length to default and don't add from __future__ import annotations to everything --- config/settings.py | 50 +- config/tests/test_settings.py | 32 +- config/tests/test_urls.py | 2 - config/urls.py | 7 +- config/wsgi.py | 2 - manage.py | 2 - pyproject.toml | 20 +- twitch/apps.py | 2 - twitch/feeds.py | 200 ++++-- .../commands/backfill_image_dimensions.py | 2 - twitch/management/commands/backup_db.py | 30 +- .../commands/better_import_drops.py | 340 +++++++--- .../commands/cleanup_orphaned_channels.py | 26 +- .../commands/cleanup_unknown_organizations.py | 18 +- .../convert_images_to_modern_formats.py | 35 +- .../management/commands/download_box_art.py | 40 +- .../commands/download_campaign_images.py | 116 +++- .../management/commands/import_chat_badges.py | 23 +- twitch/migrations/0001_initial.py | 503 ++++++++++++--- twitch/migrations/0002_alter_game_box_art.py | 14 +- ...twitch_drop_is_acco_7e9078_idx_and_more.py | 11 +- ...twitch_game_owner_i_398fa9_idx_and_more.py | 7 +- twitch/migrations/0005_add_reward_campaign.py | 115 +++- twitch/migrations/0006_add_chat_badges.py | 81 ++- ...ename_operation_name_to_operation_names.py | 18 +- ...ptions_alter_chatbadge_options_and_more.py | 38 +- ...0009_alter_chatbadge_badge_set_and_more.py | 4 +- ...ign_image_file_rewardcampaign_image_url.py | 8 +- ...height_dropbenefit_image_width_and_more.py | 4 +- ..._dropcampaign_operation_names_gin_index.py | 7 +- twitch/models.py | 77 +-- twitch/schemas.py | 75 ++- twitch/templatetags/image_tags.py | 16 +- twitch/tests/test_backup.py | 45 +- twitch/tests/test_badge_views.py | 18 +- twitch/tests/test_better_import_drops.py | 198 +++--- twitch/tests/test_chat_badges.py | 2 - twitch/tests/test_exports.py | 2 - twitch/tests/test_feeds.py | 70 +- twitch/tests/test_game_owner_organization.py | 23 +- twitch/tests/test_image_tags.py | 31 +- twitch/tests/test_schemas.py | 77 ++- twitch/tests/test_views.py | 345 +++++++--- twitch/urls.py | 68 +- twitch/utils.py | 17 +- twitch/views.py | 604 +++++++++++------- 46 files changed, 2339 insertions(+), 1086 deletions(-) diff --git a/config/settings.py b/config/settings.py index 6976d78..b112427 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging import os import sys @@ -39,7 +37,11 @@ def env_int(key: str, default: int) -> int: DEBUG: bool = env_bool(key="DEBUG", default=True) -TESTING: bool = env_bool(key="TESTING", default=False) or "test" in sys.argv or "PYTEST_VERSION" in os.environ +TESTING: bool = ( + env_bool(key="TESTING", default=False) + or "test" in sys.argv + or "PYTEST_VERSION" in os.environ +) def get_data_dir() -> Path: @@ -118,28 +120,11 @@ if not DEBUG: LOGGING: dict[str, Any] = { "version": 1, "disable_existing_loggers": False, - "handlers": { - "console": { - "level": "DEBUG", - "class": "logging.StreamHandler", - }, - }, + "handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler"}}, "loggers": { - "": { - "handlers": ["console"], - "level": "INFO", - "propagate": True, - }, - "ttvdrops": { - "handlers": ["console"], - "level": "DEBUG", - "propagate": False, - }, - "django": { - "handlers": ["console"], - "level": "INFO", - "propagate": False, - }, + "": {"handlers": ["console"], "level": "INFO", "propagate": True}, + "ttvdrops": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, + "django": {"handlers": ["console"], "level": "INFO", "propagate": False}, "django.utils.autoreload": { "handlers": ["console"], "level": "INFO", @@ -179,12 +164,7 @@ TEMPLATES: list[dict[str, Any]] = [ ] DATABASES: dict[str, dict[str, Any]] = ( - { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": ":memory:", - }, - } + {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} if TESTING else { "default": { @@ -196,19 +176,13 @@ DATABASES: dict[str, dict[str, Any]] = ( "PORT": env_int("POSTGRES_PORT", 5432), "CONN_MAX_AGE": env_int("CONN_MAX_AGE", 60), "CONN_HEALTH_CHECKS": env_bool("CONN_HEALTH_CHECKS", default=True), - "OPTIONS": { - "connect_timeout": env_int("DB_CONNECT_TIMEOUT", 10), - }, + "OPTIONS": {"connect_timeout": env_int("DB_CONNECT_TIMEOUT", 10)}, }, } ) if not TESTING: - INSTALLED_APPS = [ - *INSTALLED_APPS, - "debug_toolbar", - "silk", - ] + INSTALLED_APPS = [*INSTALLED_APPS, "debug_toolbar", "silk"] MIDDLEWARE = [ "debug_toolbar.middleware.DebugToolbarMiddleware", "silk.middleware.SilkyMiddleware", diff --git a/config/tests/test_settings.py b/config/tests/test_settings.py index e1ed291..298dd35 100644 --- a/config/tests/test_settings.py +++ b/config/tests/test_settings.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import importlib import os import sys @@ -42,7 +40,10 @@ def reload_settings_module() -> Generator[Callable[..., ModuleType]]: def _reload(**env_overrides: str | None) -> ModuleType: env: dict[str, str] = os.environ.copy() - env.setdefault("DJANGO_SECRET_KEY", original_env.get("DJANGO_SECRET_KEY", "test-secret-key")) + env.setdefault( + "DJANGO_SECRET_KEY", + original_env.get("DJANGO_SECRET_KEY", "test-secret-key"), + ) for key, value in env_overrides.items(): if value is None: @@ -95,7 +96,10 @@ def test_env_int_returns_default(monkeypatch: pytest.MonkeyPatch) -> None: assert settings.env_int("MAX_COUNT", 3) == 3 -def test_get_data_dir_uses_platformdirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_get_data_dir_uses_platformdirs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: """get_data_dir should use platformdirs and create the directory.""" fake_dir: Path = tmp_path / "data_dir" @@ -112,7 +116,9 @@ def test_get_data_dir_uses_platformdirs(monkeypatch: pytest.MonkeyPatch, tmp_pat assert path.is_dir() is True -def test_allowed_hosts_when_debug_false(reload_settings_module: Callable[..., ModuleType]) -> None: +def test_allowed_hosts_when_debug_false( + reload_settings_module: Callable[..., ModuleType], +) -> None: """When DEBUG is false, ALLOWED_HOSTS should use the production host.""" reloaded: ModuleType = reload_settings_module(DEBUG="false") @@ -120,7 +126,9 @@ def test_allowed_hosts_when_debug_false(reload_settings_module: Callable[..., Mo assert reloaded.ALLOWED_HOSTS == ["ttvdrops.lovinator.space"] -def test_allowed_hosts_when_debug_true(reload_settings_module: Callable[..., ModuleType]) -> None: +def test_allowed_hosts_when_debug_true( + reload_settings_module: Callable[..., ModuleType], +) -> None: """When DEBUG is true, development hostnames should be allowed.""" reloaded: ModuleType = reload_settings_module(DEBUG="1") @@ -128,7 +136,9 @@ def test_allowed_hosts_when_debug_true(reload_settings_module: Callable[..., Mod assert reloaded.ALLOWED_HOSTS == [".localhost", "127.0.0.1", "[::1]", "testserver"] -def test_debug_defaults_true_when_missing(reload_settings_module: Callable[..., ModuleType]) -> None: +def test_debug_defaults_true_when_missing( + reload_settings_module: Callable[..., ModuleType], +) -> None: """DEBUG should default to True when the environment variable is missing.""" reloaded: ModuleType = reload_settings_module(DEBUG=None) @@ -172,7 +182,9 @@ def test_testing_true_when_sys_argv_contains_test( assert reloaded.TESTING is True -def test_testing_true_when_pytest_version_set(reload_settings_module: Callable[..., ModuleType]) -> None: +def test_testing_true_when_pytest_version_set( + reload_settings_module: Callable[..., ModuleType], +) -> None: """TESTING should be true when PYTEST_VERSION is set in the env.""" reloaded: ModuleType = reload_settings_module(PYTEST_VERSION="7.0.0") @@ -212,7 +224,9 @@ def test_missing_secret_key_causes_system_exit(monkeypatch: pytest.MonkeyPatch) __import__("config.settings") -def test_email_settings_from_env(reload_settings_module: Callable[..., ModuleType]) -> None: +def test_email_settings_from_env( + reload_settings_module: Callable[..., ModuleType], +) -> None: """EMAIL_* values should be read from the environment and cast correctly.""" reloaded: ModuleType = reload_settings_module( EMAIL_HOST="smtp.example.com", diff --git a/config/tests/test_urls.py b/config/tests/test_urls.py index dca6fc9..83738d1 100644 --- a/config/tests/test_urls.py +++ b/config/tests/test_urls.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import importlib from typing import TYPE_CHECKING diff --git a/config/urls.py b/config/urls.py index 49724ee..2c031ea 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import TYPE_CHECKING from django.conf import settings @@ -21,10 +19,7 @@ urlpatterns: list[URLPattern | URLResolver] = [ # Serve media in development if settings.DEBUG: - urlpatterns += static( - settings.MEDIA_URL, - document_root=settings.MEDIA_ROOT, - ) + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if not settings.TESTING: from debug_toolbar.toolbar import debug_toolbar_urls diff --git a/config/wsgi.py b/config/wsgi.py index 05ced01..e61f3f2 100644 --- a/config/wsgi.py +++ b/config/wsgi.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os from typing import TYPE_CHECKING diff --git a/manage.py b/manage.py index c709739..1077d0a 100755 --- a/manage.py +++ b/manage.py @@ -1,8 +1,6 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" -from __future__ import annotations - import os import sys diff --git a/pyproject.toml b/pyproject.toml index 87d0b4d..5a17154 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,15 +45,22 @@ filterwarnings = [ ] [tool.ruff] +fix = true +preview = true +unsafe-fixes = true + +format.docstring-code-format = true +format.preview = true + +lint.future-annotations = true +lint.isort.force-single-line = true +lint.pycodestyle.ignore-overlong-task-comments = true +lint.pydocstyle.convention = "google" lint.select = ["ALL"] # Don't automatically remove unused variables lint.unfixable = ["F841"] -lint.pydocstyle.convention = "google" -lint.isort.required-imports = ["from __future__ import annotations"] -lint.isort.force-single-line = true - lint.ignore = [ "ANN002", # Checks that function *args arguments have type annotations. "ANN003", # Checks that function **kwargs arguments have type annotations. @@ -63,6 +70,7 @@ lint.ignore = [ "D104", # Checks for undocumented public package definitions. "D105", # Checks for undocumented magic method definitions. "D106", # Checks for undocumented public class definitions, for nested classes. + "E501", # Checks for lines that exceed the specified maximum character length. "ERA001", # Checks for commented-out Python code. "FIX002", # Checks for "TODO" comments. "PLR0911", # Checks for functions or methods with too many return statements. @@ -87,10 +95,6 @@ lint.ignore = [ "Q003", # Checks for strings that include escaped quotes, and suggests changing the quote style to avoid the need to escape them. "W191", # Checks for indentation that uses tabs. ] -preview = true -unsafe-fixes = true -fix = true -line-length = 120 [tool.ruff.lint.per-file-ignores] "**/tests/**" = [ diff --git a/twitch/apps.py b/twitch/apps.py index a1cf1aa..33fadea 100644 --- a/twitch/apps.py +++ b/twitch/apps.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from django.apps import AppConfig diff --git a/twitch/feeds.py b/twitch/feeds.py index 783bdeb..4828521 100644 --- a/twitch/feeds.py +++ b/twitch/feeds.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging import re from typing import TYPE_CHECKING @@ -14,12 +12,10 @@ from django.utils import feedgenerator from django.utils import timezone from django.utils.html import format_html from django.utils.html import format_html_join -from django.utils.safestring import SafeString from django.utils.safestring import SafeText from twitch.models import Channel from twitch.models import ChatBadge -from twitch.models import DropBenefit from twitch.models import DropCampaign from twitch.models import Game from twitch.models import Organization @@ -33,6 +29,9 @@ if TYPE_CHECKING: from django.db.models import QuerySet from django.http import HttpRequest from django.http import HttpResponse + from django.utils.safestring import SafeString + + from twitch.models import DropBenefit logger: logging.Logger = logging.getLogger("ttvdrops") @@ -71,12 +70,20 @@ def insert_date_info(item: Model, parts: list[SafeText]) -> None: if start_at or end_at: start_part: SafeString = ( - format_html("Starts: {} ({})", start_at.strftime("%Y-%m-%d %H:%M %Z"), naturaltime(start_at)) + format_html( + "Starts: {} ({})", + start_at.strftime("%Y-%m-%d %H:%M %Z"), + naturaltime(start_at), + ) if start_at else SafeText("") ) end_part: SafeString = ( - format_html("Ends: {} ({})", end_at.strftime("%Y-%m-%d %H:%M %Z"), naturaltime(end_at)) + format_html( + "Ends: {} ({})", + end_at.strftime("%Y-%m-%d %H:%M %Z"), + naturaltime(end_at), + ) if end_at else SafeText("") ) @@ -130,7 +137,10 @@ def _build_drops_data(drops_qs: QuerySet[TimeBasedDrop]) -> list[dict]: return drops_data -def _build_channels_html(channels: list[Channel] | QuerySet[Channel], game: Game | None) -> SafeText: +def _build_channels_html( + channels: list[Channel] | QuerySet[Channel], + game: Game | None, +) -> SafeText: """Render up to max_links channel links as
  • , then a count of additional channels, or fallback to game category link. If only one channel and drop_requirements is '1 subscriptions required', @@ -142,9 +152,11 @@ def _build_channels_html(channels: list[Channel] | QuerySet[Channel], game: Game Returns: SafeText: HTML
      with up to max_links channel links, count of more, or fallback link. - """ # noqa: E501 + """ max_links = 5 - channels_all: list[Channel] = list(channels) if isinstance(channels, list) else list(channels.all()) + channels_all: list[Channel] = ( + list(channels) if isinstance(channels, list) else list(channels.all()) + ) total: int = len(channels_all) if channels_all: @@ -166,18 +178,31 @@ def _build_channels_html(channels: list[Channel] | QuerySet[Channel], game: Game ) if not game: - logger.warning("No game associated with drop campaign for channel fallback link") - return format_html("{}", "
      • Drop has no game and no channels connected to the drop.
      ") + logger.warning( + "No game associated with drop campaign for channel fallback link", + ) + return format_html( + "{}", + "
      • Drop has no game and no channels connected to the drop.
      ", + ) if not game.twitch_directory_url: - logger.warning("Game %s has no Twitch directory URL for channel fallback link", game) - if getattr(game, "details_url", "") == "https://help.twitch.tv/s/article/twitch-chat-badges-guide ": + logger.warning( + "Game %s has no Twitch directory URL for channel fallback link", + game, + ) + if ( + getattr(game, "details_url", "") + == "https://help.twitch.tv/s/article/twitch-chat-badges-guide " + ): # TODO(TheLovinator): Improve detection of global emotes # noqa: TD003 return format_html("{}", "
      • Global Twitch Emote?
      ") - return format_html("{}", "
      • Failed to get Twitch category URL :(
      ") + return format_html( + "{}", + "
      • Failed to get Twitch category URL :(
      ", + ) - # If no channel is associated, the drop is category-wide; link to the game's Twitch directory display_name: str = getattr(game, "display_name", "this game") return format_html( '', @@ -187,10 +212,14 @@ def _build_channels_html(channels: list[Channel] | QuerySet[Channel], game: Game ) -def _construct_drops_summary(drops_data: list[dict], channel_name: str | None = None) -> SafeText: +def _construct_drops_summary( + drops_data: list[dict], + channel_name: str | None = None, +) -> SafeText: """Construct a safe HTML summary of drops and their benefits. - If the requirements indicate a subscription is required, link the benefit names to the Twitch channel. + If the requirements indicate a subscription is required, link the benefit + names to the Twitch channel. Args: drops_data (list[dict]): List of drop data dicts. @@ -205,13 +234,20 @@ def _construct_drops_summary(drops_data: list[dict], channel_name: str | None = badge_titles: set[str] = set() for drop in drops_data: for b in drop.get("benefits", []): - if getattr(b, "distribution_type", "") == "BADGE" and getattr(b, "name", ""): + if getattr(b, "distribution_type", "") == "BADGE" and getattr( + b, + "name", + "", + ): badge_titles.add(b.name) badge_descriptions_by_title: dict[str, str] = {} if badge_titles: badge_descriptions_by_title = dict( - ChatBadge.objects.filter(title__in=badge_titles).values_list("title", "description"), + ChatBadge.objects.filter(title__in=badge_titles).values_list( + "title", + "description", + ), ) def sort_key(drop: dict) -> tuple[bool, int]: @@ -226,7 +262,9 @@ def _construct_drops_summary(drops_data: list[dict], channel_name: str | None = for drop in sorted_drops: requirements: str = drop.get("requirements", "") benefits: list[DropBenefit] = drop.get("benefits", []) - is_sub_required: bool = "sub required" in requirements or "subs required" in requirements + is_sub_required: bool = ( + "sub required" in requirements or "subs required" in requirements + ) benefit_names: list[tuple[str]] = [] for b in benefits: benefit_name: str = getattr(b, "name", str(b)) @@ -238,19 +276,30 @@ def _construct_drops_summary(drops_data: list[dict], channel_name: str | None = benefit_name, ) if badge_desc: - benefit_names.append((format_html("{} ({})", linked_name, badge_desc),)) + benefit_names.append(( + format_html("{} ({})", linked_name, badge_desc), + )) else: benefit_names.append((linked_name,)) elif badge_desc: - benefit_names.append((format_html("{} ({})", benefit_name, badge_desc),)) + benefit_names.append(( + format_html("{} ({})", benefit_name, badge_desc), + )) else: benefit_names.append((benefit_name,)) - benefits_str: SafeString = format_html_join(", ", "{}", benefit_names) if benefit_names else SafeText("") + benefits_str: SafeString = ( + format_html_join(", ", "{}", benefit_names) + if benefit_names + else SafeText("") + ) if requirements: items.append(format_html("
    • {}: {}
    • ", requirements, benefits_str)) else: items.append(format_html("
    • {}
    • ", benefits_str)) - return format_html("
        {}
      ", format_html_join("", "{}", [(item,) for item in items])) + return format_html( + "
        {}
      ", + format_html_join("", "{}", [(item,) for item in items]), + ) # MARK: /rss/organizations/ @@ -265,7 +314,12 @@ class OrganizationRSSFeed(Feed): feed_copyright: str = "Information wants to be free." _limit: int | None = None - def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + def __call__( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> HttpResponse: """Override to capture limit parameter from request. Args: @@ -332,7 +386,12 @@ class GameFeed(Feed): feed_copyright: str = "Information wants to be free." _limit: int | None = None - def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + def __call__( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> HttpResponse: """Override to capture limit parameter from request. Args: @@ -375,7 +434,9 @@ class GameFeed(Feed): if box_art: description_parts.append( - SafeText(f"Box Art for {game_name}"), + SafeText( + f"Box Art for {game_name}", + ), ) if slug: @@ -456,7 +517,12 @@ class DropCampaignFeed(Feed): feed_copyright: str = "Information wants to be free." _limit: int | None = None - def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + def __call__( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> HttpResponse: """Override to capture limit parameter from request. Args: @@ -475,7 +541,7 @@ class DropCampaignFeed(Feed): return super().__call__(request, *args, **kwargs) def items(self) -> list[DropCampaign]: - """Return the latest drop campaigns ordered by most recent start date (default 200, or limited by ?limit query param).""" # noqa: E501 + """Return the latest drop campaigns ordered by most recent start date (default 200, or limited by ?limit query param).""" limit: int = self._limit if self._limit is not None else 200 queryset: QuerySet[DropCampaign] = DropCampaign.objects.order_by("-start_at") return list(_with_campaign_related(queryset)[:limit]) @@ -500,7 +566,11 @@ class DropCampaignFeed(Feed): if image_url: item_name: str = getattr(item, "name", str(object=item)) parts.append( - format_html('{}', image_url, item_name), + format_html( + '{}', + image_url, + item_name, + ), ) desc_text: str | None = getattr(item, "description", None) @@ -511,7 +581,12 @@ class DropCampaignFeed(Feed): insert_date_info(item, parts) if drops_data: - parts.append(format_html("

      {}

      ", _construct_drops_summary(drops_data, channel_name=channel_name))) + parts.append( + format_html( + "

      {}

      ", + _construct_drops_summary(drops_data, channel_name=channel_name), + ), + ) # Only show channels if drop is not subscription only if not getattr(item, "is_subscription_only", False) and channels is not None: @@ -573,7 +648,12 @@ class GameCampaignFeed(Feed): feed_copyright: str = "Information wants to be free." _limit: int | None = None - def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + def __call__( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> HttpResponse: """Override to capture limit parameter from request. Args: @@ -620,9 +700,11 @@ class GameCampaignFeed(Feed): return reverse("twitch:game_campaign_feed", args=[obj.twitch_id]) def items(self, obj: Game) -> list[DropCampaign]: - """Return the latest drop campaigns for this game, ordered by most recent start date (default 200, or limited by ?limit query param).""" # noqa: E501 + """Return the latest drop campaigns for this game, ordered by most recent start date (default 200, or limited by ?limit query param).""" limit: int = self._limit if self._limit is not None else 200 - queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter(game=obj).order_by("-start_at") + queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter( + game=obj, + ).order_by("-start_at") return list(_with_campaign_related(queryset)[:limit]) def item_title(self, item: DropCampaign) -> SafeText: @@ -645,7 +727,11 @@ class GameCampaignFeed(Feed): if image_url: item_name: str = getattr(item, "name", str(object=item)) parts.append( - format_html('{}', image_url, item_name), + format_html( + '{}', + image_url, + item_name, + ), ) desc_text: str | None = getattr(item, "description", None) @@ -656,7 +742,12 @@ class GameCampaignFeed(Feed): insert_date_info(item, parts) if drops_data: - parts.append(format_html("

      {}

      ", _construct_drops_summary(drops_data, channel_name=channel_name))) + parts.append( + format_html( + "

      {}

      ", + _construct_drops_summary(drops_data, channel_name=channel_name), + ), + ) # Only show channels if drop is not subscription only if not getattr(item, "is_subscription_only", False) and channels is not None: @@ -669,7 +760,9 @@ class GameCampaignFeed(Feed): account_link_url: str | None = getattr(item, "account_link_url", None) if account_link_url: - parts.append(format_html(' | Link Account', account_link_url)) + parts.append( + format_html(' | Link Account', account_link_url), + ) return SafeText("".join(str(p) for p in parts)) @@ -723,7 +816,12 @@ class OrganizationCampaignFeed(Feed): _limit: int | None = None - def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + def __call__( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> HttpResponse: """Override to capture limit parameter from request. Args: @@ -766,9 +864,11 @@ class OrganizationCampaignFeed(Feed): return f"Latest drop campaigns for organization {obj.name}" def items(self, obj: Organization) -> list[DropCampaign]: - """Return the latest drop campaigns for this organization, ordered by most recent start date (default 200, or limited by ?limit query param).""" # noqa: E501 + """Return the latest drop campaigns for this organization, ordered by most recent start date (default 200, or limited by ?limit query param).""" limit: int = self._limit if self._limit is not None else 200 - queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter(game__owners=obj).order_by("-start_at") + queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter( + game__owners=obj, + ).order_by("-start_at") return list(_with_campaign_related(queryset)[:limit]) def item_author_name(self, item: DropCampaign) -> str: @@ -829,7 +929,11 @@ class OrganizationCampaignFeed(Feed): if image_url: item_name: str = getattr(item, "name", str(object=item)) parts.append( - format_html('{}', image_url, item_name), + format_html( + '{}', + image_url, + item_name, + ), ) desc_text: str | None = getattr(item, "description", None) @@ -840,7 +944,12 @@ class OrganizationCampaignFeed(Feed): insert_date_info(item, parts) if drops_data: - parts.append(format_html("

      {}

      ", _construct_drops_summary(drops_data, channel_name=channel_name))) + parts.append( + format_html( + "

      {}

      ", + _construct_drops_summary(drops_data, channel_name=channel_name), + ), + ) # Only show channels if drop is not subscription only if not getattr(item, "is_subscription_only", False) and channels is not None: @@ -865,7 +974,12 @@ class RewardCampaignFeed(Feed): feed_copyright: str = "Information wants to be free." _limit: int | None = None - def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + def __call__( + self, + request: HttpRequest, + *args: object, + **kwargs: object, + ) -> HttpResponse: """Override to capture limit parameter from request. Args: diff --git a/twitch/management/commands/backfill_image_dimensions.py b/twitch/management/commands/backfill_image_dimensions.py index 6101204..44e7a9b 100644 --- a/twitch/management/commands/backfill_image_dimensions.py +++ b/twitch/management/commands/backfill_image_dimensions.py @@ -1,7 +1,5 @@ """Management command to backfill image dimensions for existing cached images.""" -from __future__ import annotations - from django.core.management.base import BaseCommand from twitch.models import DropBenefit diff --git a/twitch/management/commands/backup_db.py b/twitch/management/commands/backup_db.py index 53ec0da..c708cbb 100644 --- a/twitch/management/commands/backup_db.py +++ b/twitch/management/commands/backup_db.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import io import os import shutil @@ -79,7 +77,10 @@ class Command(BaseCommand): msg = f"Unsupported database backend: {django_connection.vendor}" raise CommandError(msg) - created_at: datetime = datetime.fromtimestamp(output_path.stat().st_mtime, tz=timezone.get_current_timezone()) + created_at: datetime = datetime.fromtimestamp( + output_path.stat().st_mtime, + tz=timezone.get_current_timezone(), + ) self.stdout.write( self.style.SUCCESS( f"Backup created: {output_path} (updated {created_at.isoformat()})", @@ -111,7 +112,11 @@ def _get_allowed_tables(prefix: str) -> list[str]: return [row[0] for row in cursor.fetchall()] -def _write_sqlite_dump(handle: io.TextIOBase, connection: sqlite3.Connection, tables: list[str]) -> None: +def _write_sqlite_dump( + handle: io.TextIOBase, + connection: sqlite3.Connection, + tables: list[str], +) -> None: """Write a SQL dump containing schema and data for the requested tables. Args: @@ -154,7 +159,11 @@ def _get_table_schema(connection: sqlite3.Connection, table: str) -> str: return row[0] if row and row[0] else "" -def _write_table_rows(handle: io.TextIOBase, connection: sqlite3.Connection, table: str) -> None: +def _write_table_rows( + handle: io.TextIOBase, + connection: sqlite3.Connection, + table: str, +) -> None: """Write INSERT statements for a table. Args: @@ -169,7 +178,11 @@ def _write_table_rows(handle: io.TextIOBase, connection: sqlite3.Connection, tab handle.write(f'INSERT INTO "{table}" VALUES ({values});\n') # noqa: S608 -def _write_indexes(handle: io.TextIOBase, connection: sqlite3.Connection, tables: list[str]) -> None: +def _write_indexes( + handle: io.TextIOBase, + connection: sqlite3.Connection, + tables: list[str], +) -> None: """Write CREATE INDEX statements for included tables. Args: @@ -251,10 +264,7 @@ def _write_postgres_dump(output_path: Path, tables: list[str]) -> None: msg = "pg_dump process did not provide stdout or stderr." raise CommandError(msg) - with ( - output_path.open("wb") as raw_handle, - zstd.open(raw_handle, "w") as compressed, - ): + with output_path.open("wb") as raw_handle, zstd.open(raw_handle, "w") as compressed: for chunk in iter(lambda: process.stdout.read(64 * 1024), b""): # pyright: ignore[reportOptionalMemberAccess] compressed.write(chunk) diff --git a/twitch/management/commands/better_import_drops.py b/twitch/management/commands/better_import_drops.py index 38c5735..3fea772 100644 --- a/twitch/management/commands/better_import_drops.py +++ b/twitch/management/commands/better_import_drops.py @@ -1,11 +1,10 @@ -from __future__ import annotations - import json import os import sys from datetime import UTC from datetime import datetime from pathlib import Path +from typing import TYPE_CHECKING from typing import Any from typing import Literal from urllib.parse import urlparse @@ -18,8 +17,6 @@ from colorama import init as colorama_init from django.core.files.base import ContentFile from django.core.management.base import BaseCommand from django.core.management.base import CommandError -from django.core.management.base import CommandParser -from json_repair import JSONReturnType from pydantic import ValidationError from tqdm import tqdm @@ -31,21 +28,26 @@ from twitch.models import Game from twitch.models import Organization from twitch.models import RewardCampaign from twitch.models import TimeBasedDrop -from twitch.schemas import ChannelInfoSchema -from twitch.schemas import CurrentUserSchema -from twitch.schemas import DropBenefitEdgeSchema -from twitch.schemas import DropBenefitSchema -from twitch.schemas import DropCampaignACLSchema -from twitch.schemas import DropCampaignSchema -from twitch.schemas import GameSchema from twitch.schemas import GraphQLResponse from twitch.schemas import OrganizationSchema -from twitch.schemas import RewardCampaign as RewardCampaignSchema -from twitch.schemas import TimeBasedDropSchema from twitch.utils import is_twitch_box_art_url from twitch.utils import normalize_twitch_box_art_url from twitch.utils import parse_date +if TYPE_CHECKING: + from django.core.management.base import CommandParser + from json_repair import JSONReturnType + + from twitch.schemas import ChannelInfoSchema + from twitch.schemas import CurrentUserSchema + from twitch.schemas import DropBenefitEdgeSchema + from twitch.schemas import DropBenefitSchema + from twitch.schemas import DropCampaignACLSchema + from twitch.schemas import DropCampaignSchema + from twitch.schemas import GameSchema + from twitch.schemas import RewardCampaign as RewardCampaignSchema + from twitch.schemas import TimeBasedDropSchema + def get_broken_directory_root() -> Path: """Get the root broken directory path from environment or default. @@ -83,10 +85,7 @@ def get_imported_directory_root() -> Path: return home / "ttvdrops" / "imported" -def _build_broken_directory( - reason: str, - operation_name: str | None = None, -) -> Path: +def _build_broken_directory(reason: str, operation_name: str | None = None) -> Path: """Compute a deeply nested broken directory for triage. Directory pattern: /////
      @@ -104,16 +103,32 @@ def _build_broken_directory( # If operation_name matches reason, skip it to avoid duplicate directories if operation_name and operation_name.replace(" ", "_") == safe_reason: - broken_dir: Path = get_broken_directory_root() / safe_reason / f"{now:%Y}" / f"{now:%m}" / f"{now:%d}" + broken_dir: Path = ( + get_broken_directory_root() + / safe_reason + / f"{now:%Y}" + / f"{now:%m}" + / f"{now:%d}" + ) else: op_segment: str = (operation_name or "unknown_op").replace(" ", "_") - broken_dir = get_broken_directory_root() / safe_reason / op_segment / f"{now:%Y}" / f"{now:%m}" / f"{now:%d}" + broken_dir = ( + get_broken_directory_root() + / safe_reason + / op_segment + / f"{now:%Y}" + / f"{now:%m}" + / f"{now:%d}" + ) broken_dir.mkdir(parents=True, exist_ok=True) return broken_dir -def move_failed_validation_file(file_path: Path, operation_name: str | None = None) -> Path: +def move_failed_validation_file( + file_path: Path, + operation_name: str | None = None, +) -> Path: """Moves a file that failed validation to a 'broken' subdirectory. Args: @@ -178,7 +193,12 @@ def move_completed_file( Returns: Path to the directory where the file was moved. """ - safe_op: str = (operation_name or "unknown_op").replace(" ", "_").replace("/", "_").replace("\\", "_") + safe_op: str = ( + (operation_name or "unknown_op") + .replace(" ", "_") + .replace("/", "_") + .replace("\\", "_") + ) target_dir: Path = get_imported_directory_root() / safe_op if campaign_structure: @@ -249,7 +269,12 @@ def detect_error_only_response( errors: Any = item.get("errors") data: Any = item.get("data") # Data is missing if key doesn't exist or value is None - if errors and data is None and isinstance(errors, list) and len(errors) > 0: + if ( + errors + and data is None + and isinstance(errors, list) + and len(errors) > 0 + ): first_error: dict[str, Any] = errors[0] message: str = first_error.get("message", "unknown error") return f"error_only: {message}" @@ -327,7 +352,7 @@ def repair_partially_broken_json(raw_text: str) -> str: # noqa: PLR0915 """ # Strategy 1: Direct repair attempt try: - fixed: str = json_repair.repair_json(raw_text) + fixed: str = json_repair.repair_json(raw_text, logging=False) # Validate it produces valid JSON parsed_data = json.loads(fixed) @@ -335,7 +360,9 @@ def repair_partially_broken_json(raw_text: str) -> str: # noqa: PLR0915 if isinstance(parsed_data, list): # Filter to only keep GraphQL responses filtered = [ - item for item in parsed_data if isinstance(item, dict) and ("data" in item or "extensions" in item) + item + for item in parsed_data + if isinstance(item, dict) and ("data" in item or "extensions" in item) ] if filtered: # If we filtered anything out, return the filtered version @@ -358,7 +385,10 @@ def repair_partially_broken_json(raw_text: str) -> str: # noqa: PLR0915 # Validate that all items look like GraphQL responses if isinstance(wrapped_data, list) and wrapped_data: # noqa: SIM102 # Check if all items have "data" or "extensions" (GraphQL response structure) - if all(isinstance(item, dict) and ("data" in item or "extensions" in item) for item in wrapped_data): + if all( + isinstance(item, dict) and ("data" in item or "extensions" in item) + for item in wrapped_data + ): return wrapped except ValueError, json.JSONDecodeError: pass @@ -405,7 +435,7 @@ def repair_partially_broken_json(raw_text: str) -> str: # noqa: PLR0915 line: str = line.strip() # noqa: PLW2901 if line and line.startswith("{"): try: - fixed_line: str = json_repair.repair_json(line) + fixed_line: str = json_repair.repair_json(line, logging=False) obj = json.loads(fixed_line) # Only keep objects that look like GraphQL responses if "data" in obj or "extensions" in obj: @@ -428,11 +458,7 @@ class Command(BaseCommand): def add_arguments(self, parser: CommandParser) -> None: """Populate the command with arguments.""" - parser.add_argument( - "path", - type=str, - help="Path to JSON file or directory", - ) + parser.add_argument("path", type=str, help="Path to JSON file or directory") parser.add_argument( "--recursive", action="store_true", @@ -487,7 +513,9 @@ class Command(BaseCommand): for response_data in responses: if isinstance(response_data, dict): try: - response: GraphQLResponse = GraphQLResponse.model_validate(response_data) + response: GraphQLResponse = GraphQLResponse.model_validate( + response_data, + ) valid_responses.append(response) except ValidationError as e: @@ -497,8 +525,13 @@ class Command(BaseCommand): # Move invalid inputs out of the hot path so future runs can progress. if not options.get("skip_broken_moves"): - op_name: str | None = extract_operation_name_from_parsed(response_data) - broken_dir = move_failed_validation_file(file_path, operation_name=op_name) + op_name: str | None = extract_operation_name_from_parsed( + response_data, + ) + broken_dir = move_failed_validation_file( + file_path, + operation_name=op_name, + ) # Once the file has been moved, bail out so we don't try to move it again later. return [], broken_dir @@ -511,10 +544,7 @@ class Command(BaseCommand): return valid_responses, broken_dir - def _get_or_create_organization( - self, - org_data: OrganizationSchema, - ) -> Organization: + def _get_or_create_organization(self, org_data: OrganizationSchema) -> Organization: """Get or create an organization. Args: @@ -525,12 +555,12 @@ class Command(BaseCommand): """ org_obj, created = Organization.objects.update_or_create( twitch_id=org_data.twitch_id, - defaults={ - "name": org_data.name, - }, + defaults={"name": org_data.name}, ) if created: - tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} Created new organization: {org_data.name}") + tqdm.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} Created new organization: {org_data.name}", + ) return org_obj @@ -572,7 +602,9 @@ class Command(BaseCommand): if created or owner_orgs: game_obj.owners.add(*owner_orgs) if created: - tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} Created new game: {game_data.display_name}") + tqdm.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} Created new game: {game_data.display_name}", + ) self._download_game_box_art(game_obj, game_obj.box_art) return game_obj @@ -615,13 +647,12 @@ class Command(BaseCommand): channel_obj, created = Channel.objects.update_or_create( twitch_id=channel_info.twitch_id, - defaults={ - "name": channel_info.name, - "display_name": display_name, - }, + defaults={"name": channel_info.name, "display_name": display_name}, ) if created: - tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} Created new channel: {display_name}") + tqdm.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} Created new channel: {display_name}", + ) return channel_obj @@ -638,12 +669,13 @@ class Command(BaseCommand): file_path: Path to the file being processed. options: Command options dictionary. - Raises: - ValueError: If datetime parsing fails for campaign dates and - crash-on-error is enabled. Returns: Tuple of (success flag, broken directory path if moved). + + Raises: + ValueError: If datetime parsing fails for campaign dates and + crash-on-error is enabled. """ valid_responses, broken_dir = self._validate_responses( responses=responses, @@ -659,7 +691,9 @@ class Command(BaseCommand): campaigns_to_process: list[DropCampaignSchema] = [] # Source 1: User or CurrentUser field (handles plural, singular, inventory) - user_obj: CurrentUserSchema | None = response.data.current_user or response.data.user + user_obj: CurrentUserSchema | None = ( + response.data.current_user or response.data.user + ) if user_obj and user_obj.drop_campaigns: campaigns_to_process.extend(user_obj.drop_campaigns) @@ -676,7 +710,11 @@ class Command(BaseCommand): for drop_campaign in campaigns_to_process: # Handle campaigns without owner (e.g., from Inventory operation) - owner_data: OrganizationSchema | None = getattr(drop_campaign, "owner", None) + owner_data: OrganizationSchema | None = getattr( + drop_campaign, + "owner", + None, + ) org_obj: Organization | None = None if owner_data: org_obj = self._get_or_create_organization(org_data=owner_data) @@ -690,7 +728,9 @@ class Command(BaseCommand): end_at_dt: datetime | None = parse_date(drop_campaign.end_at) if start_at_dt is None or end_at_dt is None: - tqdm.write(f"{Fore.RED}✗{Style.RESET_ALL} Invalid datetime in campaign: {drop_campaign.name}") + tqdm.write( + f"{Fore.RED}✗{Style.RESET_ALL} Invalid datetime in campaign: {drop_campaign.name}", + ) if options.get("crash_on_error"): msg: str = f"Failed to parse datetime for campaign {drop_campaign.name}" raise ValueError(msg) @@ -712,17 +752,26 @@ class Command(BaseCommand): defaults=defaults, ) if created: - tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} Created new campaign: {drop_campaign.name}") + tqdm.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} Created new campaign: {drop_campaign.name}", + ) - action: Literal["Imported new", "Updated"] = "Imported new" if created else "Updated" - tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} {action} campaign: {drop_campaign.name}") + action: Literal["Imported new", "Updated"] = ( + "Imported new" if created else "Updated" + ) + tqdm.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} {action} campaign: {drop_campaign.name}", + ) if ( response.extensions and response.extensions.operation_name - and response.extensions.operation_name not in campaign_obj.operation_names + and response.extensions.operation_name + not in campaign_obj.operation_names ): - campaign_obj.operation_names.append(response.extensions.operation_name) + campaign_obj.operation_names.append( + response.extensions.operation_name, + ) campaign_obj.save(update_fields=["operation_names"]) if drop_campaign.time_based_drops: @@ -769,7 +818,9 @@ class Command(BaseCommand): } if drop_schema.required_minutes_watched is not None: - drop_defaults["required_minutes_watched"] = drop_schema.required_minutes_watched + drop_defaults["required_minutes_watched"] = ( + drop_schema.required_minutes_watched + ) if start_at_dt is not None: drop_defaults["start_at"] = start_at_dt if end_at_dt is not None: @@ -780,7 +831,9 @@ class Command(BaseCommand): defaults=drop_defaults, ) if created: - tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} Created TimeBasedDrop: {drop_schema.name}") + tqdm.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} Created TimeBasedDrop: {drop_schema.name}", + ) self._process_benefit_edges( benefit_edges_schema=drop_schema.benefit_edges, @@ -808,7 +861,9 @@ class Command(BaseCommand): defaults=benefit_defaults, ) if created: - tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} Created DropBenefit: {benefit_schema.name}") + tqdm.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} Created DropBenefit: {benefit_schema.name}", + ) return benefit_obj @@ -826,7 +881,9 @@ class Command(BaseCommand): for edge_schema in benefit_edges_schema: benefit_schema: DropBenefitSchema = edge_schema.benefit - benefit_obj: DropBenefit = self._get_or_update_benefit(benefit_schema=benefit_schema) + benefit_obj: DropBenefit = self._get_or_update_benefit( + benefit_schema=benefit_schema, + ) _edge_obj, created = DropBenefitEdge.objects.update_or_create( drop=drop_obj, @@ -834,7 +891,9 @@ class Command(BaseCommand): defaults={"entitlement_limit": edge_schema.entitlement_limit}, ) if created: - tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} Linked benefit: {benefit_schema.name} → {drop_obj.name}") + tqdm.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} Linked benefit: {benefit_schema.name} → {drop_obj.name}", + ) def _process_allowed_channels( self, @@ -852,7 +911,9 @@ class Command(BaseCommand): """ # Update the allow_is_enabled flag if changed # Default to True if is_enabled is None (API doesn't always provide this field) - is_enabled: bool = allow_schema.is_enabled if allow_schema.is_enabled is not None else True + is_enabled: bool = ( + allow_schema.is_enabled if allow_schema.is_enabled is not None else True + ) if campaign_obj.allow_is_enabled != is_enabled: campaign_obj.allow_is_enabled = is_enabled campaign_obj.save(update_fields=["allow_is_enabled"]) @@ -864,7 +925,9 @@ class Command(BaseCommand): channel_objects: list[Channel] = [] if allow_schema.channels: for channel_schema in allow_schema.channels: - channel_obj: Channel = self._get_or_create_channel(channel_info=channel_schema) + channel_obj: Channel = self._get_or_create_channel( + channel_info=channel_schema, + ) channel_objects.append(channel_obj) # Only update the M2M relationship if we have channels campaign_obj.allow_channels.set(channel_objects) @@ -889,7 +952,9 @@ class Command(BaseCommand): ends_at_dt: datetime | None = parse_date(reward_campaign.ends_at) if starts_at_dt is None or ends_at_dt is None: - tqdm.write(f"{Fore.RED}✗{Style.RESET_ALL} Invalid datetime in reward campaign: {reward_campaign.name}") + tqdm.write( + f"{Fore.RED}✗{Style.RESET_ALL} Invalid datetime in reward campaign: {reward_campaign.name}", + ) if options.get("crash_on_error"): msg: str = f"Failed to parse datetime for reward campaign {reward_campaign.name}" raise ValueError(msg) @@ -923,7 +988,9 @@ class Command(BaseCommand): "about_url": reward_campaign.about_url, "is_sitewide": reward_campaign.is_sitewide, "game": game_obj, - "image_url": reward_campaign.image.image1x_url if reward_campaign.image else "", + "image_url": reward_campaign.image.image1x_url + if reward_campaign.image + else "", } _reward_campaign_obj, created = RewardCampaign.objects.update_or_create( @@ -931,11 +998,17 @@ class Command(BaseCommand): defaults=defaults, ) - action: Literal["Imported new", "Updated"] = "Imported new" if created else "Updated" - display_name = ( - f"{reward_campaign.brand}: {reward_campaign.name}" if reward_campaign.brand else reward_campaign.name + action: Literal["Imported new", "Updated"] = ( + "Imported new" if created else "Updated" + ) + display_name = ( + f"{reward_campaign.brand}: {reward_campaign.name}" + if reward_campaign.brand + else reward_campaign.name + ) + tqdm.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} {action} reward campaign: {display_name}", ) - tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} {action} reward campaign: {display_name}") def handle(self, *args, **options) -> None: # noqa: ARG002 """Main entry point for the command. @@ -978,7 +1051,9 @@ class Command(BaseCommand): total=len(json_files), desc="Processing", unit="file", - bar_format=("{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]"), + bar_format=( + "{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]" + ), colour="green", dynamic_ncols=True, ) as progress_bar: @@ -991,10 +1066,14 @@ class Command(BaseCommand): if result["success"]: success_count += 1 if options.get("verbose"): - progress_bar.write(f"{Fore.GREEN}✓{Style.RESET_ALL} {file_path.name}") + progress_bar.write( + f"{Fore.GREEN}✓{Style.RESET_ALL} {file_path.name}", + ) else: failed_count += 1 - reason: bool | str | None = result.get("reason") if isinstance(result, dict) else None + reason: bool | str | None = ( + result.get("reason") if isinstance(result, dict) else None + ) if reason: progress_bar.write( f"{Fore.RED}✗{Style.RESET_ALL} " @@ -1009,10 +1088,15 @@ class Command(BaseCommand): ) except (OSError, ValueError, KeyError) as e: error_count += 1 - progress_bar.write(f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} (error: {e})") + progress_bar.write( + f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} (error: {e})", + ) # Update postfix with statistics - progress_bar.set_postfix_str(f"✓ {success_count} | ✗ {failed_count + error_count}", refresh=True) + progress_bar.set_postfix_str( + f"✓ {success_count} | ✗ {failed_count + error_count}", + refresh=True, + ) progress_bar.update(1) self.print_processing_summary( @@ -1093,7 +1177,10 @@ class Command(BaseCommand): return "inventory_campaigns" # Structure: {"data": {"currentUser": {"dropCampaigns": [...]}}} - if "dropCampaigns" in current_user and isinstance(current_user["dropCampaigns"], list): + if "dropCampaigns" in current_user and isinstance( + current_user["dropCampaigns"], + list, + ): return "current_user_drop_campaigns" # Structure: {"data": {"channel": {"viewerDropCampaigns": [...] or {...}}}} @@ -1104,11 +1191,7 @@ class Command(BaseCommand): return None - def collect_json_files( - self, - options: dict, - input_path: Path, - ) -> list[Path]: + def collect_json_files(self, options: dict, input_path: Path) -> list[Path]: """Collect JSON files from the specified directory. Args: @@ -1122,9 +1205,13 @@ class Command(BaseCommand): if options["recursive"]: for root, _dirs, files in os.walk(input_path): root_path = Path(root) - json_files.extend(root_path / file for file in files if file.endswith(".json")) + json_files.extend( + root_path / file for file in files if file.endswith(".json") + ) else: - json_files = [f for f in input_path.iterdir() if f.is_file() and f.suffix == ".json"] + json_files = [ + f for f in input_path.iterdir() if f.is_file() and f.suffix == ".json" + ] return json_files def _normalize_responses( @@ -1147,8 +1234,13 @@ class Command(BaseCommand): """ if isinstance(parsed_json, dict): # Check for batched format: {"responses": [...]} - if "responses" in parsed_json and isinstance(parsed_json["responses"], list): - return [item for item in parsed_json["responses"] if isinstance(item, dict)] + if "responses" in parsed_json and isinstance( + parsed_json["responses"], + list, + ): + return [ + item for item in parsed_json["responses"] if isinstance(item, dict) + ] # Single response: {"data": {...}} return [parsed_json] if isinstance(parsed_json, list): @@ -1171,21 +1263,21 @@ class Command(BaseCommand): file_path: Path to the JSON file to process options: Command options + Returns: + Dict with success status and optional broken_dir path + Raises: ValidationError: If the JSON file fails validation json.JSONDecodeError: If the JSON file cannot be parsed - - Returns: - Dict with success status and optional broken_dir path """ try: raw_text: str = file_path.read_text(encoding="utf-8", errors="ignore") # Repair potentially broken JSON with multiple fallback strategies fixed_json_str: str = repair_partially_broken_json(raw_text) - parsed_json: JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str = json.loads( - fixed_json_str, - ) + parsed_json: ( + JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str + ) = json.loads(fixed_json_str) operation_name: str | None = extract_operation_name_from_parsed(parsed_json) # Check for error-only responses first @@ -1197,8 +1289,16 @@ class Command(BaseCommand): error_description, operation_name=operation_name, ) - return {"success": False, "broken_dir": str(broken_dir), "reason": error_description} - return {"success": False, "broken_dir": "(skipped)", "reason": error_description} + return { + "success": False, + "broken_dir": str(broken_dir), + "reason": error_description, + } + return { + "success": False, + "broken_dir": "(skipped)", + "reason": error_description, + } matched: str | None = detect_non_campaign_keyword(raw_text) if matched: @@ -1208,8 +1308,16 @@ class Command(BaseCommand): matched, operation_name=operation_name, ) - return {"success": False, "broken_dir": str(broken_dir), "reason": f"matched '{matched}'"} - return {"success": False, "broken_dir": "(skipped)", "reason": f"matched '{matched}'"} + return { + "success": False, + "broken_dir": str(broken_dir), + "reason": f"matched '{matched}'", + } + return { + "success": False, + "broken_dir": "(skipped)", + "reason": f"matched '{matched}'", + } if "dropCampaign" not in raw_text: if not options.get("skip_broken_moves"): broken_dir: Path | None = move_file_to_broken_subdir( @@ -1217,8 +1325,16 @@ class Command(BaseCommand): "no_dropCampaign", operation_name=operation_name, ) - return {"success": False, "broken_dir": str(broken_dir), "reason": "no dropCampaign present"} - return {"success": False, "broken_dir": "(skipped)", "reason": "no dropCampaign present"} + return { + "success": False, + "broken_dir": str(broken_dir), + "reason": "no dropCampaign present", + } + return { + "success": False, + "broken_dir": "(skipped)", + "reason": "no dropCampaign present", + } # Normalize and filter to dict responses only responses: list[dict[str, Any]] = self._normalize_responses(parsed_json) @@ -1256,7 +1372,10 @@ class Command(BaseCommand): if isinstance(parsed_json_local, (dict, list)) else None ) - broken_dir = move_failed_validation_file(file_path, operation_name=op_name) + broken_dir = move_failed_validation_file( + file_path, + operation_name=op_name, + ) return {"success": False, "broken_dir": str(broken_dir)} return {"success": False, "broken_dir": "(skipped)"} else: @@ -1285,10 +1404,12 @@ class Command(BaseCommand): # Repair potentially broken JSON with multiple fallback strategies fixed_json_str: str = repair_partially_broken_json(raw_text) - parsed_json: JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str = json.loads( - fixed_json_str, + parsed_json: ( + JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str + ) = json.loads(fixed_json_str) + operation_name: str | None = extract_operation_name_from_parsed( + parsed_json, ) - operation_name: str | None = extract_operation_name_from_parsed(parsed_json) # Check for error-only responses first error_description: str | None = detect_error_only_response(parsed_json) @@ -1386,7 +1507,14 @@ class Command(BaseCommand): if isinstance(parsed_json_local, (dict, list)) else None ) - broken_dir = move_failed_validation_file(file_path, operation_name=op_name) - progress_bar.write(f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → {broken_dir}/{file_path.name}") + broken_dir = move_failed_validation_file( + file_path, + operation_name=op_name, + ) + progress_bar.write( + f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → {broken_dir}/{file_path.name}", + ) else: - progress_bar.write(f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} (move skipped)") + progress_bar.write( + f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} (move skipped)", + ) diff --git a/twitch/management/commands/cleanup_orphaned_channels.py b/twitch/management/commands/cleanup_orphaned_channels.py index 672b97e..a99aa90 100644 --- a/twitch/management/commands/cleanup_orphaned_channels.py +++ b/twitch/management/commands/cleanup_orphaned_channels.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import TYPE_CHECKING from django.core.management.base import BaseCommand @@ -54,21 +52,31 @@ class Command(BaseCommand): self.stdout.write(self.style.SUCCESS("No orphaned channels found.")) return - self.stdout.write(f"Found {count} orphaned channels with no associated campaigns:") + self.stdout.write( + f"Found {count} orphaned channels with no associated campaigns:", + ) # Show sample of channels to be deleted for channel in orphaned_channels[:SAMPLE_PREVIEW_COUNT]: - self.stdout.write(f" - {channel.display_name} (Twitch ID: {channel.twitch_id})") + self.stdout.write( + f" - {channel.display_name} (Twitch ID: {channel.twitch_id})", + ) if count > SAMPLE_PREVIEW_COUNT: self.stdout.write(f" ... and {count - SAMPLE_PREVIEW_COUNT} more") if dry_run: - self.stdout.write(self.style.WARNING(f"\n[DRY RUN] Would delete {count} orphaned channels.")) + self.stdout.write( + self.style.WARNING( + f"\n[DRY RUN] Would delete {count} orphaned channels.", + ), + ) return if not force: - response: str = input(f"\nAre you sure you want to delete {count} orphaned channels? (yes/no): ") + response: str = input( + f"\nAre you sure you want to delete {count} orphaned channels? (yes/no): ", + ) if response.lower() != "yes": self.stdout.write(self.style.WARNING("Cancelled.")) return @@ -76,4 +84,8 @@ class Command(BaseCommand): # Delete the orphaned channels deleted_count, _ = orphaned_channels.delete() - self.stdout.write(self.style.SUCCESS(f"\nSuccessfully deleted {deleted_count} orphaned channels.")) + self.stdout.write( + self.style.SUCCESS( + f"\nSuccessfully deleted {deleted_count} orphaned channels.", + ), + ) diff --git a/twitch/management/commands/cleanup_unknown_organizations.py b/twitch/management/commands/cleanup_unknown_organizations.py index f4aade6..28c8efb 100644 --- a/twitch/management/commands/cleanup_unknown_organizations.py +++ b/twitch/management/commands/cleanup_unknown_organizations.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import TYPE_CHECKING from typing import Any @@ -8,13 +6,13 @@ 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 twitch.models import Game from twitch.models import Organization if TYPE_CHECKING: from debug_toolbar.panels.templates.panel import QuerySet + from django.core.management.base import CommandParser class Command(BaseCommand): @@ -70,11 +68,15 @@ class Command(BaseCommand): try: org: Organization = Organization.objects.get(twitch_id=org_id) except Organization.DoesNotExist as exc: # pragma: no cover - simple guard - msg: str = f"Organization with twitch_id='{org_id}' does not exist. Nothing to do." + msg: str = ( + f"Organization with twitch_id='{org_id}' does not exist. Nothing to do." + ) raise CommandError(msg) from exc # Compute the set of affected games via the through relation for accuracy and performance - affected_games_qs: QuerySet[Game, Game] = Game.objects.filter(owners=org).order_by("display_name") + affected_games_qs: QuerySet[Game, Game] = Game.objects.filter( + owners=org, + ).order_by("display_name") affected_count: int = affected_games_qs.count() if affected_count == 0: @@ -83,7 +85,7 @@ class Command(BaseCommand): ) else: self.stdout.write( - f"{Fore.CYAN}•{Style.RESET_ALL} Found {affected_count:,} game(s) linked to '{org.name}' ({org.twitch_id}).", # noqa: E501 + f"{Fore.CYAN}•{Style.RESET_ALL} Found {affected_count:,} game(s) linked to '{org.name}' ({org.twitch_id}).", ) # Show a short preview list in dry-run mode @@ -112,9 +114,9 @@ class Command(BaseCommand): org_twid: str = org.twitch_id org.delete() self.stdout.write( - f"{Fore.GREEN}✓{Style.RESET_ALL} Deleted organization '{org_name}' ({org_twid}) as it has no games.", # noqa: E501 + f"{Fore.GREEN}✓{Style.RESET_ALL} Deleted organization '{org_name}' ({org_twid}) as it has no games.", ) else: self.stdout.write( - f"{Fore.YELLOW}→{Style.RESET_ALL} Organization '{org.name}' still has {remaining_games:,} game(s); not deleted.", # noqa: E501 + f"{Fore.YELLOW}→{Style.RESET_ALL} Organization '{org.name}' still has {remaining_games:,} game(s); not deleted.", ) diff --git a/twitch/management/commands/convert_images_to_modern_formats.py b/twitch/management/commands/convert_images_to_modern_formats.py index f25f07c..3b516f2 100644 --- a/twitch/management/commands/convert_images_to_modern_formats.py +++ b/twitch/management/commands/convert_images_to_modern_formats.py @@ -1,7 +1,5 @@ """Management command to convert existing images to WebP and AVIF formats.""" -from __future__ import annotations - import logging from pathlib import Path from typing import TYPE_CHECKING @@ -48,12 +46,18 @@ class Command(BaseCommand): media_root = Path(settings.MEDIA_ROOT) if not media_root.exists(): - self.stdout.write(self.style.WARNING(f"MEDIA_ROOT does not exist: {media_root}")) + self.stdout.write( + self.style.WARNING(f"MEDIA_ROOT does not exist: {media_root}"), + ) return # Find all JPG and PNG files image_extensions = {".jpg", ".jpeg", ".png"} - image_files = [f for f in media_root.rglob("*") if f.is_file() and f.suffix.lower() in image_extensions] + image_files = [ + f + for f in media_root.rglob("*") + if f.is_file() and f.suffix.lower() in image_extensions + ] if not image_files: self.stdout.write(self.style.SUCCESS("No images found to convert")) @@ -80,7 +84,9 @@ class Command(BaseCommand): continue if dry_run: - self.stdout.write(f"Would convert: {image_path.relative_to(media_root)}") + self.stdout.write( + f"Would convert: {image_path.relative_to(media_root)}", + ) if needs_webp: self.stdout.write(f" → {webp_path.relative_to(media_root)}") if needs_avif: @@ -104,14 +110,20 @@ class Command(BaseCommand): except Exception as e: error_count += 1 self.stdout.write( - self.style.ERROR(f"✗ Error converting {image_path.relative_to(media_root)}: {e}"), + self.style.ERROR( + f"✗ Error converting {image_path.relative_to(media_root)}: {e}", + ), ) logger.exception("Failed to convert image: %s", image_path) # Summary self.stdout.write("\n" + "=" * 50) if dry_run: - self.stdout.write(self.style.SUCCESS(f"Dry run complete. Would convert {converted_count} images")) + self.stdout.write( + self.style.SUCCESS( + f"Dry run complete. Would convert {converted_count} images", + ), + ) else: self.stdout.write(self.style.SUCCESS(f"Converted: {converted_count}")) self.stdout.write(f"Skipped (already exist): {skipped_count}") @@ -177,11 +189,16 @@ class Command(BaseCommand): Returns: RGB PIL Image ready for encoding """ - if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info): + if img.mode in {"RGBA", "LA"} or ( + img.mode == "P" and "transparency" in img.info + ): # Create white background for transparency background = Image.new("RGB", img.size, (255, 255, 255)) rgba_img = img.convert("RGBA") if img.mode == "P" else img - background.paste(rgba_img, mask=rgba_img.split()[-1] if rgba_img.mode in {"RGBA", "LA"} else None) + background.paste( + rgba_img, + mask=rgba_img.split()[-1] if rgba_img.mode in {"RGBA", "LA"} else None, + ) return background if img.mode != "RGB": return img.convert("RGB") diff --git a/twitch/management/commands/download_box_art.py b/twitch/management/commands/download_box_art.py index 31afc95..e0ea78b 100644 --- a/twitch/management/commands/download_box_art.py +++ b/twitch/management/commands/download_box_art.py @@ -1,15 +1,11 @@ -from __future__ import annotations - from pathlib import Path from typing import TYPE_CHECKING -from urllib.parse import ParseResult from urllib.parse import urlparse import httpx from django.conf import settings from django.core.files.base import ContentFile from django.core.management.base import BaseCommand -from django.core.management.base import CommandParser from PIL import Image from twitch.models import Game @@ -17,6 +13,9 @@ from twitch.utils import is_twitch_box_art_url from twitch.utils import normalize_twitch_box_art_url if TYPE_CHECKING: + from urllib.parse import ParseResult + + from django.core.management.base import CommandParser from django.db.models import QuerySet @@ -63,7 +62,11 @@ class Command(BaseCommand): if not is_twitch_box_art_url(game.box_art): skipped += 1 continue - if game.box_art_file and getattr(game.box_art_file, "name", "") and not force: + if ( + game.box_art_file + and getattr(game.box_art_file, "name", "") + and not force + ): skipped += 1 continue @@ -89,7 +92,11 @@ class Command(BaseCommand): skipped += 1 continue - game.box_art_file.save(file_name, ContentFile(response.content), save=True) + game.box_art_file.save( + file_name, + ContentFile(response.content), + save=True, + ) # Auto-convert to WebP and AVIF self._convert_to_modern_formats(game.box_art_file.path) @@ -113,7 +120,11 @@ class Command(BaseCommand): """ try: source_path = Path(image_path) - if not source_path.exists() or source_path.suffix.lower() not in {".jpg", ".jpeg", ".png"}: + if not source_path.exists() or source_path.suffix.lower() not in { + ".jpg", + ".jpeg", + ".png", + }: return base_path = source_path.with_suffix("") @@ -122,10 +133,17 @@ class Command(BaseCommand): with Image.open(source_path) as img: # Convert to RGB if needed - if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info): + if img.mode in {"RGBA", "LA"} or ( + img.mode == "P" and "transparency" in img.info + ): background = Image.new("RGB", img.size, (255, 255, 255)) rgba_img = img.convert("RGBA") if img.mode == "P" else img - background.paste(rgba_img, mask=rgba_img.split()[-1] if rgba_img.mode in {"RGBA", "LA"} else None) + background.paste( + rgba_img, + mask=rgba_img.split()[-1] + if rgba_img.mode in {"RGBA", "LA"} + else None, + ) rgb_img = background elif img.mode != "RGB": rgb_img = img.convert("RGB") @@ -140,4 +158,6 @@ class Command(BaseCommand): except (OSError, ValueError) as e: # Don't fail the download if conversion fails - self.stdout.write(self.style.WARNING(f"Failed to convert {image_path}: {e}")) + self.stdout.write( + self.style.WARNING(f"Failed to convert {image_path}: {e}"), + ) diff --git a/twitch/management/commands/download_campaign_images.py b/twitch/management/commands/download_campaign_images.py index cf48908..4d419ca 100644 --- a/twitch/management/commands/download_campaign_images.py +++ b/twitch/management/commands/download_campaign_images.py @@ -1,17 +1,13 @@ """Management command to download and cache campaign, benefit, and reward images locally.""" -from __future__ import annotations - from pathlib import Path from typing import TYPE_CHECKING -from urllib.parse import ParseResult from urllib.parse import urlparse import httpx from django.conf import settings from django.core.files.base import ContentFile from django.core.management.base import BaseCommand -from django.core.management.base import CommandParser from PIL import Image from twitch.models import DropBenefit @@ -19,6 +15,9 @@ from twitch.models import DropCampaign from twitch.models import RewardCampaign if TYPE_CHECKING: + from urllib.parse import ParseResult + + from django.core.management.base import CommandParser from django.db.models import QuerySet from django.db.models.fields.files import FieldFile @@ -66,20 +65,38 @@ class Command(BaseCommand): with httpx.Client(timeout=20, follow_redirects=True) as client: if model_choice in {"campaigns", "all"}: - self.stdout.write(self.style.MIGRATE_HEADING("\nProcessing Drop Campaigns...")) - stats = self._download_campaign_images(client=client, limit=limit, force=force) + self.stdout.write( + self.style.MIGRATE_HEADING("\nProcessing Drop Campaigns..."), + ) + stats = self._download_campaign_images( + client=client, + limit=limit, + force=force, + ) self._merge_stats(total_stats, stats) self._print_stats("Drop Campaigns", stats) if model_choice in {"benefits", "all"}: - self.stdout.write(self.style.MIGRATE_HEADING("\nProcessing Drop Benefits...")) - stats = self._download_benefit_images(client=client, limit=limit, force=force) + self.stdout.write( + self.style.MIGRATE_HEADING("\nProcessing Drop Benefits..."), + ) + stats = self._download_benefit_images( + client=client, + limit=limit, + force=force, + ) self._merge_stats(total_stats, stats) self._print_stats("Drop Benefits", stats) if model_choice in {"rewards", "all"}: - self.stdout.write(self.style.MIGRATE_HEADING("\nProcessing Reward Campaigns...")) - stats = self._download_reward_campaign_images(client=client, limit=limit, force=force) + self.stdout.write( + self.style.MIGRATE_HEADING("\nProcessing Reward Campaigns..."), + ) + stats = self._download_reward_campaign_images( + client=client, + limit=limit, + force=force, + ) self._merge_stats(total_stats, stats) self._print_stats("Reward Campaigns", stats) @@ -107,18 +124,30 @@ class Command(BaseCommand): Returns: Dictionary with download statistics (total, downloaded, skipped, failed, placeholders_404). """ - queryset: QuerySet[DropCampaign] = DropCampaign.objects.all().order_by("twitch_id") + queryset: QuerySet[DropCampaign] = DropCampaign.objects.all().order_by( + "twitch_id", + ) if limit: queryset = queryset[:limit] - stats: dict[str, int] = {"total": 0, "downloaded": 0, "skipped": 0, "failed": 0, "placeholders_404": 0} + stats: dict[str, int] = { + "total": 0, + "downloaded": 0, + "skipped": 0, + "failed": 0, + "placeholders_404": 0, + } stats["total"] = queryset.count() for campaign in queryset: if not campaign.image_url: stats["skipped"] += 1 continue - if campaign.image_file and getattr(campaign.image_file, "name", "") and not force: + if ( + campaign.image_file + and getattr(campaign.image_file, "name", "") + and not force + ): stats["skipped"] += 1 continue @@ -144,18 +173,30 @@ class Command(BaseCommand): Returns: Dictionary with download statistics (total, downloaded, skipped, failed, placeholders_404). """ - queryset: QuerySet[DropBenefit] = DropBenefit.objects.all().order_by("twitch_id") + queryset: QuerySet[DropBenefit] = DropBenefit.objects.all().order_by( + "twitch_id", + ) if limit: queryset = queryset[:limit] - stats: dict[str, int] = {"total": 0, "downloaded": 0, "skipped": 0, "failed": 0, "placeholders_404": 0} + stats: dict[str, int] = { + "total": 0, + "downloaded": 0, + "skipped": 0, + "failed": 0, + "placeholders_404": 0, + } stats["total"] = queryset.count() for benefit in queryset: if not benefit.image_asset_url: stats["skipped"] += 1 continue - if benefit.image_file and getattr(benefit.image_file, "name", "") and not force: + if ( + benefit.image_file + and getattr(benefit.image_file, "name", "") + and not force + ): stats["skipped"] += 1 continue @@ -181,18 +222,30 @@ class Command(BaseCommand): Returns: Dictionary with download statistics (total, downloaded, skipped, failed, placeholders_404). """ - queryset: QuerySet[RewardCampaign] = RewardCampaign.objects.all().order_by("twitch_id") + queryset: QuerySet[RewardCampaign] = RewardCampaign.objects.all().order_by( + "twitch_id", + ) if limit: queryset = queryset[:limit] - stats: dict[str, int] = {"total": 0, "downloaded": 0, "skipped": 0, "failed": 0, "placeholders_404": 0} + stats: dict[str, int] = { + "total": 0, + "downloaded": 0, + "skipped": 0, + "failed": 0, + "placeholders_404": 0, + } stats["total"] = queryset.count() for reward_campaign in queryset: if not reward_campaign.image_url: stats["skipped"] += 1 continue - if reward_campaign.image_file and getattr(reward_campaign.image_file, "name", "") and not force: + if ( + reward_campaign.image_file + and getattr(reward_campaign.image_file, "name", "") + and not force + ): stats["skipped"] += 1 continue @@ -233,9 +286,7 @@ class Command(BaseCommand): response.raise_for_status() except httpx.HTTPError as exc: self.stdout.write( - self.style.WARNING( - f"Failed to download image for {twitch_id}: {exc}", - ), + self.style.WARNING(f"Failed to download image for {twitch_id}: {exc}"), ) return "failed" @@ -262,7 +313,11 @@ class Command(BaseCommand): """ try: source_path = Path(image_path) - if not source_path.exists() or source_path.suffix.lower() not in {".jpg", ".jpeg", ".png"}: + if not source_path.exists() or source_path.suffix.lower() not in { + ".jpg", + ".jpeg", + ".png", + }: return base_path = source_path.with_suffix("") @@ -271,10 +326,17 @@ class Command(BaseCommand): with Image.open(source_path) as img: # Convert to RGB if needed - if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info): + if img.mode in {"RGBA", "LA"} or ( + img.mode == "P" and "transparency" in img.info + ): background = Image.new("RGB", img.size, (255, 255, 255)) rgba_img = img.convert("RGBA") if img.mode == "P" else img - background.paste(rgba_img, mask=rgba_img.split()[-1] if rgba_img.mode in {"RGBA", "LA"} else None) + background.paste( + rgba_img, + mask=rgba_img.split()[-1] + if rgba_img.mode in {"RGBA", "LA"} + else None, + ) rgb_img = background elif img.mode != "RGB": rgb_img = img.convert("RGB") @@ -289,7 +351,9 @@ class Command(BaseCommand): except (OSError, ValueError) as e: # Don't fail the download if conversion fails - self.stdout.write(self.style.WARNING(f"Failed to convert {image_path}: {e}")) + self.stdout.write( + self.style.WARNING(f"Failed to convert {image_path}: {e}"), + ) def _merge_stats(self, total: dict[str, int], new: dict[str, int]) -> None: """Merge statistics from a single model into the total stats.""" diff --git a/twitch/management/commands/import_chat_badges.py b/twitch/management/commands/import_chat_badges.py index 405554d..e3df831 100644 --- a/twitch/management/commands/import_chat_badges.py +++ b/twitch/management/commands/import_chat_badges.py @@ -1,7 +1,5 @@ """Management command to import Twitch global chat badges.""" -from __future__ import annotations - import logging import os from typing import TYPE_CHECKING @@ -13,15 +11,16 @@ 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 django.core.management.base import CommandParser + + from twitch.schemas import ChatBadgeSetSchema from twitch.schemas import ChatBadgeVersionSchema logger: logging.Logger = logging.getLogger("ttvdrops") @@ -60,9 +59,15 @@ class Command(BaseCommand): 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") + 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 = ( @@ -84,7 +89,9 @@ class Command(BaseCommand): 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")) + 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 diff --git a/twitch/migrations/0001_initial.py b/twitch/migrations/0001_initial.py index d9cf85a..4847714 100644 --- a/twitch/migrations/0001_initial.py +++ b/twitch/migrations/0001_initial.py @@ -1,5 +1,5 @@ # Generated by Django 6.0 on 2025-12-11 10:49 -from __future__ import annotations + import django.db.models.deletion from django.db import migrations @@ -17,8 +17,19 @@ class Migration(migrations.Migration): migrations.CreateModel( name="Game", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("twitch_id", models.TextField(unique=True, verbose_name="Twitch game ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "twitch_id", + models.TextField(unique=True, verbose_name="Twitch game ID"), + ), ( "slug", models.TextField( @@ -30,8 +41,23 @@ class Migration(migrations.Migration): ), ), ("name", models.TextField(blank=True, default="", verbose_name="Name")), - ("display_name", models.TextField(blank=True, default="", verbose_name="Display name")), - ("box_art", models.URLField(blank=True, default="", max_length=500, verbose_name="Box art URL")), + ( + "display_name", + models.TextField( + blank=True, + default="", + verbose_name="Display name", + ), + ), + ( + "box_art", + models.URLField( + blank=True, + default="", + max_length=500, + verbose_name="Box art URL", + ), + ), ( "box_art_file", models.FileField( @@ -43,21 +69,33 @@ class Migration(migrations.Migration): ), ( "added_at", - models.DateTimeField(auto_now_add=True, help_text="Timestamp when this game record was created."), + models.DateTimeField( + auto_now_add=True, + help_text="Timestamp when this game record was created.", + ), ), ( "updated_at", - models.DateTimeField(auto_now=True, help_text="Timestamp when this game record was last updated."), + models.DateTimeField( + auto_now=True, + help_text="Timestamp when this game record was last updated.", + ), ), ], - options={ - "ordering": ["display_name"], - }, + options={"ordering": ["display_name"]}, ), migrations.CreateModel( name="Channel", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ( "twitch_id", models.TextField( @@ -66,7 +104,13 @@ class Migration(migrations.Migration): verbose_name="Channel ID", ), ), - ("name", models.TextField(help_text="The lowercase username of the channel.", verbose_name="Username")), + ( + "name", + models.TextField( + help_text="The lowercase username of the channel.", + verbose_name="Username", + ), + ), ( "display_name", models.TextField( @@ -92,23 +136,54 @@ class Migration(migrations.Migration): options={ "ordering": ["display_name"], "indexes": [ - models.Index(fields=["display_name"], name="twitch_chan_display_2bf213_idx"), + models.Index( + fields=["display_name"], + name="twitch_chan_display_2bf213_idx", + ), models.Index(fields=["name"], name="twitch_chan_name_15d566_idx"), - models.Index(fields=["twitch_id"], name="twitch_chan_twitch__c8bbc6_idx"), - models.Index(fields=["added_at"], name="twitch_chan_added_a_5ce7b4_idx"), - models.Index(fields=["updated_at"], name="twitch_chan_updated_828594_idx"), + models.Index( + fields=["twitch_id"], + name="twitch_chan_twitch__c8bbc6_idx", + ), + models.Index( + fields=["added_at"], + name="twitch_chan_added_a_5ce7b4_idx", + ), + models.Index( + fields=["updated_at"], + name="twitch_chan_updated_828594_idx", + ), ], }, ), migrations.CreateModel( name="DropBenefit", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ( "twitch_id", - models.TextField(editable=False, help_text="The Twitch ID for this benefit.", unique=True), + models.TextField( + editable=False, + help_text="The Twitch ID for this benefit.", + unique=True, + ), + ), + ( + "name", + models.TextField( + blank=True, + default="N/A", + help_text="Name of the drop benefit.", + ), ), - ("name", models.TextField(blank=True, default="N/A", help_text="Name of the drop benefit.")), ( "image_asset_url", models.URLField( @@ -130,7 +205,7 @@ class Migration(migrations.Migration): ( "created_at", models.DateTimeField( - help_text="Timestamp when the benefit was created. This is from Twitch API and not auto-generated.", # noqa: E501 + help_text="Timestamp when the benefit was created. This is from Twitch API and not auto-generated.", null=True, ), ), @@ -143,7 +218,10 @@ class Migration(migrations.Migration): ), ( "is_ios_available", - models.BooleanField(default=False, help_text="Whether the benefit is available on iOS."), + models.BooleanField( + default=False, + help_text="Whether the benefit is available on iOS.", + ), ), ( "distribution_type", @@ -172,20 +250,46 @@ class Migration(migrations.Migration): options={ "ordering": ["-created_at"], "indexes": [ - models.Index(fields=["-created_at"], name="twitch_drop_created_5d2280_idx"), - models.Index(fields=["twitch_id"], name="twitch_drop_twitch__6eab58_idx"), + models.Index( + fields=["-created_at"], + name="twitch_drop_created_5d2280_idx", + ), + models.Index( + fields=["twitch_id"], + name="twitch_drop_twitch__6eab58_idx", + ), models.Index(fields=["name"], name="twitch_drop_name_7125ff_idx"), - models.Index(fields=["distribution_type"], name="twitch_drop_distrib_08b224_idx"), - models.Index(fields=["is_ios_available"], name="twitch_drop_is_ios__5f3dcf_idx"), - models.Index(fields=["added_at"], name="twitch_drop_added_a_fba438_idx"), - models.Index(fields=["updated_at"], name="twitch_drop_updated_7aaae3_idx"), + models.Index( + fields=["distribution_type"], + name="twitch_drop_distrib_08b224_idx", + ), + models.Index( + fields=["is_ios_available"], + name="twitch_drop_is_ios__5f3dcf_idx", + ), + models.Index( + fields=["added_at"], + name="twitch_drop_added_a_fba438_idx", + ), + models.Index( + fields=["updated_at"], + name="twitch_drop_updated_7aaae3_idx", + ), ], }, ), migrations.CreateModel( name="DropBenefitEdge", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ( "entitlement_limit", models.PositiveIntegerField( @@ -220,16 +324,39 @@ class Migration(migrations.Migration): migrations.CreateModel( name="DropCampaign", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ( "twitch_id", - models.TextField(editable=False, help_text="The Twitch ID for this campaign.", unique=True), + models.TextField( + editable=False, + help_text="The Twitch ID for this campaign.", + unique=True, + ), ), ("name", models.TextField(help_text="Name of the drop campaign.")), - ("description", models.TextField(blank=True, help_text="Detailed description of the campaign.")), + ( + "description", + models.TextField( + blank=True, + help_text="Detailed description of the campaign.", + ), + ), ( "details_url", - models.URLField(blank=True, default="", help_text="URL with campaign details.", max_length=500), + models.URLField( + blank=True, + default="", + help_text="URL with campaign details.", + max_length=500, + ), ), ( "account_link_url", @@ -260,23 +387,40 @@ class Migration(migrations.Migration): ), ( "start_at", - models.DateTimeField(blank=True, help_text="Datetime when the campaign starts.", null=True), + models.DateTimeField( + blank=True, + help_text="Datetime when the campaign starts.", + null=True, + ), + ), + ( + "end_at", + models.DateTimeField( + blank=True, + help_text="Datetime when the campaign ends.", + null=True, + ), ), - ("end_at", models.DateTimeField(blank=True, help_text="Datetime when the campaign ends.", null=True)), ( "is_account_connected", - models.BooleanField(default=False, help_text="Indicates if the user account is linked."), + models.BooleanField( + default=False, + help_text="Indicates if the user account is linked.", + ), ), ( "allow_is_enabled", - models.BooleanField(default=True, help_text="Whether the campaign allows participation."), + models.BooleanField( + default=True, + help_text="Whether the campaign allows participation.", + ), ), ( "operation_name", models.TextField( blank=True, default="", - help_text="The GraphQL operation name used to fetch this campaign data (e.g., 'ViewerDropsDashboard').", # noqa: E501 + help_text="The GraphQL operation name used to fetch this campaign data (e.g., 'ViewerDropsDashboard').", ), ), ( @@ -313,14 +457,20 @@ class Migration(migrations.Migration): ), ), ], - options={ - "ordering": ["-start_at"], - }, + options={"ordering": ["-start_at"]}, ), migrations.CreateModel( name="Organization", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ( "twitch_id", models.TextField( @@ -332,7 +482,11 @@ class Migration(migrations.Migration): ), ( "name", - models.TextField(help_text="Display name of the organization.", unique=True, verbose_name="Name"), + models.TextField( + help_text="Display name of the organization.", + unique=True, + verbose_name="Name", + ), ), ( "added_at", @@ -355,9 +509,18 @@ class Migration(migrations.Migration): "ordering": ["name"], "indexes": [ models.Index(fields=["name"], name="twitch_orga_name_febe72_idx"), - models.Index(fields=["twitch_id"], name="twitch_orga_twitch__b89b29_idx"), - models.Index(fields=["added_at"], name="twitch_orga_added_a_8297ac_idx"), - models.Index(fields=["updated_at"], name="twitch_orga_updated_d7d431_idx"), + models.Index( + fields=["twitch_id"], + name="twitch_orga_twitch__b89b29_idx", + ), + models.Index( + fields=["added_at"], + name="twitch_orga_added_a_8297ac_idx", + ), + models.Index( + fields=["updated_at"], + name="twitch_orga_updated_d7d431_idx", + ), ], }, ), @@ -377,10 +540,22 @@ class Migration(migrations.Migration): migrations.CreateModel( name="TimeBasedDrop", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ( "twitch_id", - models.TextField(editable=False, help_text="The Twitch ID for this time-based drop.", unique=True), + models.TextField( + editable=False, + help_text="The Twitch ID for this time-based drop.", + unique=True, + ), ), ("name", models.TextField(help_text="Name of the time-based drop.")), ( @@ -400,9 +575,20 @@ class Migration(migrations.Migration): ), ( "start_at", - models.DateTimeField(blank=True, help_text="Datetime when this drop becomes available.", null=True), + models.DateTimeField( + blank=True, + help_text="Datetime when this drop becomes available.", + null=True, + ), + ), + ( + "end_at", + models.DateTimeField( + blank=True, + help_text="Datetime when this drop expires.", + null=True, + ), ), - ("end_at", models.DateTimeField(blank=True, help_text="Datetime when this drop expires.", null=True)), ( "added_at", models.DateTimeField( @@ -436,9 +622,7 @@ class Migration(migrations.Migration): ), ), ], - options={ - "ordering": ["start_at"], - }, + options={"ordering": ["start_at"]}, ), migrations.AddField( model_name="dropbenefitedge", @@ -452,7 +636,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="TwitchGameData", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ( "twitch_id", models.TextField( @@ -472,9 +664,24 @@ class Migration(migrations.Migration): verbose_name="Box art URL", ), ), - ("igdb_id", models.TextField(blank=True, default="", verbose_name="IGDB ID")), - ("added_at", models.DateTimeField(auto_now_add=True, help_text="Record creation time.")), - ("updated_at", models.DateTimeField(auto_now=True, help_text="Record last update time.")), + ( + "igdb_id", + models.TextField(blank=True, default="", verbose_name="IGDB ID"), + ), + ( + "added_at", + models.DateTimeField( + auto_now_add=True, + help_text="Record creation time.", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="Record last update time.", + ), + ), ( "game", models.ForeignKey( @@ -488,13 +695,14 @@ class Migration(migrations.Migration): ), ), ], - options={ - "ordering": ["name"], - }, + options={"ordering": ["name"]}, ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["-start_at"], name="twitch_drop_start_a_929f09_idx"), + index=models.Index( + fields=["-start_at"], + name="twitch_drop_start_a_929f09_idx", + ), ), migrations.AddIndex( model_name="dropcampaign", @@ -506,7 +714,10 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["twitch_id"], name="twitch_drop_twitch__b717a1_idx"), + index=models.Index( + fields=["twitch_id"], + name="twitch_drop_twitch__b717a1_idx", + ), ), migrations.AddIndex( model_name="dropcampaign", @@ -514,47 +725,80 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["description"], name="twitch_drop_descrip_5bc290_idx"), + index=models.Index( + fields=["description"], + name="twitch_drop_descrip_5bc290_idx", + ), ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["is_account_connected"], name="twitch_drop_is_acco_7e9078_idx"), + index=models.Index( + fields=["is_account_connected"], + name="twitch_drop_is_acco_7e9078_idx", + ), ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["allow_is_enabled"], name="twitch_drop_allow_i_b64555_idx"), + index=models.Index( + fields=["allow_is_enabled"], + name="twitch_drop_allow_i_b64555_idx", + ), ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["operation_name"], name="twitch_drop_operati_8cfeb5_idx"), + index=models.Index( + fields=["operation_name"], + name="twitch_drop_operati_8cfeb5_idx", + ), ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["added_at"], name="twitch_drop_added_a_babe28_idx"), + index=models.Index( + fields=["added_at"], + name="twitch_drop_added_a_babe28_idx", + ), ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["updated_at"], name="twitch_drop_updated_0df991_idx"), + index=models.Index( + fields=["updated_at"], + name="twitch_drop_updated_0df991_idx", + ), ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["game", "-start_at"], name="twitch_drop_game_id_5e9b01_idx"), + index=models.Index( + fields=["game", "-start_at"], + name="twitch_drop_game_id_5e9b01_idx", + ), ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["start_at", "end_at"], name="twitch_drop_start_a_6e5fb6_idx"), + index=models.Index( + fields=["start_at", "end_at"], + name="twitch_drop_start_a_6e5fb6_idx", + ), ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["start_at", "end_at", "game"], name="twitch_drop_start_a_b02d4c_idx"), + index=models.Index( + fields=["start_at", "end_at", "game"], + name="twitch_drop_start_a_b02d4c_idx", + ), ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["end_at", "-start_at"], name="twitch_drop_end_at_81e51b_idx"), + index=models.Index( + fields=["end_at", "-start_at"], + name="twitch_drop_end_at_81e51b_idx", + ), ), migrations.AddIndex( model_name="game", - index=models.Index(fields=["display_name"], name="twitch_game_display_a35ba3_idx"), + index=models.Index( + fields=["display_name"], + name="twitch_game_display_a35ba3_idx", + ), ), migrations.AddIndex( model_name="game", @@ -566,7 +810,10 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="game", - index=models.Index(fields=["twitch_id"], name="twitch_game_twitch__887f78_idx"), + index=models.Index( + fields=["twitch_id"], + name="twitch_game_twitch__887f78_idx", + ), ), migrations.AddIndex( model_name="game", @@ -574,19 +821,31 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="game", - index=models.Index(fields=["added_at"], name="twitch_game_added_a_9e7e19_idx"), + index=models.Index( + fields=["added_at"], + name="twitch_game_added_a_9e7e19_idx", + ), ), migrations.AddIndex( model_name="game", - index=models.Index(fields=["updated_at"], name="twitch_game_updated_01df03_idx"), + index=models.Index( + fields=["updated_at"], + name="twitch_game_updated_01df03_idx", + ), ), migrations.AddIndex( model_name="game", - index=models.Index(fields=["owner", "display_name"], name="twitch_game_owner_i_7f9043_idx"), + index=models.Index( + fields=["owner", "display_name"], + name="twitch_game_owner_i_7f9043_idx", + ), ), migrations.AddIndex( model_name="timebaseddrop", - index=models.Index(fields=["start_at"], name="twitch_time_start_a_13de4a_idx"), + index=models.Index( + fields=["start_at"], + name="twitch_time_start_a_13de4a_idx", + ), ), migrations.AddIndex( model_name="timebaseddrop", @@ -594,11 +853,17 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="timebaseddrop", - index=models.Index(fields=["campaign"], name="twitch_time_campaig_bbe349_idx"), + index=models.Index( + fields=["campaign"], + name="twitch_time_campaig_bbe349_idx", + ), ), migrations.AddIndex( model_name="timebaseddrop", - index=models.Index(fields=["twitch_id"], name="twitch_time_twitch__31707a_idx"), + index=models.Index( + fields=["twitch_id"], + name="twitch_time_twitch__31707a_idx", + ), ), migrations.AddIndex( model_name="timebaseddrop", @@ -606,31 +871,52 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="timebaseddrop", - index=models.Index(fields=["required_minutes_watched"], name="twitch_time_require_82c30c_idx"), + index=models.Index( + fields=["required_minutes_watched"], + name="twitch_time_require_82c30c_idx", + ), ), migrations.AddIndex( model_name="timebaseddrop", - index=models.Index(fields=["required_subs"], name="twitch_time_require_959431_idx"), + index=models.Index( + fields=["required_subs"], + name="twitch_time_require_959431_idx", + ), ), migrations.AddIndex( model_name="timebaseddrop", - index=models.Index(fields=["added_at"], name="twitch_time_added_a_a7de2e_idx"), + index=models.Index( + fields=["added_at"], + name="twitch_time_added_a_a7de2e_idx", + ), ), migrations.AddIndex( model_name="timebaseddrop", - index=models.Index(fields=["updated_at"], name="twitch_time_updated_9e9d9e_idx"), + index=models.Index( + fields=["updated_at"], + name="twitch_time_updated_9e9d9e_idx", + ), ), migrations.AddIndex( model_name="timebaseddrop", - index=models.Index(fields=["campaign", "start_at"], name="twitch_time_campaig_29ac87_idx"), + index=models.Index( + fields=["campaign", "start_at"], + name="twitch_time_campaig_29ac87_idx", + ), ), migrations.AddIndex( model_name="timebaseddrop", - index=models.Index(fields=["campaign", "required_minutes_watched"], name="twitch_time_campaig_920ae4_idx"), + index=models.Index( + fields=["campaign", "required_minutes_watched"], + name="twitch_time_campaig_920ae4_idx", + ), ), migrations.AddIndex( model_name="timebaseddrop", - index=models.Index(fields=["start_at", "end_at"], name="twitch_time_start_a_c481f1_idx"), + index=models.Index( + fields=["start_at", "end_at"], + name="twitch_time_start_a_c481f1_idx", + ), ), migrations.AddIndex( model_name="dropbenefitedge", @@ -638,23 +924,38 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="dropbenefitedge", - index=models.Index(fields=["benefit"], name="twitch_drop_benefit_c92c87_idx"), + index=models.Index( + fields=["benefit"], + name="twitch_drop_benefit_c92c87_idx", + ), ), migrations.AddIndex( model_name="dropbenefitedge", - index=models.Index(fields=["entitlement_limit"], name="twitch_drop_entitle_bee3a0_idx"), + index=models.Index( + fields=["entitlement_limit"], + name="twitch_drop_entitle_bee3a0_idx", + ), ), migrations.AddIndex( model_name="dropbenefitedge", - index=models.Index(fields=["added_at"], name="twitch_drop_added_a_2100ba_idx"), + index=models.Index( + fields=["added_at"], + name="twitch_drop_added_a_2100ba_idx", + ), ), migrations.AddIndex( model_name="dropbenefitedge", - index=models.Index(fields=["updated_at"], name="twitch_drop_updated_00e3f2_idx"), + index=models.Index( + fields=["updated_at"], + name="twitch_drop_updated_00e3f2_idx", + ), ), migrations.AddConstraint( model_name="dropbenefitedge", - constraint=models.UniqueConstraint(fields=("drop", "benefit"), name="unique_drop_benefit"), + constraint=models.UniqueConstraint( + fields=("drop", "benefit"), + name="unique_drop_benefit", + ), ), migrations.AddIndex( model_name="twitchgamedata", @@ -662,7 +963,10 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="twitchgamedata", - index=models.Index(fields=["twitch_id"], name="twitch_twit_twitch__2207e6_idx"), + index=models.Index( + fields=["twitch_id"], + name="twitch_twit_twitch__2207e6_idx", + ), ), migrations.AddIndex( model_name="twitchgamedata", @@ -670,14 +974,23 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="twitchgamedata", - index=models.Index(fields=["igdb_id"], name="twitch_twit_igdb_id_161335_idx"), + index=models.Index( + fields=["igdb_id"], + name="twitch_twit_igdb_id_161335_idx", + ), ), migrations.AddIndex( model_name="twitchgamedata", - index=models.Index(fields=["added_at"], name="twitch_twit_added_a_2f4f36_idx"), + index=models.Index( + fields=["added_at"], + name="twitch_twit_added_a_2f4f36_idx", + ), ), migrations.AddIndex( model_name="twitchgamedata", - index=models.Index(fields=["updated_at"], name="twitch_twit_updated_ca8c4b_idx"), + index=models.Index( + fields=["updated_at"], + name="twitch_twit_updated_ca8c4b_idx", + ), ), ] diff --git a/twitch/migrations/0002_alter_game_box_art.py b/twitch/migrations/0002_alter_game_box_art.py index 0684510..c9cca74 100644 --- a/twitch/migrations/0002_alter_game_box_art.py +++ b/twitch/migrations/0002_alter_game_box_art.py @@ -1,5 +1,5 @@ # Generated by Django 6.0 on 2026-01-05 20:47 -from __future__ import annotations + from django.db import migrations from django.db import models @@ -8,14 +8,18 @@ from django.db import models class Migration(migrations.Migration): """Alter box_art field to allow null values.""" - dependencies = [ - ("twitch", "0001_initial"), - ] + dependencies = [("twitch", "0001_initial")] operations = [ migrations.AlterField( model_name="game", name="box_art", - field=models.URLField(blank=True, default="", max_length=500, null=True, verbose_name="Box art URL"), + field=models.URLField( + blank=True, + default="", + max_length=500, + null=True, + verbose_name="Box art URL", + ), ), ] diff --git a/twitch/migrations/0003_remove_dropcampaign_twitch_drop_is_acco_7e9078_idx_and_more.py b/twitch/migrations/0003_remove_dropcampaign_twitch_drop_is_acco_7e9078_idx_and_more.py index f6aefea..e2f0e82 100644 --- a/twitch/migrations/0003_remove_dropcampaign_twitch_drop_is_acco_7e9078_idx_and_more.py +++ b/twitch/migrations/0003_remove_dropcampaign_twitch_drop_is_acco_7e9078_idx_and_more.py @@ -1,5 +1,5 @@ # Generated by Django 6.0 on 2026-01-05 22:29 -from __future__ import annotations + from django.db import migrations @@ -7,17 +7,12 @@ from django.db import migrations class Migration(migrations.Migration): """Remove is_account_connected field and its index from DropCampaign.""" - dependencies = [ - ("twitch", "0002_alter_game_box_art"), - ] + dependencies = [("twitch", "0002_alter_game_box_art")] operations = [ migrations.RemoveIndex( model_name="dropcampaign", name="twitch_drop_is_acco_7e9078_idx", ), - migrations.RemoveField( - model_name="dropcampaign", - name="is_account_connected", - ), + migrations.RemoveField(model_name="dropcampaign", name="is_account_connected"), ] diff --git a/twitch/migrations/0004_remove_game_twitch_game_owner_i_398fa9_idx_and_more.py b/twitch/migrations/0004_remove_game_twitch_game_owner_i_398fa9_idx_and_more.py index 00db410..7973efb 100644 --- a/twitch/migrations/0004_remove_game_twitch_game_owner_i_398fa9_idx_and_more.py +++ b/twitch/migrations/0004_remove_game_twitch_game_owner_i_398fa9_idx_and_more.py @@ -1,5 +1,5 @@ # Generated by Django 6.0.1 on 2026-01-09 20:52 -from __future__ import annotations + from django.db import migrations from django.db import models @@ -35,8 +35,5 @@ class Migration(migrations.Migration): verbose_name="Organizations", ), ), - migrations.RemoveField( - model_name="game", - name="owner", - ), + migrations.RemoveField(model_name="game", name="owner"), ] diff --git a/twitch/migrations/0005_add_reward_campaign.py b/twitch/migrations/0005_add_reward_campaign.py index 42add96..4d2c64d 100644 --- a/twitch/migrations/0005_add_reward_campaign.py +++ b/twitch/migrations/0005_add_reward_campaign.py @@ -1,5 +1,5 @@ # Generated by Django 6.0.1 on 2026-01-13 20:31 -from __future__ import annotations + import django.db.models.deletion from django.db import migrations @@ -17,35 +17,71 @@ class Migration(migrations.Migration): migrations.CreateModel( name="RewardCampaign", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ( "twitch_id", - models.TextField(editable=False, help_text="The Twitch ID for this reward campaign.", unique=True), + models.TextField( + editable=False, + help_text="The Twitch ID for this reward campaign.", + unique=True, + ), ), ("name", models.TextField(help_text="Name of the reward campaign.")), ( "brand", - models.TextField(blank=True, default="", help_text="Brand associated with the reward campaign."), + models.TextField( + blank=True, + default="", + help_text="Brand associated with the reward campaign.", + ), ), ( "starts_at", - models.DateTimeField(blank=True, help_text="Datetime when the reward campaign starts.", null=True), + models.DateTimeField( + blank=True, + help_text="Datetime when the reward campaign starts.", + null=True, + ), ), ( "ends_at", - models.DateTimeField(blank=True, help_text="Datetime when the reward campaign ends.", null=True), + models.DateTimeField( + blank=True, + help_text="Datetime when the reward campaign ends.", + null=True, + ), ), ( "status", - models.TextField(default="UNKNOWN", help_text="Status of the reward campaign.", max_length=50), + models.TextField( + default="UNKNOWN", + help_text="Status of the reward campaign.", + max_length=50, + ), ), ( "summary", - models.TextField(blank=True, default="", help_text="Summary description of the reward campaign."), + 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."), + models.TextField( + blank=True, + default="", + help_text="Instructions for the reward campaign.", + ), ), ( "external_url", @@ -58,7 +94,11 @@ class Migration(migrations.Migration): ), ( "reward_value_url_param", - models.TextField(blank=True, default="", help_text="URL parameter for reward value."), + models.TextField( + blank=True, + default="", + help_text="URL parameter for reward value.", + ), ), ( "about_url", @@ -71,7 +111,10 @@ class Migration(migrations.Migration): ), ( "is_sitewide", - models.BooleanField(default=False, help_text="Whether the reward campaign is sitewide."), + models.BooleanField( + default=False, + help_text="Whether the reward campaign is sitewide.", + ), ), ( "added_at", @@ -102,18 +145,48 @@ class Migration(migrations.Migration): options={ "ordering": ["-starts_at"], "indexes": [ - models.Index(fields=["-starts_at"], name="twitch_rewa_starts__4df564_idx"), - models.Index(fields=["ends_at"], name="twitch_rewa_ends_at_354b15_idx"), - models.Index(fields=["twitch_id"], name="twitch_rewa_twitch__797967_idx"), + models.Index( + fields=["-starts_at"], + name="twitch_rewa_starts__4df564_idx", + ), + models.Index( + fields=["ends_at"], + name="twitch_rewa_ends_at_354b15_idx", + ), + models.Index( + fields=["twitch_id"], + name="twitch_rewa_twitch__797967_idx", + ), models.Index(fields=["name"], name="twitch_rewa_name_f1e3dd_idx"), models.Index(fields=["brand"], name="twitch_rewa_brand_41c321_idx"), - models.Index(fields=["status"], name="twitch_rewa_status_a96d6b_idx"), - models.Index(fields=["is_sitewide"], name="twitch_rewa_is_site_7d2c9f_idx"), - models.Index(fields=["game"], name="twitch_rewa_game_id_678fbb_idx"), - models.Index(fields=["added_at"], name="twitch_rewa_added_a_ae3748_idx"), - models.Index(fields=["updated_at"], name="twitch_rewa_updated_fdf599_idx"), - models.Index(fields=["starts_at", "ends_at"], name="twitch_rewa_starts__dd909d_idx"), - models.Index(fields=["status", "-starts_at"], name="twitch_rewa_status_3641a4_idx"), + models.Index( + fields=["status"], + name="twitch_rewa_status_a96d6b_idx", + ), + models.Index( + fields=["is_sitewide"], + name="twitch_rewa_is_site_7d2c9f_idx", + ), + models.Index( + fields=["game"], + name="twitch_rewa_game_id_678fbb_idx", + ), + models.Index( + fields=["added_at"], + name="twitch_rewa_added_a_ae3748_idx", + ), + models.Index( + fields=["updated_at"], + name="twitch_rewa_updated_fdf599_idx", + ), + models.Index( + fields=["starts_at", "ends_at"], + name="twitch_rewa_starts__dd909d_idx", + ), + models.Index( + fields=["status", "-starts_at"], + name="twitch_rewa_status_3641a4_idx", + ), ], }, ), diff --git a/twitch/migrations/0006_add_chat_badges.py b/twitch/migrations/0006_add_chat_badges.py index 6181519..e6f7268 100644 --- a/twitch/migrations/0006_add_chat_badges.py +++ b/twitch/migrations/0006_add_chat_badges.py @@ -1,5 +1,5 @@ # 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 @@ -9,15 +9,21 @@ from django.db import models class Migration(migrations.Migration): """Add ChatBadgeSet and ChatBadge models for Twitch chat badges.""" - dependencies = [ - ("twitch", "0005_add_reward_campaign"), - ] + 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")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ( "set_id", models.TextField( @@ -46,16 +52,33 @@ class Migration(migrations.Migration): 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"), + 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")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ( "badge_id", models.TextField( @@ -87,10 +110,19 @@ class Migration(migrations.Migration): verbose_name="Image URL (72px)", ), ), - ("title", models.TextField(help_text="The title of the badge (e.g., 'VIP').", verbose_name="Title")), + ( + "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"), + models.TextField( + help_text="The description of the badge.", + verbose_name="Description", + ), ), ( "click_action", @@ -141,13 +173,30 @@ class Migration(migrations.Migration): 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=["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"), + 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", + ), ], - "constraints": [models.UniqueConstraint(fields=("badge_set", "badge_id"), name="unique_badge_set_id")], }, ), ] diff --git a/twitch/migrations/0007_rename_operation_name_to_operation_names.py b/twitch/migrations/0007_rename_operation_name_to_operation_names.py index c8cc5fb..55985e2 100644 --- a/twitch/migrations/0007_rename_operation_name_to_operation_names.py +++ b/twitch/migrations/0007_rename_operation_name_to_operation_names.py @@ -1,5 +1,5 @@ # Generated by Django 6.0.1 on 2026-01-17 05:32 -from __future__ import annotations + from django.db import migrations from django.db import models @@ -26,9 +26,7 @@ def reverse_operation_names_to_string(apps, schema_editor) -> None: # noqa: ARG class Migration(migrations.Migration): """Rename operation_name field to operation_names and convert to list.""" - dependencies = [ - ("twitch", "0006_add_chat_badges"), - ] + dependencies = [("twitch", "0006_add_chat_badges")] operations = [ migrations.RemoveIndex( @@ -41,7 +39,7 @@ class Migration(migrations.Migration): field=models.JSONField( blank=True, default=list, - help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).", # noqa: E501 + help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).", ), ), migrations.RunPython( @@ -50,10 +48,10 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="dropcampaign", - index=models.Index(fields=["operation_names"], name="twitch_drop_operati_fe3bc8_idx"), - ), - migrations.RemoveField( - model_name="dropcampaign", - name="operation_name", + index=models.Index( + fields=["operation_names"], + name="twitch_drop_operati_fe3bc8_idx", + ), ), + migrations.RemoveField(model_name="dropcampaign", name="operation_name"), ] diff --git a/twitch/migrations/0008_alter_channel_options_alter_chatbadge_options_and_more.py b/twitch/migrations/0008_alter_channel_options_alter_chatbadge_options_and_more.py index ab678c6..63fdd6d 100644 --- a/twitch/migrations/0008_alter_channel_options_alter_chatbadge_options_and_more.py +++ b/twitch/migrations/0008_alter_channel_options_alter_chatbadge_options_and_more.py @@ -1,25 +1,29 @@ # Generated by Django 6.0.2 on 2026-02-09 19:04 -from __future__ import annotations + import django.db.models.manager from django.db import migrations class Migration(migrations.Migration): - "Alter model options to use prefetch_manager as the base manager and set default ordering for better performance and consistent query results." # noqa: E501 + "Alter model options to use prefetch_manager as the base manager and set default ordering for better performance and consistent query results." - dependencies = [ - ("twitch", "0007_rename_operation_name_to_operation_names"), - ] + dependencies = [("twitch", "0007_rename_operation_name_to_operation_names")] operations = [ migrations.AlterModelOptions( name="channel", - options={"base_manager_name": "prefetch_manager", "ordering": ["display_name"]}, + options={ + "base_manager_name": "prefetch_manager", + "ordering": ["display_name"], + }, ), migrations.AlterModelOptions( name="chatbadge", - options={"base_manager_name": "prefetch_manager", "ordering": ["badge_set", "badge_id"]}, + options={ + "base_manager_name": "prefetch_manager", + "ordering": ["badge_set", "badge_id"], + }, ), migrations.AlterModelOptions( name="chatbadgeset", @@ -27,7 +31,10 @@ class Migration(migrations.Migration): ), migrations.AlterModelOptions( name="dropbenefit", - options={"base_manager_name": "prefetch_manager", "ordering": ["-created_at"]}, + options={ + "base_manager_name": "prefetch_manager", + "ordering": ["-created_at"], + }, ), migrations.AlterModelOptions( name="dropbenefitedge", @@ -35,11 +42,17 @@ class Migration(migrations.Migration): ), migrations.AlterModelOptions( name="dropcampaign", - options={"base_manager_name": "prefetch_manager", "ordering": ["-start_at"]}, + options={ + "base_manager_name": "prefetch_manager", + "ordering": ["-start_at"], + }, ), migrations.AlterModelOptions( name="game", - options={"base_manager_name": "prefetch_manager", "ordering": ["display_name"]}, + options={ + "base_manager_name": "prefetch_manager", + "ordering": ["display_name"], + }, ), migrations.AlterModelOptions( name="organization", @@ -47,7 +60,10 @@ class Migration(migrations.Migration): ), migrations.AlterModelOptions( name="rewardcampaign", - options={"base_manager_name": "prefetch_manager", "ordering": ["-starts_at"]}, + options={ + "base_manager_name": "prefetch_manager", + "ordering": ["-starts_at"], + }, ), migrations.AlterModelOptions( name="timebaseddrop", diff --git a/twitch/migrations/0009_alter_chatbadge_badge_set_and_more.py b/twitch/migrations/0009_alter_chatbadge_badge_set_and_more.py index 502578e..99e9d64 100644 --- a/twitch/migrations/0009_alter_chatbadge_badge_set_and_more.py +++ b/twitch/migrations/0009_alter_chatbadge_badge_set_and_more.py @@ -1,5 +1,5 @@ # Generated by Django 6.0.2 on 2026-02-09 19:05 -from __future__ import annotations + import auto_prefetch import django.db.models.deletion @@ -7,7 +7,7 @@ from django.db import migrations class Migration(migrations.Migration): - "Alter ChatBadge.badge_set to use auto_prefetch.ForeignKey and update related fields to use auto_prefetch.ForeignKey as well for better performance." # noqa: E501 + "Alter ChatBadge.badge_set to use auto_prefetch.ForeignKey and update related fields to use auto_prefetch.ForeignKey as well for better performance." dependencies = [ ("twitch", "0008_alter_channel_options_alter_chatbadge_options_and_more"), diff --git a/twitch/migrations/0010_rewardcampaign_image_file_rewardcampaign_image_url.py b/twitch/migrations/0010_rewardcampaign_image_file_rewardcampaign_image_url.py index d8b5a91..0e84d61 100644 --- a/twitch/migrations/0010_rewardcampaign_image_file_rewardcampaign_image_url.py +++ b/twitch/migrations/0010_rewardcampaign_image_file_rewardcampaign_image_url.py @@ -1,16 +1,14 @@ # Generated by Django 6.0.2 on 2026-02-11 22:55 -from __future__ import annotations + from django.db import migrations from django.db import models class Migration(migrations.Migration): - """Add image_file and image_url fields to RewardCampaign model for storing local file and original URL of campaign images.""" # noqa: E501 + """Add image_file and image_url fields to RewardCampaign model for storing local file and original URL of campaign images.""" - dependencies = [ - ("twitch", "0009_alter_chatbadge_badge_set_and_more"), - ] + dependencies = [("twitch", "0009_alter_chatbadge_badge_set_and_more")] operations = [ migrations.AddField( diff --git a/twitch/migrations/0011_dropbenefit_image_height_dropbenefit_image_width_and_more.py b/twitch/migrations/0011_dropbenefit_image_height_dropbenefit_image_width_and_more.py index 97e10ef..6cae230 100644 --- a/twitch/migrations/0011_dropbenefit_image_height_dropbenefit_image_width_and_more.py +++ b/twitch/migrations/0011_dropbenefit_image_height_dropbenefit_image_width_and_more.py @@ -1,12 +1,12 @@ # Generated by Django 6.0.2 on 2026-02-12 03:41 -from __future__ import annotations + from django.db import migrations from django.db import models class Migration(migrations.Migration): - """Add image height and width fields to DropBenefit, DropCampaign, Game, and RewardCampaign, then update ImageFields to use them.""" # noqa: E501 + """Add image height and width fields to DropBenefit, DropCampaign, Game, and RewardCampaign, then update ImageFields to use them.""" dependencies = [ ("twitch", "0010_rewardcampaign_image_file_rewardcampaign_image_url"), diff --git a/twitch/migrations/0012_dropcampaign_operation_names_gin_index.py b/twitch/migrations/0012_dropcampaign_operation_names_gin_index.py index 30cb566..d1890c8 100644 --- a/twitch/migrations/0012_dropcampaign_operation_names_gin_index.py +++ b/twitch/migrations/0012_dropcampaign_operation_names_gin_index.py @@ -1,5 +1,5 @@ # Generated by Django 6.0.2 on 2026-02-12 12:00 -from __future__ import annotations + from django.contrib.postgres.indexes import GinIndex from django.db import migrations @@ -19,6 +19,9 @@ class Migration(migrations.Migration): ), migrations.AddIndex( model_name="dropcampaign", - index=GinIndex(fields=["operation_names"], name="twitch_drop_operati_gin_idx"), + index=GinIndex( + fields=["operation_names"], + name="twitch_drop_operati_gin_idx", + ), ), ] diff --git a/twitch/models.py b/twitch/models.py index 7d88c28..5f704fd 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging from typing import TYPE_CHECKING @@ -87,20 +85,12 @@ class Game(auto_prefetch.Model): 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(blank=True, default="", verbose_name="Name") + display_name = models.TextField(blank=True, default="", verbose_name="Display name") box_art = models.URLField( # noqa: DJ001 max_length=500, blank=True, - null=True, # We allow null here to distinguish between no box art and empty string + null=True, default="", verbose_name="Box art URL", ) @@ -243,7 +233,9 @@ class TwitchGameData(auto_prefetch.Model): blank=True, default="", verbose_name="Box art URL", - help_text=("URL template with {width}x{height} placeholders for the box art image."), + 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") @@ -322,9 +314,7 @@ class DropCampaign(auto_prefetch.Model): editable=False, help_text="The Twitch ID for this campaign.", ) - 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.", @@ -399,7 +389,7 @@ class DropCampaign(auto_prefetch.Model): operation_names = models.JSONField( default=list, blank=True, - help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).", # noqa: E501 + help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).", ) added_at = models.DateTimeField( @@ -486,10 +476,7 @@ class DropCampaign(auto_prefetch.Model): if self.image_file and getattr(self.image_file, "url", None): return self.image_file.url except (AttributeError, OSError, ValueError) as exc: - logger.debug( - "Failed to resolve DropCampaign.image_file url: %s", - exc, - ) + logger.debug("Failed to resolve DropCampaign.image_file url: %s", exc) if self.image_url: return self.image_url @@ -507,8 +494,9 @@ class DropCampaign(auto_prefetch.Model): def duration_iso(self) -> str: """Return the campaign duration in ISO 8601 format (e.g., 'P3DT4H30M'). - This is used for the