Lower line-length to default and don't add from __future__ import annotations to everything
This commit is contained in:
parent
dcc4cecb8d
commit
1118c03c1b
46 changed files with 2338 additions and 1085 deletions
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -39,7 +37,11 @@ def env_int(key: str, default: int) -> int:
|
||||||
|
|
||||||
|
|
||||||
DEBUG: bool = env_bool(key="DEBUG", default=True)
|
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:
|
def get_data_dir() -> Path:
|
||||||
|
|
@ -118,28 +120,11 @@ if not DEBUG:
|
||||||
LOGGING: dict[str, Any] = {
|
LOGGING: dict[str, Any] = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
"handlers": {
|
"handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler"}},
|
||||||
"console": {
|
|
||||||
"level": "DEBUG",
|
|
||||||
"class": "logging.StreamHandler",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"loggers": {
|
"loggers": {
|
||||||
"": {
|
"": {"handlers": ["console"], "level": "INFO", "propagate": True},
|
||||||
"handlers": ["console"],
|
"ttvdrops": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||||
"level": "INFO",
|
"django": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
||||||
"propagate": True,
|
|
||||||
},
|
|
||||||
"ttvdrops": {
|
|
||||||
"handlers": ["console"],
|
|
||||||
"level": "DEBUG",
|
|
||||||
"propagate": False,
|
|
||||||
},
|
|
||||||
"django": {
|
|
||||||
"handlers": ["console"],
|
|
||||||
"level": "INFO",
|
|
||||||
"propagate": False,
|
|
||||||
},
|
|
||||||
"django.utils.autoreload": {
|
"django.utils.autoreload": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
|
|
@ -179,12 +164,7 @@ TEMPLATES: list[dict[str, Any]] = [
|
||||||
]
|
]
|
||||||
|
|
||||||
DATABASES: dict[str, 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
|
if TESTING
|
||||||
else {
|
else {
|
||||||
"default": {
|
"default": {
|
||||||
|
|
@ -196,19 +176,13 @@ DATABASES: dict[str, dict[str, Any]] = (
|
||||||
"PORT": env_int("POSTGRES_PORT", 5432),
|
"PORT": env_int("POSTGRES_PORT", 5432),
|
||||||
"CONN_MAX_AGE": env_int("CONN_MAX_AGE", 60),
|
"CONN_MAX_AGE": env_int("CONN_MAX_AGE", 60),
|
||||||
"CONN_HEALTH_CHECKS": env_bool("CONN_HEALTH_CHECKS", default=True),
|
"CONN_HEALTH_CHECKS": env_bool("CONN_HEALTH_CHECKS", default=True),
|
||||||
"OPTIONS": {
|
"OPTIONS": {"connect_timeout": env_int("DB_CONNECT_TIMEOUT", 10)},
|
||||||
"connect_timeout": env_int("DB_CONNECT_TIMEOUT", 10),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if not TESTING:
|
if not TESTING:
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [*INSTALLED_APPS, "debug_toolbar", "silk"]
|
||||||
*INSTALLED_APPS,
|
|
||||||
"debug_toolbar",
|
|
||||||
"silk",
|
|
||||||
]
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||||
"silk.middleware.SilkyMiddleware",
|
"silk.middleware.SilkyMiddleware",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -42,7 +40,10 @@ def reload_settings_module() -> Generator[Callable[..., ModuleType]]:
|
||||||
|
|
||||||
def _reload(**env_overrides: str | None) -> ModuleType:
|
def _reload(**env_overrides: str | None) -> ModuleType:
|
||||||
env: dict[str, str] = os.environ.copy()
|
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():
|
for key, value in env_overrides.items():
|
||||||
if value is None:
|
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
|
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."""
|
"""get_data_dir should use platformdirs and create the directory."""
|
||||||
fake_dir: Path = tmp_path / "data_dir"
|
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
|
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."""
|
"""When DEBUG is false, ALLOWED_HOSTS should use the production host."""
|
||||||
reloaded: ModuleType = reload_settings_module(DEBUG="false")
|
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"]
|
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."""
|
"""When DEBUG is true, development hostnames should be allowed."""
|
||||||
reloaded: ModuleType = reload_settings_module(DEBUG="1")
|
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"]
|
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."""
|
"""DEBUG should default to True when the environment variable is missing."""
|
||||||
reloaded: ModuleType = reload_settings_module(DEBUG=None)
|
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
|
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."""
|
"""TESTING should be true when PYTEST_VERSION is set in the env."""
|
||||||
reloaded: ModuleType = reload_settings_module(PYTEST_VERSION="7.0.0")
|
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")
|
__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."""
|
"""EMAIL_* values should be read from the environment and cast correctly."""
|
||||||
reloaded: ModuleType = reload_settings_module(
|
reloaded: ModuleType = reload_settings_module(
|
||||||
EMAIL_HOST="smtp.example.com",
|
EMAIL_HOST="smtp.example.com",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -21,10 +19,7 @@ urlpatterns: list[URLPattern | URLResolver] = [
|
||||||
|
|
||||||
# Serve media in development
|
# Serve media in development
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static(
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
settings.MEDIA_URL,
|
|
||||||
document_root=settings.MEDIA_ROOT,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not settings.TESTING:
|
if not settings.TESTING:
|
||||||
from debug_toolbar.toolbar import debug_toolbar_urls
|
from debug_toolbar.toolbar import debug_toolbar_urls
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,15 +45,22 @@ filterwarnings = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[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"]
|
lint.select = ["ALL"]
|
||||||
|
|
||||||
# Don't automatically remove unused variables
|
# Don't automatically remove unused variables
|
||||||
lint.unfixable = ["F841"]
|
lint.unfixable = ["F841"]
|
||||||
|
|
||||||
lint.pydocstyle.convention = "google"
|
|
||||||
lint.isort.required-imports = ["from __future__ import annotations"]
|
|
||||||
lint.isort.force-single-line = true
|
|
||||||
|
|
||||||
lint.ignore = [
|
lint.ignore = [
|
||||||
"ANN002", # Checks that function *args arguments have type annotations.
|
"ANN002", # Checks that function *args arguments have type annotations.
|
||||||
"ANN003", # Checks that function **kwargs 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.
|
"D104", # Checks for undocumented public package definitions.
|
||||||
"D105", # Checks for undocumented magic method definitions.
|
"D105", # Checks for undocumented magic method definitions.
|
||||||
"D106", # Checks for undocumented public class definitions, for nested classes.
|
"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.
|
"ERA001", # Checks for commented-out Python code.
|
||||||
"FIX002", # Checks for "TODO" comments.
|
"FIX002", # Checks for "TODO" comments.
|
||||||
"PLR0911", # Checks for functions or methods with too many return statements.
|
"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.
|
"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.
|
"W191", # Checks for indentation that uses tabs.
|
||||||
]
|
]
|
||||||
preview = true
|
|
||||||
unsafe-fixes = true
|
|
||||||
fix = true
|
|
||||||
line-length = 120
|
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"**/tests/**" = [
|
"**/tests/**" = [
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
200
twitch/feeds.py
200
twitch/feeds.py
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
@ -14,12 +12,10 @@ from django.utils import feedgenerator
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.html import format_html_join
|
from django.utils.html import format_html_join
|
||||||
from django.utils.safestring import SafeString
|
|
||||||
from django.utils.safestring import SafeText
|
from django.utils.safestring import SafeText
|
||||||
|
|
||||||
from twitch.models import Channel
|
from twitch.models import Channel
|
||||||
from twitch.models import ChatBadge
|
from twitch.models import ChatBadge
|
||||||
from twitch.models import DropBenefit
|
|
||||||
from twitch.models import DropCampaign
|
from twitch.models import DropCampaign
|
||||||
from twitch.models import Game
|
from twitch.models import Game
|
||||||
from twitch.models import Organization
|
from twitch.models import Organization
|
||||||
|
|
@ -33,6 +29,9 @@ if TYPE_CHECKING:
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
from django.utils.safestring import SafeString
|
||||||
|
|
||||||
|
from twitch.models import DropBenefit
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("ttvdrops")
|
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:
|
if start_at or end_at:
|
||||||
start_part: SafeString = (
|
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
|
if start_at
|
||||||
else SafeText("")
|
else SafeText("")
|
||||||
)
|
)
|
||||||
end_part: SafeString = (
|
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
|
if end_at
|
||||||
else SafeText("")
|
else SafeText("")
|
||||||
)
|
)
|
||||||
|
|
@ -130,7 +137,10 @@ def _build_drops_data(drops_qs: QuerySet[TimeBasedDrop]) -> list[dict]:
|
||||||
return drops_data
|
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 <li>, then a count of additional channels, or fallback to game category link.
|
"""Render up to max_links channel links as <li>, then a count of additional channels, or fallback to game category link.
|
||||||
|
|
||||||
If only one channel and drop_requirements is '1 subscriptions required',
|
If only one channel and drop_requirements is '1 subscriptions required',
|
||||||
|
|
@ -142,9 +152,11 @@ def _build_channels_html(channels: list[Channel] | QuerySet[Channel], game: Game
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
SafeText: HTML <ul> with up to max_links channel links, count of more, or fallback link.
|
SafeText: HTML <ul> with up to max_links channel links, count of more, or fallback link.
|
||||||
""" # noqa: E501
|
"""
|
||||||
max_links = 5
|
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)
|
total: int = len(channels_all)
|
||||||
|
|
||||||
if channels_all:
|
if channels_all:
|
||||||
|
|
@ -166,18 +178,31 @@ def _build_channels_html(channels: list[Channel] | QuerySet[Channel], game: Game
|
||||||
)
|
)
|
||||||
|
|
||||||
if not game:
|
if not game:
|
||||||
logger.warning("No game associated with drop campaign for channel fallback link")
|
logger.warning(
|
||||||
return format_html("{}", "<ul><li>Drop has no game and no channels connected to the drop.</li></ul>")
|
"No game associated with drop campaign for channel fallback link",
|
||||||
|
)
|
||||||
|
return format_html(
|
||||||
|
"{}",
|
||||||
|
"<ul><li>Drop has no game and no channels connected to the drop.</li></ul>",
|
||||||
|
)
|
||||||
|
|
||||||
if not game.twitch_directory_url:
|
if not game.twitch_directory_url:
|
||||||
logger.warning("Game %s has no Twitch directory URL for channel fallback link", game)
|
logger.warning(
|
||||||
if getattr(game, "details_url", "") == "https://help.twitch.tv/s/article/twitch-chat-badges-guide ":
|
"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
|
# TODO(TheLovinator): Improve detection of global emotes # noqa: TD003
|
||||||
return format_html("{}", "<ul><li>Global Twitch Emote?</li></ul>")
|
return format_html("{}", "<ul><li>Global Twitch Emote?</li></ul>")
|
||||||
|
|
||||||
return format_html("{}", "<ul><li>Failed to get Twitch category URL :(</li></ul>")
|
return format_html(
|
||||||
|
"{}",
|
||||||
|
"<ul><li>Failed to get Twitch category URL :(</li></ul>",
|
||||||
|
)
|
||||||
|
|
||||||
# If no channel is associated, the drop is category-wide; link to the game's Twitch directory
|
|
||||||
display_name: str = getattr(game, "display_name", "this game")
|
display_name: str = getattr(game, "display_name", "this game")
|
||||||
return format_html(
|
return format_html(
|
||||||
'<ul><li><a href="{}" title="Browse {} category">Category-wide for {}</a></li></ul>',
|
'<ul><li><a href="{}" title="Browse {} category">Category-wide for {}</a></li></ul>',
|
||||||
|
|
@ -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.
|
"""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:
|
Args:
|
||||||
drops_data (list[dict]): List of drop data dicts.
|
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()
|
badge_titles: set[str] = set()
|
||||||
for drop in drops_data:
|
for drop in drops_data:
|
||||||
for b in drop.get("benefits", []):
|
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_titles.add(b.name)
|
||||||
|
|
||||||
badge_descriptions_by_title: dict[str, str] = {}
|
badge_descriptions_by_title: dict[str, str] = {}
|
||||||
if badge_titles:
|
if badge_titles:
|
||||||
badge_descriptions_by_title = dict(
|
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]:
|
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:
|
for drop in sorted_drops:
|
||||||
requirements: str = drop.get("requirements", "")
|
requirements: str = drop.get("requirements", "")
|
||||||
benefits: list[DropBenefit] = drop.get("benefits", [])
|
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]] = []
|
benefit_names: list[tuple[str]] = []
|
||||||
for b in benefits:
|
for b in benefits:
|
||||||
benefit_name: str = getattr(b, "name", str(b))
|
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,
|
benefit_name,
|
||||||
)
|
)
|
||||||
if badge_desc:
|
if badge_desc:
|
||||||
benefit_names.append((format_html("{} (<em>{}</em>)", linked_name, badge_desc),))
|
benefit_names.append((
|
||||||
|
format_html("{} (<em>{}</em>)", linked_name, badge_desc),
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
benefit_names.append((linked_name,))
|
benefit_names.append((linked_name,))
|
||||||
elif badge_desc:
|
elif badge_desc:
|
||||||
benefit_names.append((format_html("{} (<em>{}</em>)", benefit_name, badge_desc),))
|
benefit_names.append((
|
||||||
|
format_html("{} (<em>{}</em>)", benefit_name, badge_desc),
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
benefit_names.append((benefit_name,))
|
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:
|
if requirements:
|
||||||
items.append(format_html("<li>{}: {}</li>", requirements, benefits_str))
|
items.append(format_html("<li>{}: {}</li>", requirements, benefits_str))
|
||||||
else:
|
else:
|
||||||
items.append(format_html("<li>{}</li>", benefits_str))
|
items.append(format_html("<li>{}</li>", benefits_str))
|
||||||
return format_html("<ul>{}</ul>", format_html_join("", "{}", [(item,) for item in items]))
|
return format_html(
|
||||||
|
"<ul>{}</ul>",
|
||||||
|
format_html_join("", "{}", [(item,) for item in items]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# MARK: /rss/organizations/
|
# MARK: /rss/organizations/
|
||||||
|
|
@ -265,7 +314,12 @@ class OrganizationRSSFeed(Feed):
|
||||||
feed_copyright: str = "Information wants to be free."
|
feed_copyright: str = "Information wants to be free."
|
||||||
_limit: int | None = None
|
_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.
|
"""Override to capture limit parameter from request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -332,7 +386,12 @@ class GameFeed(Feed):
|
||||||
feed_copyright: str = "Information wants to be free."
|
feed_copyright: str = "Information wants to be free."
|
||||||
_limit: int | None = None
|
_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.
|
"""Override to capture limit parameter from request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -375,7 +434,9 @@ class GameFeed(Feed):
|
||||||
|
|
||||||
if box_art:
|
if box_art:
|
||||||
description_parts.append(
|
description_parts.append(
|
||||||
SafeText(f"<img src='{box_art}' alt='Box Art for {game_name}' width='600' height='800' />"),
|
SafeText(
|
||||||
|
f"<img src='{box_art}' alt='Box Art for {game_name}' width='600' height='800' />",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if slug:
|
if slug:
|
||||||
|
|
@ -456,7 +517,12 @@ class DropCampaignFeed(Feed):
|
||||||
feed_copyright: str = "Information wants to be free."
|
feed_copyright: str = "Information wants to be free."
|
||||||
_limit: int | None = None
|
_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.
|
"""Override to capture limit parameter from request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -475,7 +541,7 @@ class DropCampaignFeed(Feed):
|
||||||
return super().__call__(request, *args, **kwargs)
|
return super().__call__(request, *args, **kwargs)
|
||||||
|
|
||||||
def items(self) -> list[DropCampaign]:
|
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
|
limit: int = self._limit if self._limit is not None else 200
|
||||||
queryset: QuerySet[DropCampaign] = DropCampaign.objects.order_by("-start_at")
|
queryset: QuerySet[DropCampaign] = DropCampaign.objects.order_by("-start_at")
|
||||||
return list(_with_campaign_related(queryset)[:limit])
|
return list(_with_campaign_related(queryset)[:limit])
|
||||||
|
|
@ -500,7 +566,11 @@ class DropCampaignFeed(Feed):
|
||||||
if image_url:
|
if image_url:
|
||||||
item_name: str = getattr(item, "name", str(object=item))
|
item_name: str = getattr(item, "name", str(object=item))
|
||||||
parts.append(
|
parts.append(
|
||||||
format_html('<img src="{}" alt="{}" width="160" height="160" />', image_url, item_name),
|
format_html(
|
||||||
|
'<img src="{}" alt="{}" width="160" height="160" />',
|
||||||
|
image_url,
|
||||||
|
item_name,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
desc_text: str | None = getattr(item, "description", None)
|
desc_text: str | None = getattr(item, "description", None)
|
||||||
|
|
@ -511,7 +581,12 @@ class DropCampaignFeed(Feed):
|
||||||
insert_date_info(item, parts)
|
insert_date_info(item, parts)
|
||||||
|
|
||||||
if drops_data:
|
if drops_data:
|
||||||
parts.append(format_html("<p>{}</p>", _construct_drops_summary(drops_data, channel_name=channel_name)))
|
parts.append(
|
||||||
|
format_html(
|
||||||
|
"<p>{}</p>",
|
||||||
|
_construct_drops_summary(drops_data, channel_name=channel_name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Only show channels if drop is not subscription only
|
# Only show channels if drop is not subscription only
|
||||||
if not getattr(item, "is_subscription_only", False) and channels is not None:
|
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."
|
feed_copyright: str = "Information wants to be free."
|
||||||
_limit: int | None = None
|
_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.
|
"""Override to capture limit parameter from request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -620,9 +700,11 @@ class GameCampaignFeed(Feed):
|
||||||
return reverse("twitch:game_campaign_feed", args=[obj.twitch_id])
|
return reverse("twitch:game_campaign_feed", args=[obj.twitch_id])
|
||||||
|
|
||||||
def items(self, obj: Game) -> list[DropCampaign]:
|
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
|
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])
|
return list(_with_campaign_related(queryset)[:limit])
|
||||||
|
|
||||||
def item_title(self, item: DropCampaign) -> SafeText:
|
def item_title(self, item: DropCampaign) -> SafeText:
|
||||||
|
|
@ -645,7 +727,11 @@ class GameCampaignFeed(Feed):
|
||||||
if image_url:
|
if image_url:
|
||||||
item_name: str = getattr(item, "name", str(object=item))
|
item_name: str = getattr(item, "name", str(object=item))
|
||||||
parts.append(
|
parts.append(
|
||||||
format_html('<img src="{}" alt="{}" width="160" height="160" />', image_url, item_name),
|
format_html(
|
||||||
|
'<img src="{}" alt="{}" width="160" height="160" />',
|
||||||
|
image_url,
|
||||||
|
item_name,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
desc_text: str | None = getattr(item, "description", None)
|
desc_text: str | None = getattr(item, "description", None)
|
||||||
|
|
@ -656,7 +742,12 @@ class GameCampaignFeed(Feed):
|
||||||
insert_date_info(item, parts)
|
insert_date_info(item, parts)
|
||||||
|
|
||||||
if drops_data:
|
if drops_data:
|
||||||
parts.append(format_html("<p>{}</p>", _construct_drops_summary(drops_data, channel_name=channel_name)))
|
parts.append(
|
||||||
|
format_html(
|
||||||
|
"<p>{}</p>",
|
||||||
|
_construct_drops_summary(drops_data, channel_name=channel_name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Only show channels if drop is not subscription only
|
# Only show channels if drop is not subscription only
|
||||||
if not getattr(item, "is_subscription_only", False) and channels is not None:
|
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)
|
account_link_url: str | None = getattr(item, "account_link_url", None)
|
||||||
if account_link_url:
|
if account_link_url:
|
||||||
parts.append(format_html(' | <a href="{}">Link Account</a>', account_link_url))
|
parts.append(
|
||||||
|
format_html(' | <a href="{}">Link Account</a>', account_link_url),
|
||||||
|
)
|
||||||
|
|
||||||
return SafeText("".join(str(p) for p in parts))
|
return SafeText("".join(str(p) for p in parts))
|
||||||
|
|
||||||
|
|
@ -723,7 +816,12 @@ class OrganizationCampaignFeed(Feed):
|
||||||
|
|
||||||
_limit: int | None = None
|
_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.
|
"""Override to capture limit parameter from request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -766,9 +864,11 @@ class OrganizationCampaignFeed(Feed):
|
||||||
return f"Latest drop campaigns for organization {obj.name}"
|
return f"Latest drop campaigns for organization {obj.name}"
|
||||||
|
|
||||||
def items(self, obj: Organization) -> list[DropCampaign]:
|
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
|
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])
|
return list(_with_campaign_related(queryset)[:limit])
|
||||||
|
|
||||||
def item_author_name(self, item: DropCampaign) -> str:
|
def item_author_name(self, item: DropCampaign) -> str:
|
||||||
|
|
@ -829,7 +929,11 @@ class OrganizationCampaignFeed(Feed):
|
||||||
if image_url:
|
if image_url:
|
||||||
item_name: str = getattr(item, "name", str(object=item))
|
item_name: str = getattr(item, "name", str(object=item))
|
||||||
parts.append(
|
parts.append(
|
||||||
format_html('<img src="{}" alt="{}" width="160" height="160" />', image_url, item_name),
|
format_html(
|
||||||
|
'<img src="{}" alt="{}" width="160" height="160" />',
|
||||||
|
image_url,
|
||||||
|
item_name,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
desc_text: str | None = getattr(item, "description", None)
|
desc_text: str | None = getattr(item, "description", None)
|
||||||
|
|
@ -840,7 +944,12 @@ class OrganizationCampaignFeed(Feed):
|
||||||
insert_date_info(item, parts)
|
insert_date_info(item, parts)
|
||||||
|
|
||||||
if drops_data:
|
if drops_data:
|
||||||
parts.append(format_html("<p>{}</p>", _construct_drops_summary(drops_data, channel_name=channel_name)))
|
parts.append(
|
||||||
|
format_html(
|
||||||
|
"<p>{}</p>",
|
||||||
|
_construct_drops_summary(drops_data, channel_name=channel_name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Only show channels if drop is not subscription only
|
# Only show channels if drop is not subscription only
|
||||||
if not getattr(item, "is_subscription_only", False) and channels is not None:
|
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."
|
feed_copyright: str = "Information wants to be free."
|
||||||
_limit: int | None = None
|
_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.
|
"""Override to capture limit parameter from request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Management command to backfill image dimensions for existing cached images."""
|
"""Management command to backfill image dimensions for existing cached images."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from twitch.models import DropBenefit
|
from twitch.models import DropBenefit
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -79,7 +77,10 @@ class Command(BaseCommand):
|
||||||
msg = f"Unsupported database backend: {django_connection.vendor}"
|
msg = f"Unsupported database backend: {django_connection.vendor}"
|
||||||
raise CommandError(msg)
|
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.stdout.write(
|
||||||
self.style.SUCCESS(
|
self.style.SUCCESS(
|
||||||
f"Backup created: {output_path} (updated {created_at.isoformat()})",
|
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()]
|
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.
|
"""Write a SQL dump containing schema and data for the requested tables.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -154,7 +159,11 @@ def _get_table_schema(connection: sqlite3.Connection, table: str) -> str:
|
||||||
return row[0] if row and row[0] else ""
|
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.
|
"""Write INSERT statements for a table.
|
||||||
|
|
||||||
Args:
|
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
|
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.
|
"""Write CREATE INDEX statements for included tables.
|
||||||
|
|
||||||
Args:
|
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."
|
msg = "pg_dump process did not provide stdout or stderr."
|
||||||
raise CommandError(msg)
|
raise CommandError(msg)
|
||||||
|
|
||||||
with (
|
with output_path.open("wb") as raw_handle, zstd.open(raw_handle, "w") as compressed:
|
||||||
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]
|
for chunk in iter(lambda: process.stdout.read(64 * 1024), b""): # pyright: ignore[reportOptionalMemberAccess]
|
||||||
compressed.write(chunk)
|
compressed.write(chunk)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from urllib.parse import urlparse
|
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.files.base import ContentFile
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
from django.core.management.base import CommandParser
|
|
||||||
from json_repair import JSONReturnType
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
|
@ -31,21 +28,26 @@ from twitch.models import Game
|
||||||
from twitch.models import Organization
|
from twitch.models import Organization
|
||||||
from twitch.models import RewardCampaign
|
from twitch.models import RewardCampaign
|
||||||
from twitch.models import TimeBasedDrop
|
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 GraphQLResponse
|
||||||
from twitch.schemas import OrganizationSchema
|
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 is_twitch_box_art_url
|
||||||
from twitch.utils import normalize_twitch_box_art_url
|
from twitch.utils import normalize_twitch_box_art_url
|
||||||
from twitch.utils import parse_date
|
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:
|
def get_broken_directory_root() -> Path:
|
||||||
"""Get the root broken directory path from environment or default.
|
"""Get the root broken directory path from environment or default.
|
||||||
|
|
@ -83,10 +85,7 @@ def get_imported_directory_root() -> Path:
|
||||||
return home / "ttvdrops" / "imported"
|
return home / "ttvdrops" / "imported"
|
||||||
|
|
||||||
|
|
||||||
def _build_broken_directory(
|
def _build_broken_directory(reason: str, operation_name: str | None = None) -> Path:
|
||||||
reason: str,
|
|
||||||
operation_name: str | None = None,
|
|
||||||
) -> Path:
|
|
||||||
"""Compute a deeply nested broken directory for triage.
|
"""Compute a deeply nested broken directory for triage.
|
||||||
|
|
||||||
Directory pattern: <broken_root>/<reason>/<operation>/<YYYY>/<MM>/<DD>
|
Directory pattern: <broken_root>/<reason>/<operation>/<YYYY>/<MM>/<DD>
|
||||||
|
|
@ -104,16 +103,32 @@ def _build_broken_directory(
|
||||||
|
|
||||||
# If operation_name matches reason, skip it to avoid duplicate directories
|
# If operation_name matches reason, skip it to avoid duplicate directories
|
||||||
if operation_name and operation_name.replace(" ", "_") == safe_reason:
|
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:
|
else:
|
||||||
op_segment: str = (operation_name or "unknown_op").replace(" ", "_")
|
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)
|
broken_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return broken_dir
|
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.
|
"""Moves a file that failed validation to a 'broken' subdirectory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -178,7 +193,12 @@ def move_completed_file(
|
||||||
Returns:
|
Returns:
|
||||||
Path to the directory where the file was moved.
|
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
|
target_dir: Path = get_imported_directory_root() / safe_op
|
||||||
|
|
||||||
if campaign_structure:
|
if campaign_structure:
|
||||||
|
|
@ -249,7 +269,12 @@ def detect_error_only_response(
|
||||||
errors: Any = item.get("errors")
|
errors: Any = item.get("errors")
|
||||||
data: Any = item.get("data")
|
data: Any = item.get("data")
|
||||||
# Data is missing if key doesn't exist or value is None
|
# 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]
|
first_error: dict[str, Any] = errors[0]
|
||||||
message: str = first_error.get("message", "unknown error")
|
message: str = first_error.get("message", "unknown error")
|
||||||
return f"error_only: {message}"
|
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
|
# Strategy 1: Direct repair attempt
|
||||||
try:
|
try:
|
||||||
fixed: str = json_repair.repair_json(raw_text)
|
fixed: str = json_repair.repair_json(raw_text, logging=False)
|
||||||
# Validate it produces valid JSON
|
# Validate it produces valid JSON
|
||||||
parsed_data = json.loads(fixed)
|
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):
|
if isinstance(parsed_data, list):
|
||||||
# Filter to only keep GraphQL responses
|
# Filter to only keep GraphQL responses
|
||||||
filtered = [
|
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 filtered:
|
||||||
# If we filtered anything out, return the filtered version
|
# 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
|
# Validate that all items look like GraphQL responses
|
||||||
if isinstance(wrapped_data, list) and wrapped_data: # noqa: SIM102
|
if isinstance(wrapped_data, list) and wrapped_data: # noqa: SIM102
|
||||||
# Check if all items have "data" or "extensions" (GraphQL response structure)
|
# 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
|
return wrapped
|
||||||
except ValueError, json.JSONDecodeError:
|
except ValueError, json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
@ -405,7 +435,7 @@ def repair_partially_broken_json(raw_text: str) -> str: # noqa: PLR0915
|
||||||
line: str = line.strip() # noqa: PLW2901
|
line: str = line.strip() # noqa: PLW2901
|
||||||
if line and line.startswith("{"):
|
if line and line.startswith("{"):
|
||||||
try:
|
try:
|
||||||
fixed_line: str = json_repair.repair_json(line)
|
fixed_line: str = json_repair.repair_json(line, logging=False)
|
||||||
obj = json.loads(fixed_line)
|
obj = json.loads(fixed_line)
|
||||||
# Only keep objects that look like GraphQL responses
|
# Only keep objects that look like GraphQL responses
|
||||||
if "data" in obj or "extensions" in obj:
|
if "data" in obj or "extensions" in obj:
|
||||||
|
|
@ -428,11 +458,7 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
def add_arguments(self, parser: CommandParser) -> None:
|
def add_arguments(self, parser: CommandParser) -> None:
|
||||||
"""Populate the command with arguments."""
|
"""Populate the command with arguments."""
|
||||||
parser.add_argument(
|
parser.add_argument("path", type=str, help="Path to JSON file or directory")
|
||||||
"path",
|
|
||||||
type=str,
|
|
||||||
help="Path to JSON file or directory",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--recursive",
|
"--recursive",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -487,7 +513,9 @@ class Command(BaseCommand):
|
||||||
for response_data in responses:
|
for response_data in responses:
|
||||||
if isinstance(response_data, dict):
|
if isinstance(response_data, dict):
|
||||||
try:
|
try:
|
||||||
response: GraphQLResponse = GraphQLResponse.model_validate(response_data)
|
response: GraphQLResponse = GraphQLResponse.model_validate(
|
||||||
|
response_data,
|
||||||
|
)
|
||||||
valid_responses.append(response)
|
valid_responses.append(response)
|
||||||
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
|
|
@ -497,8 +525,13 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
# Move invalid inputs out of the hot path so future runs can progress.
|
# Move invalid inputs out of the hot path so future runs can progress.
|
||||||
if not options.get("skip_broken_moves"):
|
if not options.get("skip_broken_moves"):
|
||||||
op_name: str | None = extract_operation_name_from_parsed(response_data)
|
op_name: str | None = extract_operation_name_from_parsed(
|
||||||
broken_dir = move_failed_validation_file(file_path, operation_name=op_name)
|
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.
|
# Once the file has been moved, bail out so we don't try to move it again later.
|
||||||
return [], broken_dir
|
return [], broken_dir
|
||||||
|
|
@ -511,10 +544,7 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
return valid_responses, broken_dir
|
return valid_responses, broken_dir
|
||||||
|
|
||||||
def _get_or_create_organization(
|
def _get_or_create_organization(self, org_data: OrganizationSchema) -> Organization:
|
||||||
self,
|
|
||||||
org_data: OrganizationSchema,
|
|
||||||
) -> Organization:
|
|
||||||
"""Get or create an organization.
|
"""Get or create an organization.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -525,12 +555,12 @@ class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
org_obj, created = Organization.objects.update_or_create(
|
org_obj, created = Organization.objects.update_or_create(
|
||||||
twitch_id=org_data.twitch_id,
|
twitch_id=org_data.twitch_id,
|
||||||
defaults={
|
defaults={"name": org_data.name},
|
||||||
"name": org_data.name,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if created:
|
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
|
return org_obj
|
||||||
|
|
||||||
|
|
@ -572,7 +602,9 @@ class Command(BaseCommand):
|
||||||
if created or owner_orgs:
|
if created or owner_orgs:
|
||||||
game_obj.owners.add(*owner_orgs)
|
game_obj.owners.add(*owner_orgs)
|
||||||
if created:
|
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)
|
self._download_game_box_art(game_obj, game_obj.box_art)
|
||||||
return game_obj
|
return game_obj
|
||||||
|
|
||||||
|
|
@ -615,13 +647,12 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
channel_obj, created = Channel.objects.update_or_create(
|
channel_obj, created = Channel.objects.update_or_create(
|
||||||
twitch_id=channel_info.twitch_id,
|
twitch_id=channel_info.twitch_id,
|
||||||
defaults={
|
defaults={"name": channel_info.name, "display_name": display_name},
|
||||||
"name": channel_info.name,
|
|
||||||
"display_name": display_name,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if created:
|
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
|
return channel_obj
|
||||||
|
|
||||||
|
|
@ -638,12 +669,13 @@ class Command(BaseCommand):
|
||||||
file_path: Path to the file being processed.
|
file_path: Path to the file being processed.
|
||||||
options: Command options dictionary.
|
options: Command options dictionary.
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If datetime parsing fails for campaign dates and
|
|
||||||
crash-on-error is enabled.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success flag, broken directory path if moved).
|
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(
|
valid_responses, broken_dir = self._validate_responses(
|
||||||
responses=responses,
|
responses=responses,
|
||||||
|
|
@ -659,7 +691,9 @@ class Command(BaseCommand):
|
||||||
campaigns_to_process: list[DropCampaignSchema] = []
|
campaigns_to_process: list[DropCampaignSchema] = []
|
||||||
|
|
||||||
# Source 1: User or CurrentUser field (handles plural, singular, inventory)
|
# 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:
|
if user_obj and user_obj.drop_campaigns:
|
||||||
campaigns_to_process.extend(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:
|
for drop_campaign in campaigns_to_process:
|
||||||
# Handle campaigns without owner (e.g., from Inventory operation)
|
# 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
|
org_obj: Organization | None = None
|
||||||
if owner_data:
|
if owner_data:
|
||||||
org_obj = self._get_or_create_organization(org_data=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)
|
end_at_dt: datetime | None = parse_date(drop_campaign.end_at)
|
||||||
|
|
||||||
if start_at_dt is None or end_at_dt is None:
|
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"):
|
if options.get("crash_on_error"):
|
||||||
msg: str = f"Failed to parse datetime for campaign {drop_campaign.name}"
|
msg: str = f"Failed to parse datetime for campaign {drop_campaign.name}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
@ -712,17 +752,26 @@ class Command(BaseCommand):
|
||||||
defaults=defaults,
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
if created:
|
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"
|
action: Literal["Imported new", "Updated"] = (
|
||||||
tqdm.write(f"{Fore.GREEN}✓{Style.RESET_ALL} {action} campaign: {drop_campaign.name}")
|
"Imported new" if created else "Updated"
|
||||||
|
)
|
||||||
|
tqdm.write(
|
||||||
|
f"{Fore.GREEN}✓{Style.RESET_ALL} {action} campaign: {drop_campaign.name}",
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
response.extensions
|
response.extensions
|
||||||
and response.extensions.operation_name
|
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"])
|
campaign_obj.save(update_fields=["operation_names"])
|
||||||
|
|
||||||
if drop_campaign.time_based_drops:
|
if drop_campaign.time_based_drops:
|
||||||
|
|
@ -769,7 +818,9 @@ class Command(BaseCommand):
|
||||||
}
|
}
|
||||||
|
|
||||||
if drop_schema.required_minutes_watched is not None:
|
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:
|
if start_at_dt is not None:
|
||||||
drop_defaults["start_at"] = start_at_dt
|
drop_defaults["start_at"] = start_at_dt
|
||||||
if end_at_dt is not None:
|
if end_at_dt is not None:
|
||||||
|
|
@ -780,7 +831,9 @@ class Command(BaseCommand):
|
||||||
defaults=drop_defaults,
|
defaults=drop_defaults,
|
||||||
)
|
)
|
||||||
if created:
|
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(
|
self._process_benefit_edges(
|
||||||
benefit_edges_schema=drop_schema.benefit_edges,
|
benefit_edges_schema=drop_schema.benefit_edges,
|
||||||
|
|
@ -808,7 +861,9 @@ class Command(BaseCommand):
|
||||||
defaults=benefit_defaults,
|
defaults=benefit_defaults,
|
||||||
)
|
)
|
||||||
if created:
|
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
|
return benefit_obj
|
||||||
|
|
||||||
|
|
@ -826,7 +881,9 @@ class Command(BaseCommand):
|
||||||
for edge_schema in benefit_edges_schema:
|
for edge_schema in benefit_edges_schema:
|
||||||
benefit_schema: DropBenefitSchema = edge_schema.benefit
|
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(
|
_edge_obj, created = DropBenefitEdge.objects.update_or_create(
|
||||||
drop=drop_obj,
|
drop=drop_obj,
|
||||||
|
|
@ -834,7 +891,9 @@ class Command(BaseCommand):
|
||||||
defaults={"entitlement_limit": edge_schema.entitlement_limit},
|
defaults={"entitlement_limit": edge_schema.entitlement_limit},
|
||||||
)
|
)
|
||||||
if created:
|
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(
|
def _process_allowed_channels(
|
||||||
self,
|
self,
|
||||||
|
|
@ -852,7 +911,9 @@ class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
# Update the allow_is_enabled flag if changed
|
# Update the allow_is_enabled flag if changed
|
||||||
# Default to True if is_enabled is None (API doesn't always provide this field)
|
# 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:
|
if campaign_obj.allow_is_enabled != is_enabled:
|
||||||
campaign_obj.allow_is_enabled = is_enabled
|
campaign_obj.allow_is_enabled = is_enabled
|
||||||
campaign_obj.save(update_fields=["allow_is_enabled"])
|
campaign_obj.save(update_fields=["allow_is_enabled"])
|
||||||
|
|
@ -864,7 +925,9 @@ class Command(BaseCommand):
|
||||||
channel_objects: list[Channel] = []
|
channel_objects: list[Channel] = []
|
||||||
if allow_schema.channels:
|
if allow_schema.channels:
|
||||||
for channel_schema in 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)
|
channel_objects.append(channel_obj)
|
||||||
# Only update the M2M relationship if we have channels
|
# Only update the M2M relationship if we have channels
|
||||||
campaign_obj.allow_channels.set(channel_objects)
|
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)
|
ends_at_dt: datetime | None = parse_date(reward_campaign.ends_at)
|
||||||
|
|
||||||
if starts_at_dt is None or ends_at_dt is None:
|
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"):
|
if options.get("crash_on_error"):
|
||||||
msg: str = f"Failed to parse datetime for reward campaign {reward_campaign.name}"
|
msg: str = f"Failed to parse datetime for reward campaign {reward_campaign.name}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
@ -923,7 +988,9 @@ class Command(BaseCommand):
|
||||||
"about_url": reward_campaign.about_url,
|
"about_url": reward_campaign.about_url,
|
||||||
"is_sitewide": reward_campaign.is_sitewide,
|
"is_sitewide": reward_campaign.is_sitewide,
|
||||||
"game": game_obj,
|
"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(
|
_reward_campaign_obj, created = RewardCampaign.objects.update_or_create(
|
||||||
|
|
@ -931,11 +998,17 @@ class Command(BaseCommand):
|
||||||
defaults=defaults,
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
|
|
||||||
action: Literal["Imported new", "Updated"] = "Imported new" if created else "Updated"
|
action: Literal["Imported new", "Updated"] = (
|
||||||
display_name = (
|
"Imported new" if created else "Updated"
|
||||||
f"{reward_campaign.brand}: {reward_campaign.name}" if reward_campaign.brand else reward_campaign.name
|
)
|
||||||
|
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
|
def handle(self, *args, **options) -> None: # noqa: ARG002
|
||||||
"""Main entry point for the command.
|
"""Main entry point for the command.
|
||||||
|
|
@ -978,7 +1051,9 @@ class Command(BaseCommand):
|
||||||
total=len(json_files),
|
total=len(json_files),
|
||||||
desc="Processing",
|
desc="Processing",
|
||||||
unit="file",
|
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",
|
colour="green",
|
||||||
dynamic_ncols=True,
|
dynamic_ncols=True,
|
||||||
) as progress_bar:
|
) as progress_bar:
|
||||||
|
|
@ -991,10 +1066,14 @@ class Command(BaseCommand):
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
success_count += 1
|
success_count += 1
|
||||||
if options.get("verbose"):
|
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:
|
else:
|
||||||
failed_count += 1
|
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:
|
if reason:
|
||||||
progress_bar.write(
|
progress_bar.write(
|
||||||
f"{Fore.RED}✗{Style.RESET_ALL} "
|
f"{Fore.RED}✗{Style.RESET_ALL} "
|
||||||
|
|
@ -1009,10 +1088,15 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
except (OSError, ValueError, KeyError) as e:
|
except (OSError, ValueError, KeyError) as e:
|
||||||
error_count += 1
|
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
|
# 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)
|
progress_bar.update(1)
|
||||||
|
|
||||||
self.print_processing_summary(
|
self.print_processing_summary(
|
||||||
|
|
@ -1093,7 +1177,10 @@ class Command(BaseCommand):
|
||||||
return "inventory_campaigns"
|
return "inventory_campaigns"
|
||||||
|
|
||||||
# Structure: {"data": {"currentUser": {"dropCampaigns": [...]}}}
|
# 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"
|
return "current_user_drop_campaigns"
|
||||||
|
|
||||||
# Structure: {"data": {"channel": {"viewerDropCampaigns": [...] or {...}}}}
|
# Structure: {"data": {"channel": {"viewerDropCampaigns": [...] or {...}}}}
|
||||||
|
|
@ -1104,11 +1191,7 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def collect_json_files(
|
def collect_json_files(self, options: dict, input_path: Path) -> list[Path]:
|
||||||
self,
|
|
||||||
options: dict,
|
|
||||||
input_path: Path,
|
|
||||||
) -> list[Path]:
|
|
||||||
"""Collect JSON files from the specified directory.
|
"""Collect JSON files from the specified directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -1122,9 +1205,13 @@ class Command(BaseCommand):
|
||||||
if options["recursive"]:
|
if options["recursive"]:
|
||||||
for root, _dirs, files in os.walk(input_path):
|
for root, _dirs, files in os.walk(input_path):
|
||||||
root_path = Path(root)
|
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:
|
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
|
return json_files
|
||||||
|
|
||||||
def _normalize_responses(
|
def _normalize_responses(
|
||||||
|
|
@ -1147,8 +1234,13 @@ class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
if isinstance(parsed_json, dict):
|
if isinstance(parsed_json, dict):
|
||||||
# Check for batched format: {"responses": [...]}
|
# Check for batched format: {"responses": [...]}
|
||||||
if "responses" in parsed_json and isinstance(parsed_json["responses"], list):
|
if "responses" in parsed_json and isinstance(
|
||||||
return [item for item in parsed_json["responses"] if isinstance(item, dict)]
|
parsed_json["responses"],
|
||||||
|
list,
|
||||||
|
):
|
||||||
|
return [
|
||||||
|
item for item in parsed_json["responses"] if isinstance(item, dict)
|
||||||
|
]
|
||||||
# Single response: {"data": {...}}
|
# Single response: {"data": {...}}
|
||||||
return [parsed_json]
|
return [parsed_json]
|
||||||
if isinstance(parsed_json, list):
|
if isinstance(parsed_json, list):
|
||||||
|
|
@ -1171,21 +1263,21 @@ class Command(BaseCommand):
|
||||||
file_path: Path to the JSON file to process
|
file_path: Path to the JSON file to process
|
||||||
options: Command options
|
options: Command options
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status and optional broken_dir path
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the JSON file fails validation
|
ValidationError: If the JSON file fails validation
|
||||||
json.JSONDecodeError: If the JSON file cannot be parsed
|
json.JSONDecodeError: If the JSON file cannot be parsed
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with success status and optional broken_dir path
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
raw_text: str = file_path.read_text(encoding="utf-8", errors="ignore")
|
raw_text: str = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
|
||||||
# Repair potentially broken JSON with multiple fallback strategies
|
# Repair potentially broken JSON with multiple fallback strategies
|
||||||
fixed_json_str: str = repair_partially_broken_json(raw_text)
|
fixed_json_str: str = repair_partially_broken_json(raw_text)
|
||||||
parsed_json: JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str = json.loads(
|
parsed_json: (
|
||||||
fixed_json_str,
|
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
|
# Check for error-only responses first
|
||||||
|
|
@ -1197,8 +1289,16 @@ class Command(BaseCommand):
|
||||||
error_description,
|
error_description,
|
||||||
operation_name=operation_name,
|
operation_name=operation_name,
|
||||||
)
|
)
|
||||||
return {"success": False, "broken_dir": str(broken_dir), "reason": error_description}
|
return {
|
||||||
return {"success": False, "broken_dir": "(skipped)", "reason": error_description}
|
"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)
|
matched: str | None = detect_non_campaign_keyword(raw_text)
|
||||||
if matched:
|
if matched:
|
||||||
|
|
@ -1208,8 +1308,16 @@ class Command(BaseCommand):
|
||||||
matched,
|
matched,
|
||||||
operation_name=operation_name,
|
operation_name=operation_name,
|
||||||
)
|
)
|
||||||
return {"success": False, "broken_dir": str(broken_dir), "reason": f"matched '{matched}'"}
|
return {
|
||||||
return {"success": False, "broken_dir": "(skipped)", "reason": f"matched '{matched}'"}
|
"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 "dropCampaign" not in raw_text:
|
||||||
if not options.get("skip_broken_moves"):
|
if not options.get("skip_broken_moves"):
|
||||||
broken_dir: Path | None = move_file_to_broken_subdir(
|
broken_dir: Path | None = move_file_to_broken_subdir(
|
||||||
|
|
@ -1217,8 +1325,16 @@ class Command(BaseCommand):
|
||||||
"no_dropCampaign",
|
"no_dropCampaign",
|
||||||
operation_name=operation_name,
|
operation_name=operation_name,
|
||||||
)
|
)
|
||||||
return {"success": False, "broken_dir": str(broken_dir), "reason": "no dropCampaign present"}
|
return {
|
||||||
return {"success": False, "broken_dir": "(skipped)", "reason": "no dropCampaign present"}
|
"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
|
# Normalize and filter to dict responses only
|
||||||
responses: list[dict[str, Any]] = self._normalize_responses(parsed_json)
|
responses: list[dict[str, Any]] = self._normalize_responses(parsed_json)
|
||||||
|
|
@ -1256,7 +1372,10 @@ class Command(BaseCommand):
|
||||||
if isinstance(parsed_json_local, (dict, list))
|
if isinstance(parsed_json_local, (dict, list))
|
||||||
else None
|
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": str(broken_dir)}
|
||||||
return {"success": False, "broken_dir": "(skipped)"}
|
return {"success": False, "broken_dir": "(skipped)"}
|
||||||
else:
|
else:
|
||||||
|
|
@ -1285,10 +1404,12 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
# Repair potentially broken JSON with multiple fallback strategies
|
# Repair potentially broken JSON with multiple fallback strategies
|
||||||
fixed_json_str: str = repair_partially_broken_json(raw_text)
|
fixed_json_str: str = repair_partially_broken_json(raw_text)
|
||||||
parsed_json: JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str = json.loads(
|
parsed_json: (
|
||||||
fixed_json_str,
|
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
|
# Check for error-only responses first
|
||||||
error_description: str | None = detect_error_only_response(parsed_json)
|
error_description: str | None = detect_error_only_response(parsed_json)
|
||||||
|
|
@ -1386,7 +1507,14 @@ class Command(BaseCommand):
|
||||||
if isinstance(parsed_json_local, (dict, list))
|
if isinstance(parsed_json_local, (dict, list))
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
broken_dir = move_failed_validation_file(file_path, operation_name=op_name)
|
broken_dir = move_failed_validation_file(
|
||||||
progress_bar.write(f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → {broken_dir}/{file_path.name}")
|
file_path,
|
||||||
|
operation_name=op_name,
|
||||||
|
)
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → {broken_dir}/{file_path.name}",
|
||||||
|
)
|
||||||
else:
|
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)",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
@ -54,21 +52,31 @@ class Command(BaseCommand):
|
||||||
self.stdout.write(self.style.SUCCESS("No orphaned channels found."))
|
self.stdout.write(self.style.SUCCESS("No orphaned channels found."))
|
||||||
return
|
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
|
# Show sample of channels to be deleted
|
||||||
for channel in orphaned_channels[:SAMPLE_PREVIEW_COUNT]:
|
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:
|
if count > SAMPLE_PREVIEW_COUNT:
|
||||||
self.stdout.write(f" ... and {count - SAMPLE_PREVIEW_COUNT} more")
|
self.stdout.write(f" ... and {count - SAMPLE_PREVIEW_COUNT} more")
|
||||||
|
|
||||||
if dry_run:
|
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
|
return
|
||||||
|
|
||||||
if not force:
|
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":
|
if response.lower() != "yes":
|
||||||
self.stdout.write(self.style.WARNING("Cancelled."))
|
self.stdout.write(self.style.WARNING("Cancelled."))
|
||||||
return
|
return
|
||||||
|
|
@ -76,4 +84,8 @@ class Command(BaseCommand):
|
||||||
# Delete the orphaned channels
|
# Delete the orphaned channels
|
||||||
deleted_count, _ = orphaned_channels.delete()
|
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.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -8,13 +6,13 @@ from colorama import Style
|
||||||
from colorama import init as colorama_init
|
from colorama import init as colorama_init
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
from django.core.management.base import CommandParser
|
|
||||||
|
|
||||||
from twitch.models import Game
|
from twitch.models import Game
|
||||||
from twitch.models import Organization
|
from twitch.models import Organization
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from debug_toolbar.panels.templates.panel import QuerySet
|
from debug_toolbar.panels.templates.panel import QuerySet
|
||||||
|
from django.core.management.base import CommandParser
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|
@ -70,11 +68,15 @@ class Command(BaseCommand):
|
||||||
try:
|
try:
|
||||||
org: Organization = Organization.objects.get(twitch_id=org_id)
|
org: Organization = Organization.objects.get(twitch_id=org_id)
|
||||||
except Organization.DoesNotExist as exc: # pragma: no cover - simple guard
|
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
|
raise CommandError(msg) from exc
|
||||||
|
|
||||||
# Compute the set of affected games via the through relation for accuracy and performance
|
# 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()
|
affected_count: int = affected_games_qs.count()
|
||||||
|
|
||||||
if affected_count == 0:
|
if affected_count == 0:
|
||||||
|
|
@ -83,7 +85,7 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.stdout.write(
|
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
|
# Show a short preview list in dry-run mode
|
||||||
|
|
@ -112,9 +114,9 @@ class Command(BaseCommand):
|
||||||
org_twid: str = org.twitch_id
|
org_twid: str = org.twitch_id
|
||||||
org.delete()
|
org.delete()
|
||||||
self.stdout.write(
|
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:
|
else:
|
||||||
self.stdout.write(
|
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.",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Management command to convert existing images to WebP and AVIF formats."""
|
"""Management command to convert existing images to WebP and AVIF formats."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
@ -48,12 +46,18 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
media_root = Path(settings.MEDIA_ROOT)
|
media_root = Path(settings.MEDIA_ROOT)
|
||||||
if not media_root.exists():
|
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
|
return
|
||||||
|
|
||||||
# Find all JPG and PNG files
|
# Find all JPG and PNG files
|
||||||
image_extensions = {".jpg", ".jpeg", ".png"}
|
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:
|
if not image_files:
|
||||||
self.stdout.write(self.style.SUCCESS("No images found to convert"))
|
self.stdout.write(self.style.SUCCESS("No images found to convert"))
|
||||||
|
|
@ -80,7 +84,9 @@ class Command(BaseCommand):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if dry_run:
|
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:
|
if needs_webp:
|
||||||
self.stdout.write(f" → {webp_path.relative_to(media_root)}")
|
self.stdout.write(f" → {webp_path.relative_to(media_root)}")
|
||||||
if needs_avif:
|
if needs_avif:
|
||||||
|
|
@ -104,14 +110,20 @@ class Command(BaseCommand):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_count += 1
|
error_count += 1
|
||||||
self.stdout.write(
|
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)
|
logger.exception("Failed to convert image: %s", image_path)
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
self.stdout.write("\n" + "=" * 50)
|
self.stdout.write("\n" + "=" * 50)
|
||||||
if dry_run:
|
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:
|
else:
|
||||||
self.stdout.write(self.style.SUCCESS(f"Converted: {converted_count}"))
|
self.stdout.write(self.style.SUCCESS(f"Converted: {converted_count}"))
|
||||||
self.stdout.write(f"Skipped (already exist): {skipped_count}")
|
self.stdout.write(f"Skipped (already exist): {skipped_count}")
|
||||||
|
|
@ -177,11 +189,16 @@ class Command(BaseCommand):
|
||||||
Returns:
|
Returns:
|
||||||
RGB PIL Image ready for encoding
|
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
|
# Create white background for transparency
|
||||||
background = Image.new("RGB", img.size, (255, 255, 255))
|
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||||
rgba_img = img.convert("RGBA") if img.mode == "P" else img
|
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
|
return background
|
||||||
if img.mode != "RGB":
|
if img.mode != "RGB":
|
||||||
return img.convert("RGB")
|
return img.convert("RGB")
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from urllib.parse import ParseResult
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.core.management.base import CommandParser
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from twitch.models import Game
|
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
|
from twitch.utils import normalize_twitch_box_art_url
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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 import QuerySet
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -63,7 +62,11 @@ class Command(BaseCommand):
|
||||||
if not is_twitch_box_art_url(game.box_art):
|
if not is_twitch_box_art_url(game.box_art):
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
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
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -89,7 +92,11 @@ class Command(BaseCommand):
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
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
|
# Auto-convert to WebP and AVIF
|
||||||
self._convert_to_modern_formats(game.box_art_file.path)
|
self._convert_to_modern_formats(game.box_art_file.path)
|
||||||
|
|
@ -113,7 +120,11 @@ class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
source_path = Path(image_path)
|
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
|
return
|
||||||
|
|
||||||
base_path = source_path.with_suffix("")
|
base_path = source_path.with_suffix("")
|
||||||
|
|
@ -122,10 +133,17 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
with Image.open(source_path) as img:
|
with Image.open(source_path) as img:
|
||||||
# Convert to RGB if needed
|
# 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))
|
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||||
rgba_img = img.convert("RGBA") if img.mode == "P" else img
|
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
|
rgb_img = background
|
||||||
elif img.mode != "RGB":
|
elif img.mode != "RGB":
|
||||||
rgb_img = img.convert("RGB")
|
rgb_img = img.convert("RGB")
|
||||||
|
|
@ -140,4 +158,6 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
# Don't fail the download if conversion fails
|
# 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}"),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
"""Management command to download and cache campaign, benefit, and reward images locally."""
|
"""Management command to download and cache campaign, benefit, and reward images locally."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from urllib.parse import ParseResult
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.core.management.base import CommandParser
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from twitch.models import DropBenefit
|
from twitch.models import DropBenefit
|
||||||
|
|
@ -19,6 +15,9 @@ from twitch.models import DropCampaign
|
||||||
from twitch.models import RewardCampaign
|
from twitch.models import RewardCampaign
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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 import QuerySet
|
||||||
from django.db.models.fields.files import FieldFile
|
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:
|
with httpx.Client(timeout=20, follow_redirects=True) as client:
|
||||||
if model_choice in {"campaigns", "all"}:
|
if model_choice in {"campaigns", "all"}:
|
||||||
self.stdout.write(self.style.MIGRATE_HEADING("\nProcessing Drop Campaigns..."))
|
self.stdout.write(
|
||||||
stats = self._download_campaign_images(client=client, limit=limit, force=force)
|
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._merge_stats(total_stats, stats)
|
||||||
self._print_stats("Drop Campaigns", stats)
|
self._print_stats("Drop Campaigns", stats)
|
||||||
|
|
||||||
if model_choice in {"benefits", "all"}:
|
if model_choice in {"benefits", "all"}:
|
||||||
self.stdout.write(self.style.MIGRATE_HEADING("\nProcessing Drop Benefits..."))
|
self.stdout.write(
|
||||||
stats = self._download_benefit_images(client=client, limit=limit, force=force)
|
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._merge_stats(total_stats, stats)
|
||||||
self._print_stats("Drop Benefits", stats)
|
self._print_stats("Drop Benefits", stats)
|
||||||
|
|
||||||
if model_choice in {"rewards", "all"}:
|
if model_choice in {"rewards", "all"}:
|
||||||
self.stdout.write(self.style.MIGRATE_HEADING("\nProcessing Reward Campaigns..."))
|
self.stdout.write(
|
||||||
stats = self._download_reward_campaign_images(client=client, limit=limit, force=force)
|
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._merge_stats(total_stats, stats)
|
||||||
self._print_stats("Reward Campaigns", stats)
|
self._print_stats("Reward Campaigns", stats)
|
||||||
|
|
||||||
|
|
@ -107,18 +124,30 @@ class Command(BaseCommand):
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with download statistics (total, downloaded, skipped, failed, placeholders_404).
|
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:
|
if limit:
|
||||||
queryset = queryset[: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()
|
stats["total"] = queryset.count()
|
||||||
|
|
||||||
for campaign in queryset:
|
for campaign in queryset:
|
||||||
if not campaign.image_url:
|
if not campaign.image_url:
|
||||||
stats["skipped"] += 1
|
stats["skipped"] += 1
|
||||||
continue
|
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
|
stats["skipped"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -144,18 +173,30 @@ class Command(BaseCommand):
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with download statistics (total, downloaded, skipped, failed, placeholders_404).
|
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:
|
if limit:
|
||||||
queryset = queryset[: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()
|
stats["total"] = queryset.count()
|
||||||
|
|
||||||
for benefit in queryset:
|
for benefit in queryset:
|
||||||
if not benefit.image_asset_url:
|
if not benefit.image_asset_url:
|
||||||
stats["skipped"] += 1
|
stats["skipped"] += 1
|
||||||
continue
|
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
|
stats["skipped"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -181,18 +222,30 @@ class Command(BaseCommand):
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with download statistics (total, downloaded, skipped, failed, placeholders_404).
|
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:
|
if limit:
|
||||||
queryset = queryset[: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()
|
stats["total"] = queryset.count()
|
||||||
|
|
||||||
for reward_campaign in queryset:
|
for reward_campaign in queryset:
|
||||||
if not reward_campaign.image_url:
|
if not reward_campaign.image_url:
|
||||||
stats["skipped"] += 1
|
stats["skipped"] += 1
|
||||||
continue
|
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
|
stats["skipped"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -233,9 +286,7 @@ class Command(BaseCommand):
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.WARNING(
|
self.style.WARNING(f"Failed to download image for {twitch_id}: {exc}"),
|
||||||
f"Failed to download image for {twitch_id}: {exc}",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return "failed"
|
return "failed"
|
||||||
|
|
||||||
|
|
@ -262,7 +313,11 @@ class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
source_path = Path(image_path)
|
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
|
return
|
||||||
|
|
||||||
base_path = source_path.with_suffix("")
|
base_path = source_path.with_suffix("")
|
||||||
|
|
@ -271,10 +326,17 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
with Image.open(source_path) as img:
|
with Image.open(source_path) as img:
|
||||||
# Convert to RGB if needed
|
# 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))
|
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||||
rgba_img = img.convert("RGBA") if img.mode == "P" else img
|
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
|
rgb_img = background
|
||||||
elif img.mode != "RGB":
|
elif img.mode != "RGB":
|
||||||
rgb_img = img.convert("RGB")
|
rgb_img = img.convert("RGB")
|
||||||
|
|
@ -289,7 +351,9 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
# Don't fail the download if conversion fails
|
# 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:
|
def _merge_stats(self, total: dict[str, int], new: dict[str, int]) -> None:
|
||||||
"""Merge statistics from a single model into the total stats."""
|
"""Merge statistics from a single model into the total stats."""
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Management command to import Twitch global chat badges."""
|
"""Management command to import Twitch global chat badges."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
@ -13,15 +11,16 @@ from colorama import Style
|
||||||
from colorama import init as colorama_init
|
from colorama import init as colorama_init
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
from django.core.management.base import CommandParser
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from twitch.models import ChatBadge
|
from twitch.models import ChatBadge
|
||||||
from twitch.models import ChatBadgeSet
|
from twitch.models import ChatBadgeSet
|
||||||
from twitch.schemas import ChatBadgeSetSchema
|
|
||||||
from twitch.schemas import GlobalChatBadgesResponse
|
from twitch.schemas import GlobalChatBadgesResponse
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from django.core.management.base import CommandParser
|
||||||
|
|
||||||
|
from twitch.schemas import ChatBadgeSetSchema
|
||||||
from twitch.schemas import ChatBadgeVersionSchema
|
from twitch.schemas import ChatBadgeVersionSchema
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("ttvdrops")
|
logger: logging.Logger = logging.getLogger("ttvdrops")
|
||||||
|
|
@ -60,9 +59,15 @@ class Command(BaseCommand):
|
||||||
colorama_init(autoreset=True)
|
colorama_init(autoreset=True)
|
||||||
|
|
||||||
# Get credentials from arguments or environment
|
# Get credentials from arguments or environment
|
||||||
client_id: str | None = options.get("client_id") or os.getenv("TWITCH_CLIENT_ID")
|
client_id: str | None = options.get("client_id") or os.getenv(
|
||||||
client_secret: str | None = options.get("client_secret") or os.getenv("TWITCH_CLIENT_SECRET")
|
"TWITCH_CLIENT_ID",
|
||||||
access_token: str | None = options.get("access_token") or os.getenv("TWITCH_ACCESS_TOKEN")
|
)
|
||||||
|
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:
|
if not client_id:
|
||||||
msg = (
|
msg = (
|
||||||
|
|
@ -84,7 +89,9 @@ class Command(BaseCommand):
|
||||||
self.stdout.write("Obtaining access token from Twitch...")
|
self.stdout.write("Obtaining access token from Twitch...")
|
||||||
try:
|
try:
|
||||||
access_token = self._get_app_access_token(client_id, client_secret)
|
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:
|
except httpx.HTTPError as e:
|
||||||
msg = f"Failed to obtain access token: {e}"
|
msg = f"Failed to obtain access token: {e}"
|
||||||
raise CommandError(msg) from e
|
raise CommandError(msg) from e
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Generated by Django 6.0 on 2025-12-11 10:49
|
# Generated by Django 6.0 on 2025-12-11 10:49
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
@ -17,8 +17,19 @@ class Migration(migrations.Migration):
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Game",
|
name="Game",
|
||||||
fields=[
|
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",
|
"slug",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
|
|
@ -30,8 +41,23 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("name", models.TextField(blank=True, default="", verbose_name="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(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",
|
"box_art_file",
|
||||||
models.FileField(
|
models.FileField(
|
||||||
|
|
@ -43,21 +69,33 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"added_at",
|
"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",
|
"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={
|
options={"ordering": ["display_name"]},
|
||||||
"ordering": ["display_name"],
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Channel",
|
name="Channel",
|
||||||
fields=[
|
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",
|
"twitch_id",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
|
|
@ -66,7 +104,13 @@ class Migration(migrations.Migration):
|
||||||
verbose_name="Channel ID",
|
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",
|
"display_name",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
|
|
@ -92,23 +136,54 @@ class Migration(migrations.Migration):
|
||||||
options={
|
options={
|
||||||
"ordering": ["display_name"],
|
"ordering": ["display_name"],
|
||||||
"indexes": [
|
"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=["name"], name="twitch_chan_name_15d566_idx"),
|
||||||
models.Index(fields=["twitch_id"], name="twitch_chan_twitch__c8bbc6_idx"),
|
models.Index(
|
||||||
models.Index(fields=["added_at"], name="twitch_chan_added_a_5ce7b4_idx"),
|
fields=["twitch_id"],
|
||||||
models.Index(fields=["updated_at"], name="twitch_chan_updated_828594_idx"),
|
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(
|
migrations.CreateModel(
|
||||||
name="DropBenefit",
|
name="DropBenefit",
|
||||||
fields=[
|
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",
|
"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",
|
"image_asset_url",
|
||||||
models.URLField(
|
models.URLField(
|
||||||
|
|
@ -130,7 +205,7 @@ class Migration(migrations.Migration):
|
||||||
(
|
(
|
||||||
"created_at",
|
"created_at",
|
||||||
models.DateTimeField(
|
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,
|
null=True,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -143,7 +218,10 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"is_ios_available",
|
"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",
|
"distribution_type",
|
||||||
|
|
@ -172,20 +250,46 @@ class Migration(migrations.Migration):
|
||||||
options={
|
options={
|
||||||
"ordering": ["-created_at"],
|
"ordering": ["-created_at"],
|
||||||
"indexes": [
|
"indexes": [
|
||||||
models.Index(fields=["-created_at"], name="twitch_drop_created_5d2280_idx"),
|
models.Index(
|
||||||
models.Index(fields=["twitch_id"], name="twitch_drop_twitch__6eab58_idx"),
|
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=["name"], name="twitch_drop_name_7125ff_idx"),
|
||||||
models.Index(fields=["distribution_type"], name="twitch_drop_distrib_08b224_idx"),
|
models.Index(
|
||||||
models.Index(fields=["is_ios_available"], name="twitch_drop_is_ios__5f3dcf_idx"),
|
fields=["distribution_type"],
|
||||||
models.Index(fields=["added_at"], name="twitch_drop_added_a_fba438_idx"),
|
name="twitch_drop_distrib_08b224_idx",
|
||||||
models.Index(fields=["updated_at"], name="twitch_drop_updated_7aaae3_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(
|
migrations.CreateModel(
|
||||||
name="DropBenefitEdge",
|
name="DropBenefitEdge",
|
||||||
fields=[
|
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",
|
"entitlement_limit",
|
||||||
models.PositiveIntegerField(
|
models.PositiveIntegerField(
|
||||||
|
|
@ -220,16 +324,39 @@ class Migration(migrations.Migration):
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="DropCampaign",
|
name="DropCampaign",
|
||||||
fields=[
|
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",
|
"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.")),
|
("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",
|
"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",
|
"account_link_url",
|
||||||
|
|
@ -260,23 +387,40 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"start_at",
|
"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",
|
"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",
|
"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",
|
"operation_name",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
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={
|
options={"ordering": ["-start_at"]},
|
||||||
"ordering": ["-start_at"],
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Organization",
|
name="Organization",
|
||||||
fields=[
|
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",
|
"twitch_id",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
|
|
@ -332,7 +482,11 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"name",
|
"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",
|
"added_at",
|
||||||
|
|
@ -355,9 +509,18 @@ class Migration(migrations.Migration):
|
||||||
"ordering": ["name"],
|
"ordering": ["name"],
|
||||||
"indexes": [
|
"indexes": [
|
||||||
models.Index(fields=["name"], name="twitch_orga_name_febe72_idx"),
|
models.Index(fields=["name"], name="twitch_orga_name_febe72_idx"),
|
||||||
models.Index(fields=["twitch_id"], name="twitch_orga_twitch__b89b29_idx"),
|
models.Index(
|
||||||
models.Index(fields=["added_at"], name="twitch_orga_added_a_8297ac_idx"),
|
fields=["twitch_id"],
|
||||||
models.Index(fields=["updated_at"], name="twitch_orga_updated_d7d431_idx"),
|
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(
|
migrations.CreateModel(
|
||||||
name="TimeBasedDrop",
|
name="TimeBasedDrop",
|
||||||
fields=[
|
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",
|
"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.")),
|
("name", models.TextField(help_text="Name of the time-based drop.")),
|
||||||
(
|
(
|
||||||
|
|
@ -400,9 +575,20 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"start_at",
|
"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",
|
"added_at",
|
||||||
models.DateTimeField(
|
models.DateTimeField(
|
||||||
|
|
@ -436,9 +622,7 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={"ordering": ["start_at"]},
|
||||||
"ordering": ["start_at"],
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="dropbenefitedge",
|
model_name="dropbenefitedge",
|
||||||
|
|
@ -452,7 +636,15 @@ class Migration(migrations.Migration):
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="TwitchGameData",
|
name="TwitchGameData",
|
||||||
fields=[
|
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",
|
"twitch_id",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
|
|
@ -472,9 +664,24 @@ class Migration(migrations.Migration):
|
||||||
verbose_name="Box art URL",
|
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.")),
|
"igdb_id",
|
||||||
("updated_at", models.DateTimeField(auto_now=True, help_text="Record last update time.")),
|
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",
|
"game",
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
|
|
@ -488,13 +695,14 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={"ordering": ["name"]},
|
||||||
"ordering": ["name"],
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
model_name="dropcampaign",
|
||||||
|
|
@ -506,7 +714,10 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
model_name="dropcampaign",
|
||||||
|
|
@ -514,47 +725,80 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="game",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="game",
|
model_name="game",
|
||||||
|
|
@ -566,7 +810,10 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="game",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="game",
|
model_name="game",
|
||||||
|
|
@ -574,19 +821,31 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="game",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="game",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="game",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
model_name="timebaseddrop",
|
||||||
|
|
@ -594,11 +853,17 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
model_name="timebaseddrop",
|
||||||
|
|
@ -606,31 +871,52 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="timebaseddrop",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropbenefitedge",
|
model_name="dropbenefitedge",
|
||||||
|
|
@ -638,23 +924,38 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="dropbenefitedge",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropbenefitedge",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropbenefitedge",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="dropbenefitedge",
|
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(
|
migrations.AddConstraint(
|
||||||
model_name="dropbenefitedge",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="twitchgamedata",
|
model_name="twitchgamedata",
|
||||||
|
|
@ -662,7 +963,10 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="twitchgamedata",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="twitchgamedata",
|
model_name="twitchgamedata",
|
||||||
|
|
@ -670,14 +974,23 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="twitchgamedata",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="twitchgamedata",
|
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(
|
migrations.AddIndex(
|
||||||
model_name="twitchgamedata",
|
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",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Generated by Django 6.0 on 2026-01-05 20:47
|
# Generated by Django 6.0 on 2026-01-05 20:47
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
@ -8,14 +8,18 @@ from django.db import models
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
"""Alter box_art field to allow null values."""
|
"""Alter box_art field to allow null values."""
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("twitch", "0001_initial")]
|
||||||
("twitch", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="game",
|
model_name="game",
|
||||||
name="box_art",
|
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",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Generated by Django 6.0 on 2026-01-05 22:29
|
# Generated by Django 6.0 on 2026-01-05 22:29
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
@ -7,17 +7,12 @@ from django.db import migrations
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
"""Remove is_account_connected field and its index from DropCampaign."""
|
"""Remove is_account_connected field and its index from DropCampaign."""
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("twitch", "0002_alter_game_box_art")]
|
||||||
("twitch", "0002_alter_game_box_art"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RemoveIndex(
|
migrations.RemoveIndex(
|
||||||
model_name="dropcampaign",
|
model_name="dropcampaign",
|
||||||
name="twitch_drop_is_acco_7e9078_idx",
|
name="twitch_drop_is_acco_7e9078_idx",
|
||||||
),
|
),
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(model_name="dropcampaign", name="is_account_connected"),
|
||||||
model_name="dropcampaign",
|
|
||||||
name="is_account_connected",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Generated by Django 6.0.1 on 2026-01-09 20:52
|
# 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 migrations
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
@ -35,8 +35,5 @@ class Migration(migrations.Migration):
|
||||||
verbose_name="Organizations",
|
verbose_name="Organizations",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(model_name="game", name="owner"),
|
||||||
model_name="game",
|
|
||||||
name="owner",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Generated by Django 6.0.1 on 2026-01-13 20:31
|
# Generated by Django 6.0.1 on 2026-01-13 20:31
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
@ -17,35 +17,71 @@ class Migration(migrations.Migration):
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="RewardCampaign",
|
name="RewardCampaign",
|
||||||
fields=[
|
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",
|
"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.")),
|
("name", models.TextField(help_text="Name of the reward campaign.")),
|
||||||
(
|
(
|
||||||
"brand",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"external_url",
|
||||||
|
|
@ -58,7 +94,11 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"reward_value_url_param",
|
"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",
|
"about_url",
|
||||||
|
|
@ -71,7 +111,10 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"is_sitewide",
|
"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",
|
"added_at",
|
||||||
|
|
@ -102,18 +145,48 @@ class Migration(migrations.Migration):
|
||||||
options={
|
options={
|
||||||
"ordering": ["-starts_at"],
|
"ordering": ["-starts_at"],
|
||||||
"indexes": [
|
"indexes": [
|
||||||
models.Index(fields=["-starts_at"], name="twitch_rewa_starts__4df564_idx"),
|
models.Index(
|
||||||
models.Index(fields=["ends_at"], name="twitch_rewa_ends_at_354b15_idx"),
|
fields=["-starts_at"],
|
||||||
models.Index(fields=["twitch_id"], name="twitch_rewa_twitch__797967_idx"),
|
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=["name"], name="twitch_rewa_name_f1e3dd_idx"),
|
||||||
models.Index(fields=["brand"], name="twitch_rewa_brand_41c321_idx"),
|
models.Index(fields=["brand"], name="twitch_rewa_brand_41c321_idx"),
|
||||||
models.Index(fields=["status"], name="twitch_rewa_status_a96d6b_idx"),
|
models.Index(
|
||||||
models.Index(fields=["is_sitewide"], name="twitch_rewa_is_site_7d2c9f_idx"),
|
fields=["status"],
|
||||||
models.Index(fields=["game"], name="twitch_rewa_game_id_678fbb_idx"),
|
name="twitch_rewa_status_a96d6b_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(
|
||||||
models.Index(fields=["starts_at", "ends_at"], name="twitch_rewa_starts__dd909d_idx"),
|
fields=["is_sitewide"],
|
||||||
models.Index(fields=["status", "-starts_at"], name="twitch_rewa_status_3641a4_idx"),
|
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",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Generated by Django 6.0.1 on 2026-01-15 21:57
|
# Generated by Django 6.0.1 on 2026-01-15 21:57
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
@ -9,15 +9,21 @@ from django.db import models
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
"""Add ChatBadgeSet and ChatBadge models for Twitch chat badges."""
|
"""Add ChatBadgeSet and ChatBadge models for Twitch chat badges."""
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("twitch", "0005_add_reward_campaign")]
|
||||||
("twitch", "0005_add_reward_campaign"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="ChatBadgeSet",
|
name="ChatBadgeSet",
|
||||||
fields=[
|
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",
|
"set_id",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
|
|
@ -46,16 +52,33 @@ class Migration(migrations.Migration):
|
||||||
options={
|
options={
|
||||||
"ordering": ["set_id"],
|
"ordering": ["set_id"],
|
||||||
"indexes": [
|
"indexes": [
|
||||||
models.Index(fields=["set_id"], name="twitch_chat_set_id_9319f2_idx"),
|
models.Index(
|
||||||
models.Index(fields=["added_at"], name="twitch_chat_added_a_b0023a_idx"),
|
fields=["set_id"],
|
||||||
models.Index(fields=["updated_at"], name="twitch_chat_updated_90afed_idx"),
|
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(
|
migrations.CreateModel(
|
||||||
name="ChatBadge",
|
name="ChatBadge",
|
||||||
fields=[
|
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",
|
"badge_id",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
|
|
@ -87,10 +110,19 @@ class Migration(migrations.Migration):
|
||||||
verbose_name="Image URL (72px)",
|
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",
|
"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",
|
"click_action",
|
||||||
|
|
@ -141,13 +173,30 @@ class Migration(migrations.Migration):
|
||||||
options={
|
options={
|
||||||
"ordering": ["badge_set", "badge_id"],
|
"ordering": ["badge_set", "badge_id"],
|
||||||
"indexes": [
|
"indexes": [
|
||||||
models.Index(fields=["badge_set"], name="twitch_chat_badge_s_54f225_idx"),
|
models.Index(
|
||||||
models.Index(fields=["badge_id"], name="twitch_chat_badge_i_58a68a_idx"),
|
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=["title"], name="twitch_chat_title_0f42d2_idx"),
|
||||||
models.Index(fields=["added_at"], name="twitch_chat_added_a_9ba7dd_idx"),
|
models.Index(
|
||||||
models.Index(fields=["updated_at"], name="twitch_chat_updated_568ad1_idx"),
|
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")],
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Generated by Django 6.0.1 on 2026-01-17 05:32
|
# 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 migrations
|
||||||
from django.db import models
|
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):
|
class Migration(migrations.Migration):
|
||||||
"""Rename operation_name field to operation_names and convert to list."""
|
"""Rename operation_name field to operation_names and convert to list."""
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("twitch", "0006_add_chat_badges")]
|
||||||
("twitch", "0006_add_chat_badges"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RemoveIndex(
|
migrations.RemoveIndex(
|
||||||
|
|
@ -41,7 +39,7 @@ class Migration(migrations.Migration):
|
||||||
field=models.JSONField(
|
field=models.JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=list,
|
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(
|
migrations.RunPython(
|
||||||
|
|
@ -50,10 +48,10 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
model_name="dropcampaign",
|
||||||
index=models.Index(fields=["operation_names"], name="twitch_drop_operati_fe3bc8_idx"),
|
index=models.Index(
|
||||||
),
|
fields=["operation_names"],
|
||||||
migrations.RemoveField(
|
name="twitch_drop_operati_fe3bc8_idx",
|
||||||
model_name="dropcampaign",
|
),
|
||||||
name="operation_name",
|
|
||||||
),
|
),
|
||||||
|
migrations.RemoveField(model_name="dropcampaign", name="operation_name"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,29 @@
|
||||||
# Generated by Django 6.0.2 on 2026-02-09 19:04
|
# Generated by Django 6.0.2 on 2026-02-09 19:04
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import django.db.models.manager
|
import django.db.models.manager
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
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 = [
|
dependencies = [("twitch", "0007_rename_operation_name_to_operation_names")]
|
||||||
("twitch", "0007_rename_operation_name_to_operation_names"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="channel",
|
name="channel",
|
||||||
options={"base_manager_name": "prefetch_manager", "ordering": ["display_name"]},
|
options={
|
||||||
|
"base_manager_name": "prefetch_manager",
|
||||||
|
"ordering": ["display_name"],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="chatbadge",
|
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(
|
migrations.AlterModelOptions(
|
||||||
name="chatbadgeset",
|
name="chatbadgeset",
|
||||||
|
|
@ -27,7 +31,10 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="dropbenefit",
|
name="dropbenefit",
|
||||||
options={"base_manager_name": "prefetch_manager", "ordering": ["-created_at"]},
|
options={
|
||||||
|
"base_manager_name": "prefetch_manager",
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="dropbenefitedge",
|
name="dropbenefitedge",
|
||||||
|
|
@ -35,11 +42,17 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="dropcampaign",
|
name="dropcampaign",
|
||||||
options={"base_manager_name": "prefetch_manager", "ordering": ["-start_at"]},
|
options={
|
||||||
|
"base_manager_name": "prefetch_manager",
|
||||||
|
"ordering": ["-start_at"],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="game",
|
name="game",
|
||||||
options={"base_manager_name": "prefetch_manager", "ordering": ["display_name"]},
|
options={
|
||||||
|
"base_manager_name": "prefetch_manager",
|
||||||
|
"ordering": ["display_name"],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="organization",
|
name="organization",
|
||||||
|
|
@ -47,7 +60,10 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="rewardcampaign",
|
name="rewardcampaign",
|
||||||
options={"base_manager_name": "prefetch_manager", "ordering": ["-starts_at"]},
|
options={
|
||||||
|
"base_manager_name": "prefetch_manager",
|
||||||
|
"ordering": ["-starts_at"],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="timebaseddrop",
|
name="timebaseddrop",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Generated by Django 6.0.2 on 2026-02-09 19:05
|
# Generated by Django 6.0.2 on 2026-02-09 19:05
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import auto_prefetch
|
import auto_prefetch
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
|
@ -7,7 +7,7 @@ from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
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 = [
|
dependencies = [
|
||||||
("twitch", "0008_alter_channel_options_alter_chatbadge_options_and_more"),
|
("twitch", "0008_alter_channel_options_alter_chatbadge_options_and_more"),
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
# Generated by Django 6.0.2 on 2026-02-11 22:55
|
# 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 migrations
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
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 = [
|
dependencies = [("twitch", "0009_alter_chatbadge_badge_set_and_more")]
|
||||||
("twitch", "0009_alter_chatbadge_badge_set_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# Generated by Django 6.0.2 on 2026-02-12 03:41
|
# 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 migrations
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
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 = [
|
dependencies = [
|
||||||
("twitch", "0010_rewardcampaign_image_file_rewardcampaign_image_url"),
|
("twitch", "0010_rewardcampaign_image_file_rewardcampaign_image_url"),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Generated by Django 6.0.2 on 2026-02-12 12:00
|
# 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.contrib.postgres.indexes import GinIndex
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
@ -19,6 +19,9 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="dropcampaign",
|
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",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
|
@ -87,20 +85,12 @@ class Game(auto_prefetch.Model):
|
||||||
verbose_name="Slug",
|
verbose_name="Slug",
|
||||||
help_text="Short unique identifier for the game.",
|
help_text="Short unique identifier for the game.",
|
||||||
)
|
)
|
||||||
name = models.TextField(
|
name = models.TextField(blank=True, default="", verbose_name="Name")
|
||||||
blank=True,
|
display_name = models.TextField(blank=True, default="", verbose_name="Display name")
|
||||||
default="",
|
|
||||||
verbose_name="Name",
|
|
||||||
)
|
|
||||||
display_name = models.TextField(
|
|
||||||
blank=True,
|
|
||||||
default="",
|
|
||||||
verbose_name="Display name",
|
|
||||||
)
|
|
||||||
box_art = models.URLField( # noqa: DJ001
|
box_art = models.URLField( # noqa: DJ001
|
||||||
max_length=500,
|
max_length=500,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True, # We allow null here to distinguish between no box art and empty string
|
null=True,
|
||||||
default="",
|
default="",
|
||||||
verbose_name="Box art URL",
|
verbose_name="Box art URL",
|
||||||
)
|
)
|
||||||
|
|
@ -243,7 +233,9 @@ class TwitchGameData(auto_prefetch.Model):
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
default="",
|
||||||
verbose_name="Box art URL",
|
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")
|
igdb_id = models.TextField(blank=True, default="", verbose_name="IGDB ID")
|
||||||
|
|
||||||
|
|
@ -322,9 +314,7 @@ class DropCampaign(auto_prefetch.Model):
|
||||||
editable=False,
|
editable=False,
|
||||||
help_text="The Twitch ID for this campaign.",
|
help_text="The Twitch ID for this campaign.",
|
||||||
)
|
)
|
||||||
name = models.TextField(
|
name = models.TextField(help_text="Name of the drop campaign.")
|
||||||
help_text="Name of the drop campaign.",
|
|
||||||
)
|
|
||||||
description = models.TextField(
|
description = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Detailed description of the campaign.",
|
help_text="Detailed description of the campaign.",
|
||||||
|
|
@ -399,7 +389,7 @@ class DropCampaign(auto_prefetch.Model):
|
||||||
operation_names = models.JSONField(
|
operation_names = models.JSONField(
|
||||||
default=list,
|
default=list,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).", # noqa: E501
|
help_text="List of GraphQL operation names used to fetch this campaign data (e.g., ['ViewerDropsDashboard', 'Inventory']).",
|
||||||
)
|
)
|
||||||
|
|
||||||
added_at = models.DateTimeField(
|
added_at = models.DateTimeField(
|
||||||
|
|
@ -486,10 +476,7 @@ class DropCampaign(auto_prefetch.Model):
|
||||||
if self.image_file and getattr(self.image_file, "url", None):
|
if self.image_file and getattr(self.image_file, "url", None):
|
||||||
return self.image_file.url
|
return self.image_file.url
|
||||||
except (AttributeError, OSError, ValueError) as exc:
|
except (AttributeError, OSError, ValueError) as exc:
|
||||||
logger.debug(
|
logger.debug("Failed to resolve DropCampaign.image_file url: %s", exc)
|
||||||
"Failed to resolve DropCampaign.image_file url: %s",
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.image_url:
|
if self.image_url:
|
||||||
return self.image_url
|
return self.image_url
|
||||||
|
|
@ -507,8 +494,9 @@ class DropCampaign(auto_prefetch.Model):
|
||||||
def duration_iso(self) -> str:
|
def duration_iso(self) -> str:
|
||||||
"""Return the campaign duration in ISO 8601 format (e.g., 'P3DT4H30M').
|
"""Return the campaign duration in ISO 8601 format (e.g., 'P3DT4H30M').
|
||||||
|
|
||||||
This is used for the <time> element's datetime attribute to provide machine-readable duration.
|
This is used for the <time> element's datetime attribute to provide
|
||||||
If start_at or end_at is missing, returns an empty string.
|
machine-readable duration. If start_at or end_at is missing, returns
|
||||||
|
an empty string.
|
||||||
"""
|
"""
|
||||||
if not self.start_at or not self.end_at:
|
if not self.start_at or not self.end_at:
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -628,7 +616,9 @@ class DropBenefit(auto_prefetch.Model):
|
||||||
)
|
)
|
||||||
created_at = models.DateTimeField(
|
created_at = models.DateTimeField(
|
||||||
null=True,
|
null=True,
|
||||||
help_text=("Timestamp when the benefit was created. This is from Twitch API and not auto-generated."),
|
help_text=(
|
||||||
|
"Timestamp when the benefit was created. This is from Twitch API and not auto-generated."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
entitlement_limit = models.PositiveIntegerField(
|
entitlement_limit = models.PositiveIntegerField(
|
||||||
default=1,
|
default=1,
|
||||||
|
|
@ -679,10 +669,7 @@ class DropBenefit(auto_prefetch.Model):
|
||||||
if self.image_file and getattr(self.image_file, "url", None):
|
if self.image_file and getattr(self.image_file, "url", None):
|
||||||
return self.image_file.url
|
return self.image_file.url
|
||||||
except (AttributeError, OSError, ValueError) as exc:
|
except (AttributeError, OSError, ValueError) as exc:
|
||||||
logger.debug(
|
logger.debug("Failed to resolve DropBenefit.image_file url: %s", exc)
|
||||||
"Failed to resolve DropBenefit.image_file url: %s",
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return self.image_asset_url or ""
|
return self.image_asset_url or ""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -743,9 +730,7 @@ class TimeBasedDrop(auto_prefetch.Model):
|
||||||
editable=False,
|
editable=False,
|
||||||
help_text="The Twitch ID for this time-based drop.",
|
help_text="The Twitch ID for this time-based drop.",
|
||||||
)
|
)
|
||||||
name = models.TextField(
|
name = models.TextField(help_text="Name of the time-based drop.")
|
||||||
help_text="Name of the time-based drop.",
|
|
||||||
)
|
|
||||||
required_minutes_watched = models.PositiveIntegerField(
|
required_minutes_watched = models.PositiveIntegerField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
@ -821,9 +806,7 @@ class RewardCampaign(auto_prefetch.Model):
|
||||||
editable=False,
|
editable=False,
|
||||||
help_text="The Twitch ID for this reward campaign.",
|
help_text="The Twitch ID for this reward campaign.",
|
||||||
)
|
)
|
||||||
name = models.TextField(
|
name = models.TextField(help_text="Name of the reward campaign.")
|
||||||
help_text="Name of the reward campaign.",
|
|
||||||
)
|
|
||||||
brand = models.TextField(
|
brand = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
default="",
|
||||||
|
|
@ -956,10 +939,7 @@ class RewardCampaign(auto_prefetch.Model):
|
||||||
if self.image_file and getattr(self.image_file, "url", None):
|
if self.image_file and getattr(self.image_file, "url", None):
|
||||||
return self.image_file.url
|
return self.image_file.url
|
||||||
except (AttributeError, OSError, ValueError) as exc:
|
except (AttributeError, OSError, ValueError) as exc:
|
||||||
logger.debug(
|
logger.debug("Failed to resolve RewardCampaign.image_file url: %s", exc)
|
||||||
"Failed to resolve RewardCampaign.image_file url: %s",
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return self.image_url or ""
|
return self.image_url or ""
|
||||||
|
|
||||||
def get_feed_title(self) -> str:
|
def get_feed_title(self) -> str:
|
||||||
|
|
@ -1002,15 +982,26 @@ class RewardCampaign(auto_prefetch.Model):
|
||||||
parts.append(format_html("<p>{}</p>", end_part))
|
parts.append(format_html("<p>{}</p>", end_part))
|
||||||
|
|
||||||
if self.is_sitewide:
|
if self.is_sitewide:
|
||||||
parts.append(SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"))
|
parts.append(
|
||||||
|
SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"),
|
||||||
|
)
|
||||||
elif self.game:
|
elif self.game:
|
||||||
parts.append(format_html("<p>Game: {}</p>", self.game.display_name or self.game.name))
|
parts.append(
|
||||||
|
format_html(
|
||||||
|
"<p>Game: {}</p>",
|
||||||
|
self.game.display_name or self.game.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if self.about_url:
|
if self.about_url:
|
||||||
parts.append(format_html('<p><a href="{}">Learn more</a></p>', self.about_url))
|
parts.append(
|
||||||
|
format_html('<p><a href="{}">Learn more</a></p>', self.about_url),
|
||||||
|
)
|
||||||
|
|
||||||
if self.external_url:
|
if self.external_url:
|
||||||
parts.append(format_html('<p><a href="{}">Redeem reward</a></p>', self.external_url))
|
parts.append(
|
||||||
|
format_html('<p><a href="{}">Redeem reward</a></p>', self.external_url),
|
||||||
|
)
|
||||||
|
|
||||||
return "".join(str(p) for p in parts)
|
return "".join(str(p) for p in parts)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -31,12 +29,24 @@ class GameSchema(BaseModel):
|
||||||
Handles both ViewerDropsDashboard and Inventory operation formats.
|
Handles both ViewerDropsDashboard and Inventory operation formats.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
twitch_id: str = Field(alias="id") # Present in both ViewerDropsDashboard and Inventory formats
|
# Present in both ViewerDropsDashboard and Inventory formats
|
||||||
display_name: str | None = Field(default=None, alias="displayName") # Present in both formats
|
twitch_id: str = Field(alias="id")
|
||||||
box_art_url: str | None = Field(default=None, alias="boxArtURL") # Present in both formats, made optional
|
|
||||||
slug: str | None = None # Present in Inventory format
|
# Present in both formats
|
||||||
name: str | None = None # Present in Inventory format (alternative to displayName)
|
display_name: str | None = Field(default=None, alias="displayName")
|
||||||
type_name: Literal["Game"] = Field(alias="__typename") # Present in both formats
|
|
||||||
|
# Present in both formats, made optional
|
||||||
|
box_art_url: str | None = Field(default=None, alias="boxArtURL")
|
||||||
|
|
||||||
|
# Present in Inventory format
|
||||||
|
slug: str | None = None
|
||||||
|
|
||||||
|
# Present in Inventory format (alternative to displayName)
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
# Present in both formats
|
||||||
|
type_name: Literal["Game"] = Field(alias="__typename")
|
||||||
|
|
||||||
owner_organization: dict | None = Field(default=None, alias="ownerOrganization")
|
owner_organization: dict | None = Field(default=None, alias="ownerOrganization")
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
|
|
@ -51,8 +61,9 @@ class GameSchema(BaseModel):
|
||||||
def normalize_box_art_url(cls, v: str | None) -> str | None:
|
def normalize_box_art_url(cls, v: str | None) -> str | None:
|
||||||
"""Normalize Twitch box art URLs to higher quality variants.
|
"""Normalize Twitch box art URLs to higher quality variants.
|
||||||
|
|
||||||
Twitch's box art URLs often include size suffixes (e.g. -120x160) that point to lower quality images.
|
Twitch's box art URLs often include size suffixes (e.g. -120x160)
|
||||||
This validator removes those suffixes to get the original higher quality image.
|
that point to lower quality images. This validator removes those
|
||||||
|
suffixes to get the original higher quality image.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
v: The raw box_art_url value (str or None).
|
v: The raw box_art_url value (str or None).
|
||||||
|
|
@ -146,8 +157,11 @@ class DropBenefitSchema(BaseModel):
|
||||||
created_at: str | None = Field(default=None, alias="createdAt")
|
created_at: str | None = Field(default=None, alias="createdAt")
|
||||||
entitlement_limit: int = Field(default=1, alias="entitlementLimit")
|
entitlement_limit: int = Field(default=1, alias="entitlementLimit")
|
||||||
is_ios_available: bool = Field(default=False, alias="isIosAvailable")
|
is_ios_available: bool = Field(default=False, alias="isIosAvailable")
|
||||||
distribution_type: str | None = Field(default=None, alias="distributionType") # Optional in some API responses
|
|
||||||
|
# Optional in some API responses
|
||||||
|
distribution_type: str | None = Field(default=None, alias="distributionType")
|
||||||
type_name: Literal["Benefit", "DropBenefit"] = Field(alias="__typename")
|
type_name: Literal["Benefit", "DropBenefit"] = Field(alias="__typename")
|
||||||
|
|
||||||
# API response fields that should be ignored
|
# API response fields that should be ignored
|
||||||
game: dict | None = None
|
game: dict | None = None
|
||||||
owner_organization: dict | None = Field(default=None, alias="ownerOrganization")
|
owner_organization: dict | None = Field(default=None, alias="ownerOrganization")
|
||||||
|
|
@ -169,7 +183,10 @@ class DropBenefitEdgeSchema(BaseModel):
|
||||||
benefit: DropBenefitSchema
|
benefit: DropBenefitSchema
|
||||||
entitlement_limit: int = Field(alias="entitlementLimit")
|
entitlement_limit: int = Field(alias="entitlementLimit")
|
||||||
claim_count: int | None = Field(default=None, alias="claimCount")
|
claim_count: int | None = Field(default=None, alias="claimCount")
|
||||||
type_name: Literal["DropBenefitEdge"] | None = Field(default=None, alias="__typename")
|
type_name: Literal["DropBenefitEdge"] | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="__typename",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "forbid",
|
"extra": "forbid",
|
||||||
|
|
@ -193,6 +210,7 @@ class TimeBasedDropSchema(BaseModel):
|
||||||
end_at: str | None = Field(alias="endAt")
|
end_at: str | None = Field(alias="endAt")
|
||||||
benefit_edges: list[DropBenefitEdgeSchema] = Field(default=[], alias="benefitEdges")
|
benefit_edges: list[DropBenefitEdgeSchema] = Field(default=[], alias="benefitEdges")
|
||||||
type_name: Literal["TimeBasedDrop"] = Field(alias="__typename")
|
type_name: Literal["TimeBasedDrop"] = Field(alias="__typename")
|
||||||
|
|
||||||
# Inventory-specific fields
|
# Inventory-specific fields
|
||||||
precondition_drops: None = Field(default=None, alias="preconditionDrops")
|
precondition_drops: None = Field(default=None, alias="preconditionDrops")
|
||||||
self_edge: dict | None = Field(default=None, alias="self")
|
self_edge: dict | None = Field(default=None, alias="self")
|
||||||
|
|
@ -237,11 +255,16 @@ class DropCampaignSchema(BaseModel):
|
||||||
self: DropCampaignSelfEdgeSchema
|
self: DropCampaignSelfEdgeSchema
|
||||||
start_at: str = Field(alias="startAt")
|
start_at: str = Field(alias="startAt")
|
||||||
status: Literal["ACTIVE", "EXPIRED", "UPCOMING"]
|
status: Literal["ACTIVE", "EXPIRED", "UPCOMING"]
|
||||||
time_based_drops: list[TimeBasedDropSchema] = Field(default=[], alias="timeBasedDrops")
|
time_based_drops: list[TimeBasedDropSchema] = Field(
|
||||||
|
default=[],
|
||||||
|
alias="timeBasedDrops",
|
||||||
|
)
|
||||||
twitch_id: str = Field(alias="id")
|
twitch_id: str = Field(alias="id")
|
||||||
type_name: Literal["DropCampaign"] = Field(alias="__typename")
|
type_name: Literal["DropCampaign"] = Field(alias="__typename")
|
||||||
|
|
||||||
# Campaign access control list - defines which channels can participate
|
# Campaign access control list - defines which channels can participate
|
||||||
allow: DropCampaignACLSchema | None = None
|
allow: DropCampaignACLSchema | None = None
|
||||||
|
|
||||||
# Inventory-specific fields
|
# Inventory-specific fields
|
||||||
event_based_drops: list | None = Field(default=None, alias="eventBasedDrops")
|
event_based_drops: list | None = Field(default=None, alias="eventBasedDrops")
|
||||||
|
|
||||||
|
|
@ -272,8 +295,12 @@ class DropCampaignSchema(BaseModel):
|
||||||
class InventorySchema(BaseModel):
|
class InventorySchema(BaseModel):
|
||||||
"""Schema for the inventory field in Inventory operation responses."""
|
"""Schema for the inventory field in Inventory operation responses."""
|
||||||
|
|
||||||
drop_campaigns_in_progress: list[DropCampaignSchema] = Field(default=[], alias="dropCampaignsInProgress")
|
drop_campaigns_in_progress: list[DropCampaignSchema] = Field(
|
||||||
|
default=[],
|
||||||
|
alias="dropCampaignsInProgress",
|
||||||
|
)
|
||||||
type_name: Literal["Inventory"] = Field(alias="__typename")
|
type_name: Literal["Inventory"] = Field(alias="__typename")
|
||||||
|
|
||||||
# gameEventDrops field is present in Inventory but we don't process it yet
|
# gameEventDrops field is present in Inventory but we don't process it yet
|
||||||
game_event_drops: list | None = Field(default=None, alias="gameEventDrops")
|
game_event_drops: list | None = Field(default=None, alias="gameEventDrops")
|
||||||
|
|
||||||
|
|
@ -307,7 +334,10 @@ class CurrentUserSchema(BaseModel):
|
||||||
|
|
||||||
twitch_id: str = Field(alias="id")
|
twitch_id: str = Field(alias="id")
|
||||||
login: str | None = None
|
login: str | None = None
|
||||||
drop_campaigns: list[DropCampaignSchema] | None = Field(default=None, alias="dropCampaigns")
|
drop_campaigns: list[DropCampaignSchema] | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="dropCampaigns",
|
||||||
|
)
|
||||||
drop_campaign: DropCampaignSchema | None = Field(default=None, alias="dropCampaign")
|
drop_campaign: DropCampaignSchema | None = Field(default=None, alias="dropCampaign")
|
||||||
inventory: InventorySchema | None = None
|
inventory: InventorySchema | None = None
|
||||||
type_name: Literal["User"] = Field(alias="__typename")
|
type_name: Literal["User"] = Field(alias="__typename")
|
||||||
|
|
@ -467,8 +497,14 @@ class Reward(BaseModel):
|
||||||
|
|
||||||
twitch_id: str = Field(alias="id")
|
twitch_id: str = Field(alias="id")
|
||||||
name: str
|
name: str
|
||||||
banner_image: RewardCampaignImageSet | None = Field(default=None, alias="bannerImage")
|
banner_image: RewardCampaignImageSet | None = Field(
|
||||||
thumbnail_image: RewardCampaignImageSet | None = Field(default=None, alias="thumbnailImage")
|
default=None,
|
||||||
|
alias="bannerImage",
|
||||||
|
)
|
||||||
|
thumbnail_image: RewardCampaignImageSet | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="thumbnailImage",
|
||||||
|
)
|
||||||
earnable_until: str | None = Field(default=None, alias="earnableUntil")
|
earnable_until: str | None = Field(default=None, alias="earnableUntil")
|
||||||
redemption_instructions: str = Field(default="", alias="redemptionInstructions")
|
redemption_instructions: str = Field(default="", alias="redemptionInstructions")
|
||||||
redemption_url: str = Field(default="", alias="redemptionURL")
|
redemption_url: str = Field(default="", alias="redemptionURL")
|
||||||
|
|
@ -498,7 +534,10 @@ class RewardCampaign(BaseModel):
|
||||||
about_url: str = Field(default="", alias="aboutURL")
|
about_url: str = Field(default="", alias="aboutURL")
|
||||||
is_sitewide: bool = Field(default=False, alias="isSitewide")
|
is_sitewide: bool = Field(default=False, alias="isSitewide")
|
||||||
game: dict | None = None
|
game: dict | None = None
|
||||||
unlock_requirements: QuestRewardUnlockRequirements | None = Field(default=None, alias="unlockRequirements")
|
unlock_requirements: QuestRewardUnlockRequirements | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="unlockRequirements",
|
||||||
|
)
|
||||||
image: RewardCampaignImageSet | None = None
|
image: RewardCampaignImageSet | None = None
|
||||||
rewards: list[Reward] = Field(default=[])
|
rewards: list[Reward] = Field(default=[])
|
||||||
type_name: Literal["RewardCampaign"] = Field(alias="__typename")
|
type_name: Literal["RewardCampaign"] = Field(alias="__typename")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Custom template tags for rendering responsive images with modern formats."""
|
"""Custom template tags for rendering responsive images with modern formats."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
@ -94,11 +92,15 @@ def picture( # noqa: PLR0913, PLR0917
|
||||||
|
|
||||||
# AVIF first (best compression)
|
# AVIF first (best compression)
|
||||||
if avif_url != src:
|
if avif_url != src:
|
||||||
sources.append(format_html('<source srcset="{}" type="image/avif" />', avif_url))
|
sources.append(
|
||||||
|
format_html('<source srcset="{}" type="image/avif" />', avif_url),
|
||||||
|
)
|
||||||
|
|
||||||
# WebP second (good compression, widely supported)
|
# WebP second (good compression, widely supported)
|
||||||
if webp_url != src:
|
if webp_url != src:
|
||||||
sources.append(format_html('<source srcset="{}" type="image/webp" />', webp_url))
|
sources.append(
|
||||||
|
format_html('<source srcset="{}" type="image/webp" />', webp_url),
|
||||||
|
)
|
||||||
|
|
||||||
# Build img tag with format_html
|
# Build img tag with format_html
|
||||||
img_html: SafeString = format_html(
|
img_html: SafeString = format_html(
|
||||||
|
|
@ -113,4 +115,8 @@ def picture( # noqa: PLR0913, PLR0917
|
||||||
)
|
)
|
||||||
|
|
||||||
# Combine all parts safely
|
# Combine all parts safely
|
||||||
return format_html("<picture>{}{}</picture>", SafeString("".join(sources)), img_html)
|
return format_html(
|
||||||
|
"<picture>{}{}</picture>",
|
||||||
|
SafeString("".join(sources)),
|
||||||
|
img_html,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
|
@ -142,7 +140,11 @@ class TestBackupCommand:
|
||||||
assert output_dir.exists()
|
assert output_dir.exists()
|
||||||
assert len(list(output_dir.glob("test-*.sql.zst"))) == 1
|
assert len(list(output_dir.glob("test-*.sql.zst"))) == 1
|
||||||
|
|
||||||
def test_backup_uses_default_directory(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_backup_uses_default_directory(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
"""Test that backup uses DATA_DIR/datasets by default."""
|
"""Test that backup uses DATA_DIR/datasets by default."""
|
||||||
_skip_if_pg_dump_missing()
|
_skip_if_pg_dump_missing()
|
||||||
# Create test data so tables exist
|
# Create test data so tables exist
|
||||||
|
|
@ -285,7 +287,9 @@ class TestDatasetBackupViews:
|
||||||
"""Test that dataset list view displays backup files."""
|
"""Test that dataset list view displays backup files."""
|
||||||
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
||||||
|
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:dataset_backups"))
|
response: _MonkeyPatchedWSGIResponse = client.get(
|
||||||
|
reverse("twitch:dataset_backups"),
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert b"ttvdrops-20260210-120000.sql.zst" in response.content
|
assert b"ttvdrops-20260210-120000.sql.zst" in response.content
|
||||||
|
|
@ -300,7 +304,9 @@ class TestDatasetBackupViews:
|
||||||
"""Test dataset list view with empty directory."""
|
"""Test dataset list view with empty directory."""
|
||||||
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
||||||
|
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:dataset_backups"))
|
response: _MonkeyPatchedWSGIResponse = client.get(
|
||||||
|
reverse("twitch:dataset_backups"),
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert b"No dataset backups found" in response.content
|
assert b"No dataset backups found" in response.content
|
||||||
|
|
@ -332,7 +338,9 @@ class TestDatasetBackupViews:
|
||||||
os.utime(older_backup, (older_time, older_time))
|
os.utime(older_backup, (older_time, older_time))
|
||||||
os.utime(newer_backup, (newer_time, newer_time))
|
os.utime(newer_backup, (newer_time, newer_time))
|
||||||
|
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:dataset_backups"))
|
response: _MonkeyPatchedWSGIResponse = client.get(
|
||||||
|
reverse("twitch:dataset_backups"),
|
||||||
|
)
|
||||||
|
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
newer_pos = content.find("20260210-140000")
|
newer_pos = content.find("20260210-140000")
|
||||||
|
|
@ -352,7 +360,10 @@ class TestDatasetBackupViews:
|
||||||
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
||||||
|
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(
|
response: _MonkeyPatchedWSGIResponse = client.get(
|
||||||
reverse("twitch:dataset_backup_download", args=["ttvdrops-20260210-120000.sql.zst"]),
|
reverse(
|
||||||
|
"twitch:dataset_backup_download",
|
||||||
|
args=["ttvdrops-20260210-120000.sql.zst"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
@ -370,7 +381,9 @@ class TestDatasetBackupViews:
|
||||||
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
||||||
|
|
||||||
# Attempt path traversal
|
# Attempt path traversal
|
||||||
response = client.get(reverse("twitch:dataset_backup_download", args=["../../../etc/passwd"]))
|
response = client.get(
|
||||||
|
reverse("twitch:dataset_backup_download", args=["../../../etc/passwd"]),
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_dataset_download_rejects_invalid_extensions(
|
def test_dataset_download_rejects_invalid_extensions(
|
||||||
|
|
@ -386,7 +399,9 @@ class TestDatasetBackupViews:
|
||||||
invalid_file = datasets_dir / "malicious.exe"
|
invalid_file = datasets_dir / "malicious.exe"
|
||||||
invalid_file.write_text("not a backup")
|
invalid_file.write_text("not a backup")
|
||||||
|
|
||||||
response = client.get(reverse("twitch:dataset_backup_download", args=["malicious.exe"]))
|
response = client.get(
|
||||||
|
reverse("twitch:dataset_backup_download", args=["malicious.exe"]),
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_dataset_download_file_not_found(
|
def test_dataset_download_file_not_found(
|
||||||
|
|
@ -398,7 +413,9 @@ class TestDatasetBackupViews:
|
||||||
"""Test download returns 404 for non-existent file."""
|
"""Test download returns 404 for non-existent file."""
|
||||||
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
||||||
|
|
||||||
response = client.get(reverse("twitch:dataset_backup_download", args=["nonexistent.sql.zst"]))
|
response = client.get(
|
||||||
|
reverse("twitch:dataset_backup_download", args=["nonexistent.sql.zst"]),
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_dataset_list_view_shows_file_sizes(
|
def test_dataset_list_view_shows_file_sizes(
|
||||||
|
|
@ -411,7 +428,9 @@ class TestDatasetBackupViews:
|
||||||
"""Test that file sizes are displayed in human-readable format."""
|
"""Test that file sizes are displayed in human-readable format."""
|
||||||
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
monkeypatch.setattr(settings, "DATA_DIR", datasets_dir.parent)
|
||||||
|
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:dataset_backups"))
|
response: _MonkeyPatchedWSGIResponse = client.get(
|
||||||
|
reverse("twitch:dataset_backups"),
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
# Should contain size information (bytes, KB, MB, or GB)
|
# Should contain size information (bytes, KB, MB, or GB)
|
||||||
|
|
@ -432,7 +451,9 @@ class TestDatasetBackupViews:
|
||||||
(datasets_dir / "readme.txt").write_text("should be ignored")
|
(datasets_dir / "readme.txt").write_text("should be ignored")
|
||||||
(datasets_dir / "old_backup.gz").write_bytes(b"should be ignored")
|
(datasets_dir / "old_backup.gz").write_bytes(b"should be ignored")
|
||||||
|
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:dataset_backups"))
|
response: _MonkeyPatchedWSGIResponse = client.get(
|
||||||
|
reverse("twitch:dataset_backups"),
|
||||||
|
)
|
||||||
|
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
assert "backup.sql.zst" in content
|
assert "backup.sql.zst" in content
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Tests for chat badge views."""
|
"""Tests for chat badge views."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -73,7 +71,9 @@ class TestBadgeSetDetailView:
|
||||||
|
|
||||||
def test_badge_set_detail_not_found(self, client: Client) -> None:
|
def test_badge_set_detail_not_found(self, client: Client) -> None:
|
||||||
"""Test 404 when badge set doesn't exist."""
|
"""Test 404 when badge set doesn't exist."""
|
||||||
response = client.get(reverse("twitch:badge_set_detail", kwargs={"set_id": "nonexistent"}))
|
response = client.get(
|
||||||
|
reverse("twitch:badge_set_detail", kwargs={"set_id": "nonexistent"}),
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_badge_set_detail_displays_badges(self, client: Client) -> None:
|
def test_badge_set_detail_displays_badges(self, client: Client) -> None:
|
||||||
|
|
@ -91,7 +91,9 @@ class TestBadgeSetDetailView:
|
||||||
click_url="https://help.twitch.tv",
|
click_url="https://help.twitch.tv",
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(reverse("twitch:badge_set_detail", kwargs={"set_id": "moderator"}))
|
response = client.get(
|
||||||
|
reverse("twitch:badge_set_detail", kwargs={"set_id": "moderator"}),
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
|
|
||||||
|
|
@ -113,7 +115,9 @@ class TestBadgeSetDetailView:
|
||||||
description="VIP Badge",
|
description="VIP Badge",
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(reverse("twitch:badge_set_detail", kwargs={"set_id": "vip"}))
|
response = client.get(
|
||||||
|
reverse("twitch:badge_set_detail", kwargs={"set_id": "vip"}),
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
|
|
||||||
|
|
@ -133,7 +137,9 @@ class TestBadgeSetDetailView:
|
||||||
description="Test Badge",
|
description="Test Badge",
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(reverse("twitch:badge_set_detail", kwargs={"set_id": "test_set"}))
|
response = client.get(
|
||||||
|
reverse("twitch:badge_set_detail", kwargs={"set_id": "test_set"}),
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from unittest import skipIf
|
from unittest import skipIf
|
||||||
|
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
|
@ -9,7 +8,6 @@ from django.test import TestCase
|
||||||
|
|
||||||
from twitch.management.commands.better_import_drops import Command
|
from twitch.management.commands.better_import_drops import Command
|
||||||
from twitch.management.commands.better_import_drops import detect_error_only_response
|
from twitch.management.commands.better_import_drops import detect_error_only_response
|
||||||
from twitch.models import Channel
|
|
||||||
from twitch.models import DropBenefit
|
from twitch.models import DropBenefit
|
||||||
from twitch.models import DropCampaign
|
from twitch.models import DropCampaign
|
||||||
from twitch.models import Game
|
from twitch.models import Game
|
||||||
|
|
@ -17,25 +15,24 @@ from twitch.models import Organization
|
||||||
from twitch.models import TimeBasedDrop
|
from twitch.models import TimeBasedDrop
|
||||||
from twitch.schemas import DropBenefitSchema
|
from twitch.schemas import DropBenefitSchema
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from twitch.models import Channel
|
||||||
|
|
||||||
|
|
||||||
class GetOrUpdateBenefitTests(TestCase):
|
class GetOrUpdateBenefitTests(TestCase):
|
||||||
"""Tests for the _get_or_update_benefit method in better_import_drops.Command."""
|
"""Tests for the _get_or_update_benefit method in better_import_drops.Command."""
|
||||||
|
|
||||||
def test_defaults_distribution_type_when_missing(self) -> None:
|
def test_defaults_distribution_type_when_missing(self) -> None:
|
||||||
"""Ensure importer sets distribution_type to empty string when absent."""
|
"""Ensure importer sets distribution_type to empty string when absent."""
|
||||||
command = Command()
|
command: Command = Command()
|
||||||
command.benefit_cache = {}
|
benefit_schema: DropBenefitSchema = DropBenefitSchema.model_validate({
|
||||||
|
"id": "benefit-missing-distribution-type",
|
||||||
benefit_schema: DropBenefitSchema = DropBenefitSchema.model_validate(
|
"name": "Test Benefit",
|
||||||
{
|
"imageAssetURL": "https://example.com/benefit.png",
|
||||||
"id": "benefit-missing-distribution-type",
|
"entitlementLimit": 1,
|
||||||
"name": "Test Benefit",
|
"isIosAvailable": False,
|
||||||
"imageAssetURL": "https://example.com/benefit.png",
|
"__typename": "DropBenefit",
|
||||||
"entitlementLimit": 1,
|
})
|
||||||
"isIosAvailable": False,
|
|
||||||
"__typename": "DropBenefit",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
benefit: DropBenefit = command._get_or_update_benefit(benefit_schema)
|
benefit: DropBenefit = command._get_or_update_benefit(benefit_schema)
|
||||||
|
|
||||||
|
|
@ -64,7 +61,10 @@ class ExtractCampaignsTests(TestCase):
|
||||||
"detailsURL": "http://example.com",
|
"detailsURL": "http://example.com",
|
||||||
"imageURL": "",
|
"imageURL": "",
|
||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
"self": {"isAccountConnected": False, "__typename": "DropCampaignSelfEdge"},
|
"self": {
|
||||||
|
"isAccountConnected": False,
|
||||||
|
"__typename": "DropCampaignSelfEdge",
|
||||||
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"id": "g1",
|
"id": "g1",
|
||||||
"displayName": "Test Game",
|
"displayName": "Test Game",
|
||||||
|
|
@ -82,9 +82,7 @@ class ExtractCampaignsTests(TestCase):
|
||||||
"__typename": "User",
|
"__typename": "User",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {"operationName": "TestOp"},
|
||||||
"operationName": "TestOp",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validate response
|
# Validate response
|
||||||
|
|
@ -147,9 +145,7 @@ class ExtractCampaignsTests(TestCase):
|
||||||
"__typename": "User",
|
"__typename": "User",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {"operationName": "Inventory"},
|
||||||
"operationName": "Inventory",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validate and process response
|
# Validate and process response
|
||||||
|
|
@ -163,7 +159,9 @@ class ExtractCampaignsTests(TestCase):
|
||||||
assert broken_dir is None
|
assert broken_dir is None
|
||||||
|
|
||||||
# Check that campaign was created with operation_name
|
# Check that campaign was created with operation_name
|
||||||
campaign: DropCampaign = DropCampaign.objects.get(twitch_id="inventory-campaign-1")
|
campaign: DropCampaign = DropCampaign.objects.get(
|
||||||
|
twitch_id="inventory-campaign-1",
|
||||||
|
)
|
||||||
assert campaign.name == "Test Inventory Campaign"
|
assert campaign.name == "Test Inventory Campaign"
|
||||||
assert campaign.operation_names == ["Inventory"]
|
assert campaign.operation_names == ["Inventory"]
|
||||||
|
|
||||||
|
|
@ -184,9 +182,7 @@ class ExtractCampaignsTests(TestCase):
|
||||||
"__typename": "User",
|
"__typename": "User",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {"operationName": "Inventory"},
|
||||||
"operationName": "Inventory",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Should validate successfully even with null campaigns
|
# Should validate successfully even with null campaigns
|
||||||
|
|
@ -261,9 +257,7 @@ class ExtractCampaignsTests(TestCase):
|
||||||
"__typename": "User",
|
"__typename": "User",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {"operationName": "Inventory"},
|
||||||
"operationName": "Inventory",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validate and process response
|
# Validate and process response
|
||||||
|
|
@ -277,7 +271,9 @@ class ExtractCampaignsTests(TestCase):
|
||||||
assert broken_dir is None
|
assert broken_dir is None
|
||||||
|
|
||||||
# Check that campaign was created and allow_is_enabled defaults to True
|
# Check that campaign was created and allow_is_enabled defaults to True
|
||||||
campaign: DropCampaign = DropCampaign.objects.get(twitch_id="inventory-campaign-2")
|
campaign: DropCampaign = DropCampaign.objects.get(
|
||||||
|
twitch_id="inventory-campaign-2",
|
||||||
|
)
|
||||||
assert campaign.name == "Test ACL Campaign"
|
assert campaign.name == "Test ACL Campaign"
|
||||||
assert campaign.allow_is_enabled is True # Should default to True
|
assert campaign.allow_is_enabled is True # Should default to True
|
||||||
|
|
||||||
|
|
@ -304,10 +300,7 @@ class CampaignStructureDetectionTests(TestCase):
|
||||||
"id": "123",
|
"id": "123",
|
||||||
"inventory": {
|
"inventory": {
|
||||||
"dropCampaignsInProgress": [
|
"dropCampaignsInProgress": [
|
||||||
{
|
{"id": "c1", "name": "Test Campaign"},
|
||||||
"id": "c1",
|
|
||||||
"name": "Test Campaign",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"__typename": "Inventory",
|
"__typename": "Inventory",
|
||||||
},
|
},
|
||||||
|
|
@ -349,12 +342,7 @@ class CampaignStructureDetectionTests(TestCase):
|
||||||
"data": {
|
"data": {
|
||||||
"currentUser": {
|
"currentUser": {
|
||||||
"id": "123",
|
"id": "123",
|
||||||
"dropCampaigns": [
|
"dropCampaigns": [{"id": "c1", "name": "Test Campaign"}],
|
||||||
{
|
|
||||||
"id": "c1",
|
|
||||||
"name": "Test Campaign",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"__typename": "User",
|
"__typename": "User",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -367,7 +355,10 @@ class CampaignStructureDetectionTests(TestCase):
|
||||||
class OperationNameFilteringTests(TestCase):
|
class OperationNameFilteringTests(TestCase):
|
||||||
"""Tests for filtering campaigns by operation_name field."""
|
"""Tests for filtering campaigns by operation_name field."""
|
||||||
|
|
||||||
@skipIf(connection.vendor == "sqlite", reason="SQLite doesn't support JSON contains lookup")
|
@skipIf(
|
||||||
|
connection.vendor == "sqlite",
|
||||||
|
reason="SQLite doesn't support JSON contains lookup",
|
||||||
|
)
|
||||||
def test_can_filter_campaigns_by_operation_name(self) -> None:
|
def test_can_filter_campaigns_by_operation_name(self) -> None:
|
||||||
"""Ensure campaigns can be filtered by operation_name to separate data sources."""
|
"""Ensure campaigns can be filtered by operation_name to separate data sources."""
|
||||||
command = Command()
|
command = Command()
|
||||||
|
|
@ -388,7 +379,10 @@ class OperationNameFilteringTests(TestCase):
|
||||||
"detailsURL": "https://example.com",
|
"detailsURL": "https://example.com",
|
||||||
"imageURL": "",
|
"imageURL": "",
|
||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
"self": {"isAccountConnected": False, "__typename": "DropCampaignSelfEdge"},
|
"self": {
|
||||||
|
"isAccountConnected": False,
|
||||||
|
"__typename": "DropCampaignSelfEdge",
|
||||||
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"id": "game-1",
|
"id": "game-1",
|
||||||
"displayName": "Game 1",
|
"displayName": "Game 1",
|
||||||
|
|
@ -407,9 +401,7 @@ class OperationNameFilteringTests(TestCase):
|
||||||
"__typename": "User",
|
"__typename": "User",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {"operationName": "ViewerDropsDashboard"},
|
||||||
"operationName": "ViewerDropsDashboard",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Import an Inventory campaign
|
# Import an Inventory campaign
|
||||||
|
|
@ -429,7 +421,10 @@ class OperationNameFilteringTests(TestCase):
|
||||||
"detailsURL": "https://example.com",
|
"detailsURL": "https://example.com",
|
||||||
"imageURL": "",
|
"imageURL": "",
|
||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
"self": {"isAccountConnected": True, "__typename": "DropCampaignSelfEdge"},
|
"self": {
|
||||||
|
"isAccountConnected": True,
|
||||||
|
"__typename": "DropCampaignSelfEdge",
|
||||||
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"id": "game-2",
|
"id": "game-2",
|
||||||
"displayName": "Game 2",
|
"displayName": "Game 2",
|
||||||
|
|
@ -452,9 +447,7 @@ class OperationNameFilteringTests(TestCase):
|
||||||
"__typename": "User",
|
"__typename": "User",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {"operationName": "Inventory"},
|
||||||
"operationName": "Inventory",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Process both payloads
|
# Process both payloads
|
||||||
|
|
@ -462,8 +455,12 @@ class OperationNameFilteringTests(TestCase):
|
||||||
command.process_responses([inventory_payload], Path("inventory.json"), {})
|
command.process_responses([inventory_payload], Path("inventory.json"), {})
|
||||||
|
|
||||||
# Verify we can filter by operation_names with JSON containment
|
# Verify we can filter by operation_names with JSON containment
|
||||||
viewer_campaigns = DropCampaign.objects.filter(operation_names__contains=["ViewerDropsDashboard"])
|
viewer_campaigns = DropCampaign.objects.filter(
|
||||||
inventory_campaigns = DropCampaign.objects.filter(operation_names__contains=["Inventory"])
|
operation_names__contains=["ViewerDropsDashboard"],
|
||||||
|
)
|
||||||
|
inventory_campaigns = DropCampaign.objects.filter(
|
||||||
|
operation_names__contains=["Inventory"],
|
||||||
|
)
|
||||||
|
|
||||||
assert len(viewer_campaigns) >= 1
|
assert len(viewer_campaigns) >= 1
|
||||||
assert len(inventory_campaigns) >= 1
|
assert len(inventory_campaigns) >= 1
|
||||||
|
|
@ -501,7 +498,10 @@ class GameImportTests(TestCase):
|
||||||
"detailsURL": "https://example.com/details",
|
"detailsURL": "https://example.com/details",
|
||||||
"imageURL": "",
|
"imageURL": "",
|
||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
"self": {"isAccountConnected": True, "__typename": "DropCampaignSelfEdge"},
|
"self": {
|
||||||
|
"isAccountConnected": True,
|
||||||
|
"__typename": "DropCampaignSelfEdge",
|
||||||
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"id": "497057",
|
"id": "497057",
|
||||||
"slug": "destiny-2",
|
"slug": "destiny-2",
|
||||||
|
|
@ -558,12 +558,17 @@ class ExampleJsonImportTests(TestCase):
|
||||||
assert success is True
|
assert success is True
|
||||||
assert broken_dir is None
|
assert broken_dir is None
|
||||||
|
|
||||||
campaign: DropCampaign = DropCampaign.objects.get(twitch_id="3b965979-ecd2-11f0-876e-0a58a9feac02")
|
campaign: DropCampaign = DropCampaign.objects.get(
|
||||||
|
twitch_id="3b965979-ecd2-11f0-876e-0a58a9feac02",
|
||||||
|
)
|
||||||
|
|
||||||
# Core campaign fields
|
# Core campaign fields
|
||||||
assert campaign.name == "Jan Drops Week 2"
|
assert campaign.name == "Jan Drops Week 2"
|
||||||
assert "Viewers will receive 50 Wandering Market Coins" in campaign.description
|
assert "Viewers will receive 50 Wandering Market Coins" in campaign.description
|
||||||
assert campaign.details_url == "https://www.smite2.com/news/closed-alpha-twitch-drops/"
|
assert (
|
||||||
|
campaign.details_url
|
||||||
|
== "https://www.smite2.com/news/closed-alpha-twitch-drops/"
|
||||||
|
)
|
||||||
assert campaign.account_link_url == "https://link.smite2.com/"
|
assert campaign.account_link_url == "https://link.smite2.com/"
|
||||||
|
|
||||||
# The regression: ensure imageURL makes it into DropCampaign.image_url
|
# The regression: ensure imageURL makes it into DropCampaign.image_url
|
||||||
|
|
@ -584,17 +589,23 @@ class ExampleJsonImportTests(TestCase):
|
||||||
assert game.display_name == "SMITE 2"
|
assert game.display_name == "SMITE 2"
|
||||||
assert game.slug == "smite-2"
|
assert game.slug == "smite-2"
|
||||||
|
|
||||||
org: Organization = Organization.objects.get(twitch_id="51a157a0-674a-4863-b120-7bb6ee2466a8")
|
org: Organization = Organization.objects.get(
|
||||||
|
twitch_id="51a157a0-674a-4863-b120-7bb6ee2466a8",
|
||||||
|
)
|
||||||
assert org.name == "Hi-Rez Studios"
|
assert org.name == "Hi-Rez Studios"
|
||||||
assert game.owners.filter(pk=org.pk).exists()
|
assert game.owners.filter(pk=org.pk).exists()
|
||||||
|
|
||||||
# Drops + benefits
|
# Drops + benefits
|
||||||
assert TimeBasedDrop.objects.filter(campaign=campaign).count() == 6
|
assert TimeBasedDrop.objects.filter(campaign=campaign).count() == 6
|
||||||
first_drop: TimeBasedDrop = TimeBasedDrop.objects.get(twitch_id="933c8f91-ecd2-11f0-b3fd-0a58a9feac02")
|
first_drop: TimeBasedDrop = TimeBasedDrop.objects.get(
|
||||||
|
twitch_id="933c8f91-ecd2-11f0-b3fd-0a58a9feac02",
|
||||||
|
)
|
||||||
assert first_drop.name == "Market Coins Bundle 1"
|
assert first_drop.name == "Market Coins Bundle 1"
|
||||||
assert first_drop.required_minutes_watched == 120
|
assert first_drop.required_minutes_watched == 120
|
||||||
assert DropBenefit.objects.count() == 1
|
assert DropBenefit.objects.count() == 1
|
||||||
benefit: DropBenefit = DropBenefit.objects.get(twitch_id="ccb3fb7f-e59b-11ef-aef0-0a58a9feac02")
|
benefit: DropBenefit = DropBenefit.objects.get(
|
||||||
|
twitch_id="ccb3fb7f-e59b-11ef-aef0-0a58a9feac02",
|
||||||
|
)
|
||||||
assert (
|
assert (
|
||||||
benefit.image_asset_url
|
benefit.image_asset_url
|
||||||
== "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg"
|
== "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg"
|
||||||
|
|
@ -645,7 +656,10 @@ class ImporterRobustnessTests(TestCase):
|
||||||
"detailsURL": "https://example.com/details",
|
"detailsURL": "https://example.com/details",
|
||||||
"imageURL": None,
|
"imageURL": None,
|
||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
"self": {"isAccountConnected": False, "__typename": "DropCampaignSelfEdge"},
|
"self": {
|
||||||
|
"isAccountConnected": False,
|
||||||
|
"__typename": "DropCampaignSelfEdge",
|
||||||
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"id": "g-null-image",
|
"id": "g-null-image",
|
||||||
"displayName": "Test Game",
|
"displayName": "Test Game",
|
||||||
|
|
@ -694,12 +708,7 @@ class ErrorOnlyResponseDetectionTests(TestCase):
|
||||||
def test_detects_error_only_response_with_null_data(self) -> None:
|
def test_detects_error_only_response_with_null_data(self) -> None:
|
||||||
"""Ensure error-only response with null data field is detected."""
|
"""Ensure error-only response with null data field is detected."""
|
||||||
parsed_json = {
|
parsed_json = {
|
||||||
"errors": [
|
"errors": [{"message": "internal server error", "path": ["data"]}],
|
||||||
{
|
|
||||||
"message": "internal server error",
|
|
||||||
"path": ["data"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"data": None,
|
"data": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -708,14 +717,7 @@ class ErrorOnlyResponseDetectionTests(TestCase):
|
||||||
|
|
||||||
def test_detects_error_only_response_with_empty_data(self) -> None:
|
def test_detects_error_only_response_with_empty_data(self) -> None:
|
||||||
"""Ensure error-only response with empty data dict is allowed through."""
|
"""Ensure error-only response with empty data dict is allowed through."""
|
||||||
parsed_json = {
|
parsed_json = {"errors": [{"message": "unauthorized"}], "data": {}}
|
||||||
"errors": [
|
|
||||||
{
|
|
||||||
"message": "unauthorized",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"data": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
result = detect_error_only_response(parsed_json)
|
result = detect_error_only_response(parsed_json)
|
||||||
# Empty dict {} is considered "data exists" so this should pass
|
# Empty dict {} is considered "data exists" so this should pass
|
||||||
|
|
@ -723,13 +725,7 @@ class ErrorOnlyResponseDetectionTests(TestCase):
|
||||||
|
|
||||||
def test_detects_error_only_response_without_data_key(self) -> None:
|
def test_detects_error_only_response_without_data_key(self) -> None:
|
||||||
"""Ensure error-only response without data key is detected."""
|
"""Ensure error-only response without data key is detected."""
|
||||||
parsed_json = {
|
parsed_json = {"errors": [{"message": "missing data"}]}
|
||||||
"errors": [
|
|
||||||
{
|
|
||||||
"message": "missing data",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
result = detect_error_only_response(parsed_json)
|
result = detect_error_only_response(parsed_json)
|
||||||
assert result == "error_only: missing data"
|
assert result == "error_only: missing data"
|
||||||
|
|
@ -737,16 +733,8 @@ class ErrorOnlyResponseDetectionTests(TestCase):
|
||||||
def test_allows_response_with_both_errors_and_data(self) -> None:
|
def test_allows_response_with_both_errors_and_data(self) -> None:
|
||||||
"""Ensure responses with both errors and valid data are not flagged."""
|
"""Ensure responses with both errors and valid data are not flagged."""
|
||||||
parsed_json = {
|
parsed_json = {
|
||||||
"errors": [
|
"errors": [{"message": "partial failure"}],
|
||||||
{
|
"data": {"currentUser": {"dropCampaigns": []}},
|
||||||
"message": "partial failure",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"data": {
|
|
||||||
"currentUser": {
|
|
||||||
"dropCampaigns": [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result = detect_error_only_response(parsed_json)
|
result = detect_error_only_response(parsed_json)
|
||||||
|
|
@ -754,28 +742,14 @@ class ErrorOnlyResponseDetectionTests(TestCase):
|
||||||
|
|
||||||
def test_allows_response_with_no_errors(self) -> None:
|
def test_allows_response_with_no_errors(self) -> None:
|
||||||
"""Ensure normal responses without errors are not flagged."""
|
"""Ensure normal responses without errors are not flagged."""
|
||||||
parsed_json = {
|
parsed_json = {"data": {"currentUser": {"dropCampaigns": []}}}
|
||||||
"data": {
|
|
||||||
"currentUser": {
|
|
||||||
"dropCampaigns": [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result = detect_error_only_response(parsed_json)
|
result = detect_error_only_response(parsed_json)
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_detects_error_only_in_list_of_responses(self) -> None:
|
def test_detects_error_only_in_list_of_responses(self) -> None:
|
||||||
"""Ensure error-only detection works with list of responses."""
|
"""Ensure error-only detection works with list of responses."""
|
||||||
parsed_json = [
|
parsed_json = [{"errors": [{"message": "rate limit exceeded"}]}]
|
||||||
{
|
|
||||||
"errors": [
|
|
||||||
{
|
|
||||||
"message": "rate limit exceeded",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
result = detect_error_only_response(parsed_json)
|
result = detect_error_only_response(parsed_json)
|
||||||
assert result == "error_only: rate limit exceeded"
|
assert result == "error_only: rate limit exceeded"
|
||||||
|
|
@ -804,22 +778,14 @@ class ErrorOnlyResponseDetectionTests(TestCase):
|
||||||
|
|
||||||
def test_returns_none_for_empty_errors_list(self) -> None:
|
def test_returns_none_for_empty_errors_list(self) -> None:
|
||||||
"""Ensure empty errors list is not flagged as error-only."""
|
"""Ensure empty errors list is not flagged as error-only."""
|
||||||
parsed_json = {
|
parsed_json = {"errors": []}
|
||||||
"errors": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
result = detect_error_only_response(parsed_json)
|
result = detect_error_only_response(parsed_json)
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_handles_error_without_message_field(self) -> None:
|
def test_handles_error_without_message_field(self) -> None:
|
||||||
"""Ensure errors without message field use default text."""
|
"""Ensure errors without message field use default text."""
|
||||||
parsed_json = {
|
parsed_json = {"errors": [{"path": ["data"]}]}
|
||||||
"errors": [
|
|
||||||
{
|
|
||||||
"path": ["data"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
result = detect_error_only_response(parsed_json)
|
result = detect_error_only_response(parsed_json)
|
||||||
assert result == "error_only: unknown error"
|
assert result == "error_only: unknown error"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Tests for chat badge models and functionality."""
|
"""Tests for chat badge models and functionality."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Test RSS feeds."""
|
"""Test RSS feeds."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
@ -119,7 +117,10 @@ class RSSFeedTestCase(TestCase):
|
||||||
|
|
||||||
def test_organization_campaign_feed(self) -> None:
|
def test_organization_campaign_feed(self) -> None:
|
||||||
"""Test organization-specific campaign feed returns 200."""
|
"""Test organization-specific campaign feed returns 200."""
|
||||||
url: str = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id])
|
url: str = reverse(
|
||||||
|
"twitch:organization_campaign_feed",
|
||||||
|
args=[self.org.twitch_id],
|
||||||
|
)
|
||||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response["Content-Type"] == "application/rss+xml; charset=utf-8"
|
assert response["Content-Type"] == "application/rss+xml; charset=utf-8"
|
||||||
|
|
@ -180,7 +181,10 @@ class RSSFeedTestCase(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get feed for first organization
|
# Get feed for first organization
|
||||||
url: str = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id])
|
url: str = reverse(
|
||||||
|
"twitch:organization_campaign_feed",
|
||||||
|
args=[self.org.twitch_id],
|
||||||
|
)
|
||||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||||
content: str = response.content.decode("utf-8")
|
content: str = response.content.decode("utf-8")
|
||||||
|
|
||||||
|
|
@ -256,7 +260,10 @@ def _build_reward_campaign(game: Game, idx: int) -> RewardCampaign:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_campaign_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
|
def test_campaign_feed_queries_bounded(
|
||||||
|
client: Client,
|
||||||
|
django_assert_num_queries: QueryAsserter,
|
||||||
|
) -> None:
|
||||||
"""Campaign feed should stay within a small, fixed query budget."""
|
"""Campaign feed should stay within a small, fixed query budget."""
|
||||||
org: Organization = Organization.objects.create(
|
org: Organization = Organization.objects.create(
|
||||||
twitch_id="test-org-queries",
|
twitch_id="test-org-queries",
|
||||||
|
|
@ -274,7 +281,7 @@ def test_campaign_feed_queries_bounded(client: Client, django_assert_num_queries
|
||||||
_build_campaign(game, i)
|
_build_campaign(game, i)
|
||||||
|
|
||||||
url: str = reverse("twitch:campaign_feed")
|
url: str = reverse("twitch:campaign_feed")
|
||||||
# TODO(TheLovinator): 14 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: E501, TD003
|
# TODO(TheLovinator): 14 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: TD003
|
||||||
with django_assert_num_queries(14, exact=False):
|
with django_assert_num_queries(14, exact=False):
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||||
|
|
||||||
|
|
@ -339,7 +346,10 @@ def test_campaign_feed_queries_do_not_scale_with_items(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_game_campaign_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
|
def test_game_campaign_feed_queries_bounded(
|
||||||
|
client: Client,
|
||||||
|
django_assert_num_queries: QueryAsserter,
|
||||||
|
) -> None:
|
||||||
"""Game campaign feed should not issue excess queries when rendering multiple campaigns."""
|
"""Game campaign feed should not issue excess queries when rendering multiple campaigns."""
|
||||||
org: Organization = Organization.objects.create(
|
org: Organization = Organization.objects.create(
|
||||||
twitch_id="test-org-game-queries",
|
twitch_id="test-org-game-queries",
|
||||||
|
|
@ -358,7 +368,7 @@ def test_game_campaign_feed_queries_bounded(client: Client, django_assert_num_qu
|
||||||
|
|
||||||
url: str = reverse("twitch:game_campaign_feed", args=[game.twitch_id])
|
url: str = reverse("twitch:game_campaign_feed", args=[game.twitch_id])
|
||||||
|
|
||||||
# TODO(TheLovinator): 15 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: E501, TD003
|
# TODO(TheLovinator): 15 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: TD003
|
||||||
with django_assert_num_queries(6, exact=False):
|
with django_assert_num_queries(6, exact=False):
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||||
|
|
||||||
|
|
@ -395,13 +405,13 @@ def test_game_campaign_feed_queries_do_not_scale_with_items(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_organization_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
|
def test_organization_feed_queries_bounded(
|
||||||
|
client: Client,
|
||||||
|
django_assert_num_queries: QueryAsserter,
|
||||||
|
) -> None:
|
||||||
"""Organization RSS feed should stay within a modest query budget."""
|
"""Organization RSS feed should stay within a modest query budget."""
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
Organization.objects.create(
|
Organization.objects.create(twitch_id=f"org-feed-{i}", name=f"Org Feed {i}")
|
||||||
twitch_id=f"org-feed-{i}",
|
|
||||||
name=f"Org Feed {i}",
|
|
||||||
)
|
|
||||||
|
|
||||||
url: str = reverse("twitch:organization_feed")
|
url: str = reverse("twitch:organization_feed")
|
||||||
with django_assert_num_queries(1, exact=True):
|
with django_assert_num_queries(1, exact=True):
|
||||||
|
|
@ -411,7 +421,10 @@ def test_organization_feed_queries_bounded(client: Client, django_assert_num_que
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_game_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
|
def test_game_feed_queries_bounded(
|
||||||
|
client: Client,
|
||||||
|
django_assert_num_queries: QueryAsserter,
|
||||||
|
) -> None:
|
||||||
"""Game RSS feed should stay within a modest query budget with multiple games."""
|
"""Game RSS feed should stay within a modest query budget with multiple games."""
|
||||||
org: Organization = Organization.objects.create(
|
org: Organization = Organization.objects.create(
|
||||||
twitch_id="game-feed-org",
|
twitch_id="game-feed-org",
|
||||||
|
|
@ -435,7 +448,10 @@ def test_game_feed_queries_bounded(client: Client, django_assert_num_queries: Qu
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_organization_campaign_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
|
def test_organization_campaign_feed_queries_bounded(
|
||||||
|
client: Client,
|
||||||
|
django_assert_num_queries: QueryAsserter,
|
||||||
|
) -> None:
|
||||||
"""Organization campaign feed should not regress in query count."""
|
"""Organization campaign feed should not regress in query count."""
|
||||||
org: Organization = Organization.objects.create(
|
org: Organization = Organization.objects.create(
|
||||||
twitch_id="org-campaign-feed",
|
twitch_id="org-campaign-feed",
|
||||||
|
|
@ -453,7 +469,7 @@ def test_organization_campaign_feed_queries_bounded(client: Client, django_asser
|
||||||
_build_campaign(game, i)
|
_build_campaign(game, i)
|
||||||
|
|
||||||
url: str = reverse("twitch:organization_campaign_feed", args=[org.twitch_id])
|
url: str = reverse("twitch:organization_campaign_feed", args=[org.twitch_id])
|
||||||
# TODO(TheLovinator): 12 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: E501, TD003
|
# TODO(TheLovinator): 12 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: TD003
|
||||||
with django_assert_num_queries(12, exact=False):
|
with django_assert_num_queries(12, exact=False):
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||||
|
|
||||||
|
|
@ -490,7 +506,10 @@ def test_organization_campaign_feed_queries_do_not_scale_with_items(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_reward_campaign_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
|
def test_reward_campaign_feed_queries_bounded(
|
||||||
|
client: Client,
|
||||||
|
django_assert_num_queries: QueryAsserter,
|
||||||
|
) -> None:
|
||||||
"""Reward campaign feed should stay within a modest query budget."""
|
"""Reward campaign feed should stay within a modest query budget."""
|
||||||
org: Organization = Organization.objects.create(
|
org: Organization = Organization.objects.create(
|
||||||
twitch_id="reward-feed-org",
|
twitch_id="reward-feed-org",
|
||||||
|
|
@ -515,7 +534,10 @@ def test_reward_campaign_feed_queries_bounded(client: Client, django_assert_num_
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_docs_rss_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
|
def test_docs_rss_queries_bounded(
|
||||||
|
client: Client,
|
||||||
|
django_assert_num_queries: QueryAsserter,
|
||||||
|
) -> None:
|
||||||
"""Docs RSS page should stay within a reasonable query budget.
|
"""Docs RSS page should stay within a reasonable query budget.
|
||||||
|
|
||||||
With limit=1 for documentation examples, we should have dramatically fewer queries
|
With limit=1 for documentation examples, we should have dramatically fewer queries
|
||||||
|
|
@ -539,7 +561,7 @@ def test_docs_rss_queries_bounded(client: Client, django_assert_num_queries: Que
|
||||||
|
|
||||||
url: str = reverse("twitch:docs_rss")
|
url: str = reverse("twitch:docs_rss")
|
||||||
|
|
||||||
# TODO(TheLovinator): 31 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: E501, TD003
|
# TODO(TheLovinator): 31 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: TD003
|
||||||
with django_assert_num_queries(31, exact=False):
|
with django_assert_num_queries(31, exact=False):
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||||
|
|
||||||
|
|
@ -576,7 +598,11 @@ URL_NAMES: list[tuple[str, dict[str, str]]] = [
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@pytest.mark.parametrize(("url_name", "kwargs"), URL_NAMES)
|
@pytest.mark.parametrize(("url_name", "kwargs"), URL_NAMES)
|
||||||
def test_rss_feeds_return_200(client: Client, url_name: str, kwargs: dict[str, str]) -> None:
|
def test_rss_feeds_return_200(
|
||||||
|
client: Client,
|
||||||
|
url_name: str,
|
||||||
|
kwargs: dict[str, str],
|
||||||
|
) -> None:
|
||||||
"""Test if feeds return HTTP 200.
|
"""Test if feeds return HTTP 200.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -626,9 +652,7 @@ def test_rss_feeds_return_200(client: Client, url_name: str, kwargs: dict[str, s
|
||||||
display_name="TestChannel",
|
display_name="TestChannel",
|
||||||
)
|
)
|
||||||
|
|
||||||
badge_set: ChatBadgeSet = ChatBadgeSet.objects.create(
|
badge_set: ChatBadgeSet = ChatBadgeSet.objects.create(set_id="test-set-123")
|
||||||
set_id="test-set-123",
|
|
||||||
)
|
|
||||||
|
|
||||||
_badge: ChatBadge = ChatBadge.objects.create(
|
_badge: ChatBadge = ChatBadge.objects.create(
|
||||||
badge_set=badge_set,
|
badge_set=badge_set,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
@ -30,7 +28,10 @@ class GameOwnerOrganizationTests(TestCase):
|
||||||
"detailsURL": "https://help.twitch.tv/s/article/twitch-chat-badges-guide",
|
"detailsURL": "https://help.twitch.tv/s/article/twitch-chat-badges-guide",
|
||||||
"imageURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/495ebb6b-8134-4e51-b9d0-1f4a221b4f8d.png",
|
"imageURL": "https://static-cdn.jtvnw.net/twitch-quests-assets/CAMPAIGN/495ebb6b-8134-4e51-b9d0-1f4a221b4f8d.png",
|
||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
"self": {"isAccountConnected": True, "__typename": "DropCampaignSelfEdge"},
|
"self": {
|
||||||
|
"isAccountConnected": True,
|
||||||
|
"__typename": "DropCampaignSelfEdge",
|
||||||
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"id": "263490",
|
"id": "263490",
|
||||||
"slug": "rust",
|
"slug": "rust",
|
||||||
|
|
@ -42,10 +43,18 @@ class GameOwnerOrganizationTests(TestCase):
|
||||||
"__typename": "Organization",
|
"__typename": "Organization",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"owner": {"id": "other-org-id", "name": "Other Org", "__typename": "Organization"},
|
"owner": {
|
||||||
|
"id": "other-org-id",
|
||||||
|
"name": "Other Org",
|
||||||
|
"__typename": "Organization",
|
||||||
|
},
|
||||||
"timeBasedDrops": [],
|
"timeBasedDrops": [],
|
||||||
"eventBasedDrops": [],
|
"eventBasedDrops": [],
|
||||||
"allow": {"channels": None, "isEnabled": False, "__typename": "DropCampaignACL"},
|
"allow": {
|
||||||
|
"channels": None,
|
||||||
|
"isEnabled": False,
|
||||||
|
"__typename": "DropCampaignACL",
|
||||||
|
},
|
||||||
"__typename": "DropCampaign",
|
"__typename": "DropCampaign",
|
||||||
},
|
},
|
||||||
"__typename": "User",
|
"__typename": "User",
|
||||||
|
|
@ -65,7 +74,9 @@ class GameOwnerOrganizationTests(TestCase):
|
||||||
|
|
||||||
# Check game owners include Twitch Gaming and Other Org
|
# Check game owners include Twitch Gaming and Other Org
|
||||||
game: Game = Game.objects.get(twitch_id="263490")
|
game: Game = Game.objects.get(twitch_id="263490")
|
||||||
org1: Organization = Organization.objects.get(twitch_id="d32de13d-937e-4196-8198-1a7f875f295a")
|
org1: Organization = Organization.objects.get(
|
||||||
|
twitch_id="d32de13d-937e-4196-8198-1a7f875f295a",
|
||||||
|
)
|
||||||
org2: Organization = Organization.objects.get(twitch_id="other-org-id")
|
org2: Organization = Organization.objects.get(twitch_id="other-org-id")
|
||||||
owners = list(game.owners.all())
|
owners = list(game.owners.all())
|
||||||
assert org1 in owners
|
assert org1 in owners
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Tests for custom image template tags."""
|
"""Tests for custom image template tags."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.template import Context
|
from django.template import Context
|
||||||
from django.template import Template
|
from django.template import Template
|
||||||
from django.utils.safestring import SafeString
|
from django.utils.safestring import SafeString
|
||||||
|
|
@ -19,11 +17,16 @@ class TestGetFormatUrl:
|
||||||
|
|
||||||
def test_jpg_to_webp(self) -> None:
|
def test_jpg_to_webp(self) -> None:
|
||||||
"""Test converting JPG to WebP."""
|
"""Test converting JPG to WebP."""
|
||||||
assert get_format_url("/static/img/banner.jpg", "webp") == "/static/img/banner.webp"
|
assert (
|
||||||
|
get_format_url("/static/img/banner.jpg", "webp")
|
||||||
|
== "/static/img/banner.webp"
|
||||||
|
)
|
||||||
|
|
||||||
def test_jpeg_to_avif(self) -> None:
|
def test_jpeg_to_avif(self) -> None:
|
||||||
"""Test converting JPEG to AVIF."""
|
"""Test converting JPEG to AVIF."""
|
||||||
assert get_format_url("/static/img/photo.jpeg", "avif") == "/static/img/photo.avif"
|
assert (
|
||||||
|
get_format_url("/static/img/photo.jpeg", "avif") == "/static/img/photo.avif"
|
||||||
|
)
|
||||||
|
|
||||||
def test_png_to_webp(self) -> None:
|
def test_png_to_webp(self) -> None:
|
||||||
"""Test converting PNG to WebP."""
|
"""Test converting PNG to WebP."""
|
||||||
|
|
@ -31,7 +34,9 @@ class TestGetFormatUrl:
|
||||||
|
|
||||||
def test_uppercase_extension(self) -> None:
|
def test_uppercase_extension(self) -> None:
|
||||||
"""Test converting uppercase extensions."""
|
"""Test converting uppercase extensions."""
|
||||||
assert get_format_url("/static/img/photo.JPG", "webp") == "/static/img/photo.webp"
|
assert (
|
||||||
|
get_format_url("/static/img/photo.JPG", "webp") == "/static/img/photo.webp"
|
||||||
|
)
|
||||||
|
|
||||||
def test_non_convertible_format(self) -> None:
|
def test_non_convertible_format(self) -> None:
|
||||||
"""Test that non-convertible formats return unchanged."""
|
"""Test that non-convertible formats return unchanged."""
|
||||||
|
|
@ -187,7 +192,9 @@ class TestPictureTag:
|
||||||
|
|
||||||
def test_twitch_cdn_url_simple_img(self) -> None:
|
def test_twitch_cdn_url_simple_img(self) -> None:
|
||||||
"""Test that Twitch CDN URLs return simple img tag without picture element."""
|
"""Test that Twitch CDN URLs return simple img tag without picture element."""
|
||||||
result: SafeString = picture("https://static-cdn.jtvnw.net/ttv-boxart/1292861145.jpg")
|
result: SafeString = picture(
|
||||||
|
"https://static-cdn.jtvnw.net/ttv-boxart/1292861145.jpg",
|
||||||
|
)
|
||||||
|
|
||||||
# Should NOT have picture element
|
# Should NOT have picture element
|
||||||
assert "<picture>" not in result
|
assert "<picture>" not in result
|
||||||
|
|
@ -228,7 +235,9 @@ class TestPictureTag:
|
||||||
|
|
||||||
def test_twitch_cdn_url_with_png(self) -> None:
|
def test_twitch_cdn_url_with_png(self) -> None:
|
||||||
"""Test Twitch CDN URL with PNG format."""
|
"""Test Twitch CDN URL with PNG format."""
|
||||||
result: SafeString = picture("https://static-cdn.jtvnw.net/badges/v1/1234567.png")
|
result: SafeString = picture(
|
||||||
|
"https://static-cdn.jtvnw.net/badges/v1/1234567.png",
|
||||||
|
)
|
||||||
|
|
||||||
# Should NOT have picture element or source tags
|
# Should NOT have picture element or source tags
|
||||||
assert "<picture>" not in result
|
assert "<picture>" not in result
|
||||||
|
|
@ -244,7 +253,9 @@ class TestPictureTagTemplate:
|
||||||
|
|
||||||
def test_picture_tag_in_template(self) -> None:
|
def test_picture_tag_in_template(self) -> None:
|
||||||
"""Test that the picture tag works when called from a template."""
|
"""Test that the picture tag works when called from a template."""
|
||||||
template = Template('{% load image_tags %}{% picture src="/img/photo.jpg" alt="Test" %}')
|
template = Template(
|
||||||
|
'{% load image_tags %}{% picture src="/img/photo.jpg" alt="Test" %}',
|
||||||
|
)
|
||||||
context = Context({})
|
context = Context({})
|
||||||
result: SafeString = template.render(context)
|
result: SafeString = template.render(context)
|
||||||
|
|
||||||
|
|
@ -257,7 +268,9 @@ class TestPictureTagTemplate:
|
||||||
|
|
||||||
def test_picture_tag_with_context_variables(self) -> None:
|
def test_picture_tag_with_context_variables(self) -> None:
|
||||||
"""Test using context variables in the picture tag."""
|
"""Test using context variables in the picture tag."""
|
||||||
template = Template("{% load image_tags %}{% picture src=image_url alt=image_alt width=image_width %}")
|
template = Template(
|
||||||
|
"{% load image_tags %}{% picture src=image_url alt=image_alt width=image_width %}",
|
||||||
|
)
|
||||||
context = Context({
|
context = Context({
|
||||||
"image_url": "/img/banner.png",
|
"image_url": "/img/banner.png",
|
||||||
"image_alt": "Banner image",
|
"image_alt": "Banner image",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
"""Tests for Pydantic schemas used in the import process."""
|
"""Tests for Pydantic schemas used in the import process."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from twitch.schemas import DropBenefitSchema
|
|
||||||
from twitch.schemas import DropCampaignSchema
|
|
||||||
from twitch.schemas import GameSchema
|
from twitch.schemas import GameSchema
|
||||||
from twitch.schemas import GraphQLResponse
|
from twitch.schemas import GraphQLResponse
|
||||||
from twitch.schemas import TimeBasedDropSchema
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from twitch.schemas import DropBenefitSchema
|
||||||
|
from twitch.schemas import DropCampaignSchema
|
||||||
|
from twitch.schemas import TimeBasedDropSchema
|
||||||
|
|
||||||
|
|
||||||
def test_inventory_operation_validation() -> None:
|
def test_inventory_operation_validation() -> None:
|
||||||
|
|
@ -88,9 +90,7 @@ def test_inventory_operation_validation() -> None:
|
||||||
"__typename": "User",
|
"__typename": "User",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {"operationName": "Inventory"},
|
||||||
"operationName": "Inventory",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# This should not raise ValidationError
|
# This should not raise ValidationError
|
||||||
|
|
@ -121,16 +121,16 @@ def test_inventory_operation_validation() -> None:
|
||||||
|
|
||||||
def test_game_schema_normalizes_twitch_box_art_url() -> None:
|
def test_game_schema_normalizes_twitch_box_art_url() -> None:
|
||||||
"""Ensure Twitch box art URLs are normalized for higher quality."""
|
"""Ensure Twitch box art URLs are normalized for higher quality."""
|
||||||
schema: GameSchema = GameSchema.model_validate(
|
schema: GameSchema = GameSchema.model_validate({
|
||||||
{
|
"id": "65654",
|
||||||
"id": "65654",
|
"displayName": "Test Game",
|
||||||
"displayName": "Test Game",
|
"boxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/65654_IGDB-120x160.jpg",
|
||||||
"boxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/65654_IGDB-120x160.jpg",
|
"__typename": "Game",
|
||||||
"__typename": "Game",
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert schema.box_art_url == "https://static-cdn.jtvnw.net/ttv-boxart/65654_IGDB.jpg"
|
assert (
|
||||||
|
schema.box_art_url == "https://static-cdn.jtvnw.net/ttv-boxart/65654_IGDB.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_viewer_drops_dashboard_operation_still_works() -> None:
|
def test_viewer_drops_dashboard_operation_still_works() -> None:
|
||||||
|
|
@ -175,9 +175,7 @@ def test_viewer_drops_dashboard_operation_still_works() -> None:
|
||||||
"__typename": "User",
|
"__typename": "User",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {"operationName": "ViewerDropsDashboard"},
|
||||||
"operationName": "ViewerDropsDashboard",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# This should not raise ValidationError
|
# This should not raise ValidationError
|
||||||
|
|
@ -201,11 +199,25 @@ def test_graphql_response_with_errors() -> None:
|
||||||
"errors": [
|
"errors": [
|
||||||
{
|
{
|
||||||
"message": "service timeout",
|
"message": "service timeout",
|
||||||
"path": ["currentUser", "inventory", "dropCampaignsInProgress", 7, "allow", "channels"],
|
"path": [
|
||||||
|
"currentUser",
|
||||||
|
"inventory",
|
||||||
|
"dropCampaignsInProgress",
|
||||||
|
7,
|
||||||
|
"allow",
|
||||||
|
"channels",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"message": "service timeout",
|
"message": "service timeout",
|
||||||
"path": ["currentUser", "inventory", "dropCampaignsInProgress", 10, "allow", "channels"],
|
"path": [
|
||||||
|
"currentUser",
|
||||||
|
"inventory",
|
||||||
|
"dropCampaignsInProgress",
|
||||||
|
10,
|
||||||
|
"allow",
|
||||||
|
"channels",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"data": {
|
"data": {
|
||||||
|
|
@ -244,9 +256,7 @@ def test_graphql_response_with_errors() -> None:
|
||||||
"__typename": "User",
|
"__typename": "User",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {"operationName": "Inventory"},
|
||||||
"operationName": "Inventory",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# This should not raise ValidationError even with errors field present
|
# This should not raise ValidationError even with errors field present
|
||||||
|
|
@ -256,7 +266,14 @@ def test_graphql_response_with_errors() -> None:
|
||||||
assert response.errors is not None
|
assert response.errors is not None
|
||||||
assert len(response.errors) == 2
|
assert len(response.errors) == 2
|
||||||
assert response.errors[0].message == "service timeout"
|
assert response.errors[0].message == "service timeout"
|
||||||
assert response.errors[0].path == ["currentUser", "inventory", "dropCampaignsInProgress", 7, "allow", "channels"]
|
assert response.errors[0].path == [
|
||||||
|
"currentUser",
|
||||||
|
"inventory",
|
||||||
|
"dropCampaignsInProgress",
|
||||||
|
7,
|
||||||
|
"allow",
|
||||||
|
"channels",
|
||||||
|
]
|
||||||
|
|
||||||
# Verify the data is still accessible and valid
|
# Verify the data is still accessible and valid
|
||||||
assert response.data.current_user is not None
|
assert response.data.current_user is not None
|
||||||
|
|
@ -323,7 +340,7 @@ def test_drop_campaign_details_missing_distribution_type() -> None:
|
||||||
"benefitEdges": [
|
"benefitEdges": [
|
||||||
{
|
{
|
||||||
"benefit": {
|
"benefit": {
|
||||||
"id": "6948a129-2c6d-4d88-9444-6b96918a19f8_CUSTOM_ID_WOWS_TwitchDrops_1307_250ct", # noqa: E501
|
"id": "6948a129-2c6d-4d88-9444-6b96918a19f8_CUSTOM_ID_WOWS_TwitchDrops_1307_250ct",
|
||||||
"createdAt": "2024-08-06T16:03:15.89Z",
|
"createdAt": "2024-08-06T16:03:15.89Z",
|
||||||
"entitlementLimit": 1,
|
"entitlementLimit": 1,
|
||||||
"game": {
|
"game": {
|
||||||
|
|
@ -390,7 +407,9 @@ def test_drop_campaign_details_missing_distribution_type() -> None:
|
||||||
assert len(first_drop.benefit_edges) == 1
|
assert len(first_drop.benefit_edges) == 1
|
||||||
benefit: DropBenefitSchema = first_drop.benefit_edges[0].benefit
|
benefit: DropBenefitSchema = first_drop.benefit_edges[0].benefit
|
||||||
assert benefit.name == "13.7 Update: 250 CT"
|
assert benefit.name == "13.7 Update: 250 CT"
|
||||||
assert benefit.distribution_type is None # This field was missing in the API response
|
assert (
|
||||||
|
benefit.distribution_type is None
|
||||||
|
) # This field was missing in the API response
|
||||||
|
|
||||||
|
|
||||||
def test_reward_campaigns_available_to_user() -> None:
|
def test_reward_campaigns_available_to_user() -> None:
|
||||||
|
|
@ -454,9 +473,7 @@ def test_reward_campaigns_available_to_user() -> None:
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {"operationName": "ViewerDropsDashboard"},
|
||||||
"operationName": "ViewerDropsDashboard",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# This should not raise ValidationError
|
# This should not raise ValidationError
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
@ -22,7 +20,6 @@ from twitch.models import DropCampaign
|
||||||
from twitch.models import Game
|
from twitch.models import Game
|
||||||
from twitch.models import Organization
|
from twitch.models import Organization
|
||||||
from twitch.models import TimeBasedDrop
|
from twitch.models import TimeBasedDrop
|
||||||
from twitch.views import Page
|
|
||||||
from twitch.views import _build_breadcrumb_schema
|
from twitch.views import _build_breadcrumb_schema
|
||||||
from twitch.views import _build_pagination_info
|
from twitch.views import _build_pagination_info
|
||||||
from twitch.views import _build_seo_context
|
from twitch.views import _build_seo_context
|
||||||
|
|
@ -34,19 +31,26 @@ if TYPE_CHECKING:
|
||||||
from django.test.client import _MonkeyPatchedWSGIResponse
|
from django.test.client import _MonkeyPatchedWSGIResponse
|
||||||
from django.test.utils import ContextList
|
from django.test.utils import ContextList
|
||||||
|
|
||||||
|
from twitch.views import Page
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestSearchView:
|
class TestSearchView:
|
||||||
"""Tests for the search_view function."""
|
"""Tests for the search_view function."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_data(self) -> dict[str, Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit]:
|
def sample_data(
|
||||||
|
self,
|
||||||
|
) -> dict[str, Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit]:
|
||||||
"""Create sample data for testing.
|
"""Create sample data for testing.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A dictionary containing the created sample data.
|
A dictionary containing the created sample data.
|
||||||
"""
|
"""
|
||||||
org: Organization = Organization.objects.create(twitch_id="123", name="Test Organization")
|
org: Organization = Organization.objects.create(
|
||||||
|
twitch_id="123",
|
||||||
|
name="Test Organization",
|
||||||
|
)
|
||||||
game: Game = Game.objects.create(
|
game: Game = Game.objects.create(
|
||||||
twitch_id="456",
|
twitch_id="456",
|
||||||
name="test_game",
|
name="test_game",
|
||||||
|
|
@ -78,7 +82,9 @@ class TestSearchView:
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_context(response: _MonkeyPatchedWSGIResponse) -> ContextList | dict[str, Any]:
|
def _get_context(
|
||||||
|
response: _MonkeyPatchedWSGIResponse,
|
||||||
|
) -> ContextList | dict[str, Any]:
|
||||||
"""Normalize Django test response context to a plain dict.
|
"""Normalize Django test response context to a plain dict.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -95,7 +101,10 @@ class TestSearchView:
|
||||||
def test_empty_query(
|
def test_empty_query(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
sample_data: dict[str, Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit],
|
sample_data: dict[
|
||||||
|
str,
|
||||||
|
Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit,
|
||||||
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test search with empty query returns no results."""
|
"""Test search with empty query returns no results."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/search/?q=")
|
response: _MonkeyPatchedWSGIResponse = client.get("/search/?q=")
|
||||||
|
|
@ -108,7 +117,10 @@ class TestSearchView:
|
||||||
def test_no_query_parameter(
|
def test_no_query_parameter(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
sample_data: dict[str, Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit],
|
sample_data: dict[
|
||||||
|
str,
|
||||||
|
Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit,
|
||||||
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test search with no query parameter returns no results."""
|
"""Test search with no query parameter returns no results."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/search/")
|
response: _MonkeyPatchedWSGIResponse = client.get("/search/")
|
||||||
|
|
@ -124,7 +136,10 @@ class TestSearchView:
|
||||||
def test_short_query_istartswith(
|
def test_short_query_istartswith(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
sample_data: dict[str, Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit],
|
sample_data: dict[
|
||||||
|
str,
|
||||||
|
Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit,
|
||||||
|
],
|
||||||
model_key: Literal["org", "game", "campaign", "drop", "benefit"],
|
model_key: Literal["org", "game", "campaign", "drop", "benefit"],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test short query (< 3 chars) uses istartswith for all models."""
|
"""Test short query (< 3 chars) uses istartswith for all models."""
|
||||||
|
|
@ -151,7 +166,10 @@ class TestSearchView:
|
||||||
def test_long_query_icontains(
|
def test_long_query_icontains(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
sample_data: dict[str, Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit],
|
sample_data: dict[
|
||||||
|
str,
|
||||||
|
Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit,
|
||||||
|
],
|
||||||
model_key: Literal["org", "game", "campaign", "drop", "benefit"],
|
model_key: Literal["org", "game", "campaign", "drop", "benefit"],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test long query (>= 3 chars) uses icontains for all models."""
|
"""Test long query (>= 3 chars) uses icontains for all models."""
|
||||||
|
|
@ -174,7 +192,10 @@ class TestSearchView:
|
||||||
def test_campaign_description_search(
|
def test_campaign_description_search(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
sample_data: dict[str, Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit],
|
sample_data: dict[
|
||||||
|
str,
|
||||||
|
Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit,
|
||||||
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that campaign description is searchable."""
|
"""Test that campaign description is searchable."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/search/?q=campaign")
|
response: _MonkeyPatchedWSGIResponse = client.get("/search/?q=campaign")
|
||||||
|
|
@ -186,7 +207,10 @@ class TestSearchView:
|
||||||
def test_game_display_name_search(
|
def test_game_display_name_search(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
sample_data: dict[str, Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit],
|
sample_data: dict[
|
||||||
|
str,
|
||||||
|
Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit,
|
||||||
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that game display_name is searchable."""
|
"""Test that game display_name is searchable."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/search/?q=Game")
|
response: _MonkeyPatchedWSGIResponse = client.get("/search/?q=Game")
|
||||||
|
|
@ -198,7 +222,10 @@ class TestSearchView:
|
||||||
def test_query_no_matches(
|
def test_query_no_matches(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
sample_data: dict[str, Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit],
|
sample_data: dict[
|
||||||
|
str,
|
||||||
|
Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit,
|
||||||
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test search with query that has no matches."""
|
"""Test search with query that has no matches."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/search/?q=xyz")
|
response: _MonkeyPatchedWSGIResponse = client.get("/search/?q=xyz")
|
||||||
|
|
@ -211,7 +238,10 @@ class TestSearchView:
|
||||||
def test_context_contains_query(
|
def test_context_contains_query(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
sample_data: dict[str, Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit],
|
sample_data: dict[
|
||||||
|
str,
|
||||||
|
Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit,
|
||||||
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that context contains the search query."""
|
"""Test that context contains the search query."""
|
||||||
query = "Test"
|
query = "Test"
|
||||||
|
|
@ -222,15 +252,15 @@ class TestSearchView:
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("model_key", "related_field"),
|
("model_key", "related_field"),
|
||||||
[
|
[("campaigns", "game"), ("drops", "campaign")],
|
||||||
("campaigns", "game"),
|
|
||||||
("drops", "campaign"),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
def test_select_related_optimization(
|
def test_select_related_optimization(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
sample_data: dict[str, Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit],
|
sample_data: dict[
|
||||||
|
str,
|
||||||
|
Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit,
|
||||||
|
],
|
||||||
model_key: str,
|
model_key: str,
|
||||||
related_field: str,
|
related_field: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -238,11 +268,15 @@ class TestSearchView:
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/search/?q=Test")
|
response: _MonkeyPatchedWSGIResponse = client.get("/search/?q=Test")
|
||||||
context: ContextList | dict[str, Any] = self._get_context(response)
|
context: ContextList | dict[str, Any] = self._get_context(response)
|
||||||
|
|
||||||
results: list[Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit] = context["results"][model_key]
|
results: list[
|
||||||
|
Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit
|
||||||
|
] = context["results"][model_key]
|
||||||
assert len(results) > 0
|
assert len(results) > 0
|
||||||
|
|
||||||
# Verify the related object is accessible without additional query
|
# Verify the related object is accessible without additional query
|
||||||
first_result: Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit = results[0]
|
first_result: (
|
||||||
|
Organization | Game | DropCampaign | TimeBasedDrop | DropBenefit
|
||||||
|
) = results[0]
|
||||||
assert hasattr(first_result, related_field)
|
assert hasattr(first_result, related_field)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -251,13 +285,18 @@ class TestChannelListView:
|
||||||
"""Tests for the ChannelListView."""
|
"""Tests for the ChannelListView."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def channel_with_campaigns(self) -> dict[str, Channel | Game | Organization | list[DropCampaign]]:
|
def channel_with_campaigns(
|
||||||
|
self,
|
||||||
|
) -> dict[str, Channel | Game | Organization | list[DropCampaign]]:
|
||||||
"""Create a channel with multiple campaigns for testing.
|
"""Create a channel with multiple campaigns for testing.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A dictionary containing the created channel and campaigns.
|
A dictionary containing the created channel and campaigns.
|
||||||
"""
|
"""
|
||||||
org: Organization = Organization.objects.create(twitch_id="org1", name="Test Org")
|
org: Organization = Organization.objects.create(
|
||||||
|
twitch_id="org1",
|
||||||
|
name="Test Org",
|
||||||
|
)
|
||||||
game: Game = Game.objects.create(
|
game: Game = Game.objects.create(
|
||||||
twitch_id="game1",
|
twitch_id="game1",
|
||||||
name="test_game",
|
name="test_game",
|
||||||
|
|
@ -284,12 +323,7 @@ class TestChannelListView:
|
||||||
campaign.allow_channels.add(channel)
|
campaign.allow_channels.add(channel)
|
||||||
campaigns.append(campaign)
|
campaigns.append(campaign)
|
||||||
|
|
||||||
return {
|
return {"channel": channel, "campaigns": campaigns, "game": game, "org": org}
|
||||||
"channel": channel,
|
|
||||||
"campaigns": campaigns,
|
|
||||||
"game": game,
|
|
||||||
"org": org,
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_channel_list_loads(self, client: Client) -> None:
|
def test_channel_list_loads(self, client: Client) -> None:
|
||||||
"""Test that channel list view loads successfully."""
|
"""Test that channel list view loads successfully."""
|
||||||
|
|
@ -299,7 +333,10 @@ class TestChannelListView:
|
||||||
def test_campaign_count_annotation(
|
def test_campaign_count_annotation(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
channel_with_campaigns: dict[str, Channel | Game | Organization | list[DropCampaign]],
|
channel_with_campaigns: dict[
|
||||||
|
str,
|
||||||
|
Channel | Game | Organization | list[DropCampaign],
|
||||||
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that campaign_count is correctly annotated for channels."""
|
"""Test that campaign_count is correctly annotated for channels."""
|
||||||
channel: Channel = channel_with_campaigns["channel"] # type: ignore[assignment]
|
channel: Channel = channel_with_campaigns["channel"] # type: ignore[assignment]
|
||||||
|
|
@ -313,13 +350,18 @@ class TestChannelListView:
|
||||||
channels: list[Channel] = context["channels"]
|
channels: list[Channel] = context["channels"]
|
||||||
|
|
||||||
# Find our test channel in the results
|
# Find our test channel in the results
|
||||||
test_channel: Channel | None = next((ch for ch in channels if ch.twitch_id == channel.twitch_id), None)
|
test_channel: Channel | None = next(
|
||||||
|
(ch for ch in channels if ch.twitch_id == channel.twitch_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
assert test_channel is not None
|
assert test_channel is not None
|
||||||
assert hasattr(test_channel, "campaign_count")
|
assert hasattr(test_channel, "campaign_count")
|
||||||
|
|
||||||
campaign_count: int | None = getattr(test_channel, "campaign_count", None)
|
campaign_count: int | None = getattr(test_channel, "campaign_count", None)
|
||||||
assert campaign_count == len(campaigns), f"Expected campaign_count to be {len(campaigns)}, got {campaign_count}"
|
assert campaign_count == len(campaigns), (
|
||||||
|
f"Expected campaign_count to be {len(campaigns)}, got {campaign_count}"
|
||||||
|
)
|
||||||
|
|
||||||
def test_campaign_count_zero_for_channel_without_campaigns(
|
def test_campaign_count_zero_for_channel_without_campaigns(
|
||||||
self,
|
self,
|
||||||
|
|
@ -339,7 +381,10 @@ class TestChannelListView:
|
||||||
context = context[-1]
|
context = context[-1]
|
||||||
|
|
||||||
channels: list[Channel] = context["channels"]
|
channels: list[Channel] = context["channels"]
|
||||||
test_channel: Channel | None = next((ch for ch in channels if ch.twitch_id == channel.twitch_id), None)
|
test_channel: Channel | None = next(
|
||||||
|
(ch for ch in channels if ch.twitch_id == channel.twitch_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
assert test_channel is not None
|
assert test_channel is not None
|
||||||
assert hasattr(test_channel, "campaign_count")
|
assert hasattr(test_channel, "campaign_count")
|
||||||
|
|
@ -350,7 +395,10 @@ class TestChannelListView:
|
||||||
def test_channels_ordered_by_campaign_count(
|
def test_channels_ordered_by_campaign_count(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
channel_with_campaigns: dict[str, Channel | Game | Organization | list[DropCampaign]],
|
channel_with_campaigns: dict[
|
||||||
|
str,
|
||||||
|
Channel | Game | Organization | list[DropCampaign],
|
||||||
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that channels are ordered by campaign_count descending."""
|
"""Test that channels are ordered by campaign_count descending."""
|
||||||
game: Game = channel_with_campaigns["game"] # type: ignore[assignment]
|
game: Game = channel_with_campaigns["game"] # type: ignore[assignment]
|
||||||
|
|
@ -380,17 +428,28 @@ class TestChannelListView:
|
||||||
channels: list[Channel] = list(context["channels"])
|
channels: list[Channel] = list(context["channels"])
|
||||||
|
|
||||||
# The channel with 10 campaigns should come before the one with 5
|
# The channel with 10 campaigns should come before the one with 5
|
||||||
channel2_index: int | None = next((i for i, ch in enumerate(channels) if ch.twitch_id == "channel2"), None)
|
channel2_index: int | None = next(
|
||||||
channel1_index: int | None = next((i for i, ch in enumerate(channels) if ch.twitch_id == "channel1"), None)
|
(i for i, ch in enumerate(channels) if ch.twitch_id == "channel2"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
channel1_index: int | None = next(
|
||||||
|
(i for i, ch in enumerate(channels) if ch.twitch_id == "channel1"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
assert channel2_index is not None
|
assert channel2_index is not None
|
||||||
assert channel1_index is not None
|
assert channel1_index is not None
|
||||||
assert channel2_index < channel1_index, "Channel with more campaigns should appear first"
|
assert channel2_index < channel1_index, (
|
||||||
|
"Channel with more campaigns should appear first"
|
||||||
|
)
|
||||||
|
|
||||||
def test_channel_search_filters_correctly(
|
def test_channel_search_filters_correctly(
|
||||||
self,
|
self,
|
||||||
client: Client,
|
client: Client,
|
||||||
channel_with_campaigns: dict[str, Channel | Game | Organization | list[DropCampaign]],
|
channel_with_campaigns: dict[
|
||||||
|
str,
|
||||||
|
Channel | Game | Organization | list[DropCampaign],
|
||||||
|
],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that search parameter filters channels correctly."""
|
"""Test that search parameter filters channels correctly."""
|
||||||
channel: Channel = channel_with_campaigns["channel"] # type: ignore[assignment]
|
channel: Channel = channel_with_campaigns["channel"] # type: ignore[assignment]
|
||||||
|
|
@ -402,7 +461,9 @@ class TestChannelListView:
|
||||||
display_name="OtherChannel",
|
display_name="OtherChannel",
|
||||||
)
|
)
|
||||||
|
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(f"/channels/?search={channel.name}")
|
response: _MonkeyPatchedWSGIResponse = client.get(
|
||||||
|
f"/channels/?search={channel.name}",
|
||||||
|
)
|
||||||
context: ContextList | dict[str, Any] = response.context # type: ignore[assignment]
|
context: ContextList | dict[str, Any] = response.context # type: ignore[assignment]
|
||||||
if isinstance(context, list):
|
if isinstance(context, list):
|
||||||
context = context[-1]
|
context = context[-1]
|
||||||
|
|
@ -421,12 +482,25 @@ class TestChannelListView:
|
||||||
assert "active_campaigns" in response.context
|
assert "active_campaigns" in response.context
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_dashboard_dedupes_campaigns_for_multi_owner_game(self, client: Client) -> None:
|
def test_dashboard_dedupes_campaigns_for_multi_owner_game(
|
||||||
|
self,
|
||||||
|
client: Client,
|
||||||
|
) -> None:
|
||||||
"""Dashboard should not render duplicate campaign cards when a game has multiple owners."""
|
"""Dashboard should not render duplicate campaign cards when a game has multiple owners."""
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
org1: Organization = Organization.objects.create(twitch_id="org_a", name="Org A")
|
org1: Organization = Organization.objects.create(
|
||||||
org2: Organization = Organization.objects.create(twitch_id="org_b", name="Org B")
|
twitch_id="org_a",
|
||||||
game: Game = Game.objects.create(twitch_id="game_multi_owner", name="game", display_name="Multi Owner")
|
name="Org A",
|
||||||
|
)
|
||||||
|
org2: Organization = Organization.objects.create(
|
||||||
|
twitch_id="org_b",
|
||||||
|
name="Org B",
|
||||||
|
)
|
||||||
|
game: Game = Game.objects.create(
|
||||||
|
twitch_id="game_multi_owner",
|
||||||
|
name="game",
|
||||||
|
display_name="Multi Owner",
|
||||||
|
)
|
||||||
game.owners.add(org1, org2)
|
game.owners.add(org1, org2)
|
||||||
|
|
||||||
campaign: DropCampaign = DropCampaign.objects.create(
|
campaign: DropCampaign = DropCampaign.objects.create(
|
||||||
|
|
@ -463,14 +537,20 @@ class TestChannelListView:
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_drop_campaign_list_view(self, client: Client) -> None:
|
def test_drop_campaign_list_view(self, client: Client) -> None:
|
||||||
"""Test campaign list view returns 200 and has campaigns in context."""
|
"""Test campaign list view returns 200 and has campaigns in context."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:campaign_list"))
|
response: _MonkeyPatchedWSGIResponse = client.get(
|
||||||
|
reverse("twitch:campaign_list"),
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "campaigns" in response.context
|
assert "campaigns" in response.context
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_drop_campaign_list_pagination(self, client: Client) -> None:
|
def test_drop_campaign_list_pagination(self, client: Client) -> None:
|
||||||
"""Test pagination works correctly with 100 items per page."""
|
"""Test pagination works correctly with 100 items per page."""
|
||||||
game: Game = Game.objects.create(twitch_id="g1", name="Game", display_name="Game")
|
game: Game = Game.objects.create(
|
||||||
|
twitch_id="g1",
|
||||||
|
name="Game",
|
||||||
|
display_name="Game",
|
||||||
|
)
|
||||||
now: datetime.datetime = timezone.now()
|
now: datetime.datetime = timezone.now()
|
||||||
|
|
||||||
# Create 150 campaigns to test pagination
|
# Create 150 campaigns to test pagination
|
||||||
|
|
@ -488,7 +568,9 @@ class TestChannelListView:
|
||||||
DropCampaign.objects.bulk_create(campaigns)
|
DropCampaign.objects.bulk_create(campaigns)
|
||||||
|
|
||||||
# Test first page
|
# Test first page
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:campaign_list"))
|
response: _MonkeyPatchedWSGIResponse = client.get(
|
||||||
|
reverse("twitch:campaign_list"),
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "is_paginated" in response.context
|
assert "is_paginated" in response.context
|
||||||
assert response.context["is_paginated"] is True
|
assert response.context["is_paginated"] is True
|
||||||
|
|
@ -508,7 +590,11 @@ class TestChannelListView:
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_drop_campaign_list_status_filter_active(self, client: Client) -> None:
|
def test_drop_campaign_list_status_filter_active(self, client: Client) -> None:
|
||||||
"""Test filtering for active campaigns only."""
|
"""Test filtering for active campaigns only."""
|
||||||
game: Game = Game.objects.create(twitch_id="g1", name="Game", display_name="Game")
|
game: Game = Game.objects.create(
|
||||||
|
twitch_id="g1",
|
||||||
|
name="Game",
|
||||||
|
display_name="Game",
|
||||||
|
)
|
||||||
now: datetime.datetime = timezone.now()
|
now: datetime.datetime = timezone.now()
|
||||||
|
|
||||||
# Create active campaign
|
# Create active campaign
|
||||||
|
|
@ -553,7 +639,11 @@ class TestChannelListView:
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_drop_campaign_list_status_filter_upcoming(self, client: Client) -> None:
|
def test_drop_campaign_list_status_filter_upcoming(self, client: Client) -> None:
|
||||||
"""Test filtering for upcoming campaigns only."""
|
"""Test filtering for upcoming campaigns only."""
|
||||||
game: Game = Game.objects.create(twitch_id="g1", name="Game", display_name="Game")
|
game: Game = Game.objects.create(
|
||||||
|
twitch_id="g1",
|
||||||
|
name="Game",
|
||||||
|
display_name="Game",
|
||||||
|
)
|
||||||
now: datetime.datetime = timezone.now()
|
now: datetime.datetime = timezone.now()
|
||||||
|
|
||||||
# Create active campaign
|
# Create active campaign
|
||||||
|
|
@ -598,7 +688,11 @@ class TestChannelListView:
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_drop_campaign_list_status_filter_expired(self, client: Client) -> None:
|
def test_drop_campaign_list_status_filter_expired(self, client: Client) -> None:
|
||||||
"""Test filtering for expired campaigns only."""
|
"""Test filtering for expired campaigns only."""
|
||||||
game: Game = Game.objects.create(twitch_id="g1", name="Game", display_name="Game")
|
game: Game = Game.objects.create(
|
||||||
|
twitch_id="g1",
|
||||||
|
name="Game",
|
||||||
|
display_name="Game",
|
||||||
|
)
|
||||||
now: datetime.datetime = timezone.now()
|
now: datetime.datetime = timezone.now()
|
||||||
|
|
||||||
# Create active campaign
|
# Create active campaign
|
||||||
|
|
@ -643,8 +737,16 @@ class TestChannelListView:
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_drop_campaign_list_game_filter(self, client: Client) -> None:
|
def test_drop_campaign_list_game_filter(self, client: Client) -> None:
|
||||||
"""Test filtering campaigns by game."""
|
"""Test filtering campaigns by game."""
|
||||||
game1: Game = Game.objects.create(twitch_id="g1", name="Game 1", display_name="Game 1")
|
game1: Game = Game.objects.create(
|
||||||
game2: Game = Game.objects.create(twitch_id="g2", name="Game 2", display_name="Game 2")
|
twitch_id="g1",
|
||||||
|
name="Game 1",
|
||||||
|
display_name="Game 1",
|
||||||
|
)
|
||||||
|
game2: Game = Game.objects.create(
|
||||||
|
twitch_id="g2",
|
||||||
|
name="Game 2",
|
||||||
|
display_name="Game 2",
|
||||||
|
)
|
||||||
now: datetime.datetime = timezone.now()
|
now: datetime.datetime = timezone.now()
|
||||||
|
|
||||||
# Create campaigns for game 1
|
# Create campaigns for game 1
|
||||||
|
|
@ -692,9 +794,16 @@ class TestChannelListView:
|
||||||
assert campaigns[0].game.twitch_id == "g2"
|
assert campaigns[0].game.twitch_id == "g2"
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_drop_campaign_list_pagination_preserves_filters(self, client: Client) -> None:
|
def test_drop_campaign_list_pagination_preserves_filters(
|
||||||
|
self,
|
||||||
|
client: Client,
|
||||||
|
) -> None:
|
||||||
"""Test that pagination links preserve game and status filters."""
|
"""Test that pagination links preserve game and status filters."""
|
||||||
game: Game = Game.objects.create(twitch_id="g1", name="Game", display_name="Game")
|
game: Game = Game.objects.create(
|
||||||
|
twitch_id="g1",
|
||||||
|
name="Game",
|
||||||
|
display_name="Game",
|
||||||
|
)
|
||||||
now: datetime.datetime = timezone.now()
|
now: datetime.datetime = timezone.now()
|
||||||
|
|
||||||
# Create 150 active campaigns for game g1
|
# Create 150 active campaigns for game g1
|
||||||
|
|
@ -726,7 +835,11 @@ class TestChannelListView:
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_drop_campaign_detail_view(self, client: Client, db: object) -> None:
|
def test_drop_campaign_detail_view(self, client: Client, db: object) -> None:
|
||||||
"""Test campaign detail view returns 200 and has campaign in context."""
|
"""Test campaign detail view returns 200 and has campaign in context."""
|
||||||
game: Game = Game.objects.create(twitch_id="g1", name="Game", display_name="Game")
|
game: Game = Game.objects.create(
|
||||||
|
twitch_id="g1",
|
||||||
|
name="Game",
|
||||||
|
display_name="Game",
|
||||||
|
)
|
||||||
campaign: DropCampaign = DropCampaign.objects.create(
|
campaign: DropCampaign = DropCampaign.objects.create(
|
||||||
twitch_id="c1",
|
twitch_id="c1",
|
||||||
name="Campaign",
|
name="Campaign",
|
||||||
|
|
@ -744,7 +857,11 @@ class TestChannelListView:
|
||||||
client: Client,
|
client: Client,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test campaign detail view includes badge benefit description from ChatBadge."""
|
"""Test campaign detail view includes badge benefit description from ChatBadge."""
|
||||||
game: Game = Game.objects.create(twitch_id="g-badge", name="Game", display_name="Game")
|
game: Game = Game.objects.create(
|
||||||
|
twitch_id="g-badge",
|
||||||
|
name="Game",
|
||||||
|
display_name="Game",
|
||||||
|
)
|
||||||
campaign: DropCampaign = DropCampaign.objects.create(
|
campaign: DropCampaign = DropCampaign.objects.create(
|
||||||
twitch_id="c-badge",
|
twitch_id="c-badge",
|
||||||
name="Campaign",
|
name="Campaign",
|
||||||
|
|
@ -803,7 +920,11 @@ class TestChannelListView:
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_game_detail_view(self, client: Client, db: object) -> None:
|
def test_game_detail_view(self, client: Client, db: object) -> None:
|
||||||
"""Test game detail view returns 200 and has game in context."""
|
"""Test game detail view returns 200 and has game in context."""
|
||||||
game: Game = Game.objects.create(twitch_id="g2", name="Game2", display_name="Game2")
|
game: Game = Game.objects.create(
|
||||||
|
twitch_id="g2",
|
||||||
|
name="Game2",
|
||||||
|
display_name="Game2",
|
||||||
|
)
|
||||||
url: str = reverse("twitch:game_detail", args=[game.twitch_id])
|
url: str = reverse("twitch:game_detail", args=[game.twitch_id])
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
@ -828,7 +949,11 @@ class TestChannelListView:
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_channel_detail_view(self, client: Client, db: object) -> None:
|
def test_channel_detail_view(self, client: Client, db: object) -> None:
|
||||||
"""Test channel detail view returns 200 and has channel in context."""
|
"""Test channel detail view returns 200 and has channel in context."""
|
||||||
channel: Channel = Channel.objects.create(twitch_id="ch1", name="Channel1", display_name="Channel1")
|
channel: Channel = Channel.objects.create(
|
||||||
|
twitch_id="ch1",
|
||||||
|
name="Channel1",
|
||||||
|
display_name="Channel1",
|
||||||
|
)
|
||||||
url: str = reverse("twitch:channel_detail", args=[channel.twitch_id])
|
url: str = reverse("twitch:channel_detail", args=[channel.twitch_id])
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
@ -858,7 +983,7 @@ class TestSEOHelperFunctions:
|
||||||
|
|
||||||
def test_truncate_description_long_text(self) -> None:
|
def test_truncate_description_long_text(self) -> None:
|
||||||
"""Test that long text is truncated at word boundary."""
|
"""Test that long text is truncated at word boundary."""
|
||||||
text = "This is a very long description that exceeds the maximum length and should be truncated at a word boundary to avoid cutting off in the middle of a word" # noqa: E501
|
text = "This is a very long description that exceeds the maximum length and should be truncated at a word boundary to avoid cutting off in the middle of a word"
|
||||||
result: str = _truncate_description(text, max_length=50)
|
result: str = _truncate_description(text, max_length=50)
|
||||||
assert len(result) <= 53 # Allow some flexibility
|
assert len(result) <= 53 # Allow some flexibility
|
||||||
assert not result.endswith(" ")
|
assert not result.endswith(" ")
|
||||||
|
|
@ -890,7 +1015,9 @@ class TestSEOHelperFunctions:
|
||||||
def test_build_seo_context_with_all_parameters(self) -> None:
|
def test_build_seo_context_with_all_parameters(self) -> None:
|
||||||
"""Test _build_seo_context with all parameters."""
|
"""Test _build_seo_context with all parameters."""
|
||||||
now: datetime.datetime = timezone.now()
|
now: datetime.datetime = timezone.now()
|
||||||
breadcrumb: list[dict[str, int | str]] = [{"position": 1, "name": "Home", "url": "/"}]
|
breadcrumb: list[dict[str, int | str]] = [
|
||||||
|
{"position": 1, "name": "Home", "url": "/"},
|
||||||
|
]
|
||||||
|
|
||||||
context: dict[str, Any] = _build_seo_context(
|
context: dict[str, Any] = _build_seo_context(
|
||||||
page_title="Test",
|
page_title="Test",
|
||||||
|
|
@ -938,7 +1065,11 @@ class TestSEOHelperFunctions:
|
||||||
paginator: Paginator[int] = Paginator(items, 10)
|
paginator: Paginator[int] = Paginator(items, 10)
|
||||||
page: Page[int] = paginator.get_page(1)
|
page: Page[int] = paginator.get_page(1)
|
||||||
|
|
||||||
info: list[dict[str, str]] | None = _build_pagination_info(request, page, "/campaigns/")
|
info: list[dict[str, str]] | None = _build_pagination_info(
|
||||||
|
request,
|
||||||
|
page,
|
||||||
|
"/campaigns/",
|
||||||
|
)
|
||||||
|
|
||||||
assert info is not None
|
assert info is not None
|
||||||
assert len(info) == 1
|
assert len(info) == 1
|
||||||
|
|
@ -954,7 +1085,11 @@ class TestSEOHelperFunctions:
|
||||||
paginator: Paginator[int] = Paginator(items, 10)
|
paginator: Paginator[int] = Paginator(items, 10)
|
||||||
page: Page[int] = paginator.get_page(2)
|
page: Page[int] = paginator.get_page(2)
|
||||||
|
|
||||||
info: list[dict[str, str]] | None = _build_pagination_info(request, page, "/campaigns/")
|
info: list[dict[str, str]] | None = _build_pagination_info(
|
||||||
|
request,
|
||||||
|
page,
|
||||||
|
"/campaigns/",
|
||||||
|
)
|
||||||
|
|
||||||
assert info is not None
|
assert info is not None
|
||||||
assert len(info) == 2
|
assert len(info) == 2
|
||||||
|
|
@ -975,7 +1110,10 @@ class TestSEOMetaTags:
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: A dictionary containing the created organization, game, and campaign.
|
dict[str, Any]: A dictionary containing the created organization, game, and campaign.
|
||||||
"""
|
"""
|
||||||
org: Organization = Organization.objects.create(twitch_id="org1", name="Test Org")
|
org: Organization = Organization.objects.create(
|
||||||
|
twitch_id="org1",
|
||||||
|
name="Test Org",
|
||||||
|
)
|
||||||
game: Game = Game.objects.create(
|
game: Game = Game.objects.create(
|
||||||
twitch_id="game1",
|
twitch_id="game1",
|
||||||
name="test_game",
|
name="test_game",
|
||||||
|
|
@ -995,7 +1133,9 @@ class TestSEOMetaTags:
|
||||||
|
|
||||||
def test_campaign_list_view_has_seo_context(self, client: Client) -> None:
|
def test_campaign_list_view_has_seo_context(self, client: Client) -> None:
|
||||||
"""Test campaign list view has SEO context variables."""
|
"""Test campaign list view has SEO context variables."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:campaign_list"))
|
response: _MonkeyPatchedWSGIResponse = client.get(
|
||||||
|
reverse("twitch:campaign_list"),
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "page_title" in response.context
|
assert "page_title" in response.context
|
||||||
assert "page_description" in response.context
|
assert "page_description" in response.context
|
||||||
|
|
@ -1050,7 +1190,10 @@ class TestSEOMetaTags:
|
||||||
|
|
||||||
def test_organization_detail_view_has_breadcrumb(self, client: Client) -> None:
|
def test_organization_detail_view_has_breadcrumb(self, client: Client) -> None:
|
||||||
"""Test organization detail view has breadcrumb."""
|
"""Test organization detail view has breadcrumb."""
|
||||||
org: Organization = Organization.objects.create(twitch_id="org1", name="Test Org")
|
org: Organization = Organization.objects.create(
|
||||||
|
twitch_id="org1",
|
||||||
|
name="Test Org",
|
||||||
|
)
|
||||||
url: str = reverse("twitch:organization_detail", args=[org.twitch_id])
|
url: str = reverse("twitch:organization_detail", args=[org.twitch_id])
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||||
|
|
||||||
|
|
@ -1059,7 +1202,11 @@ class TestSEOMetaTags:
|
||||||
|
|
||||||
def test_channel_detail_view_has_breadcrumb(self, client: Client) -> None:
|
def test_channel_detail_view_has_breadcrumb(self, client: Client) -> None:
|
||||||
"""Test channel detail view has breadcrumb."""
|
"""Test channel detail view has breadcrumb."""
|
||||||
channel: Channel = Channel.objects.create(twitch_id="ch1", name="ch1", display_name="Channel 1")
|
channel: Channel = Channel.objects.create(
|
||||||
|
twitch_id="ch1",
|
||||||
|
name="ch1",
|
||||||
|
display_name="Channel 1",
|
||||||
|
)
|
||||||
url: str = reverse("twitch:channel_detail", args=[channel.twitch_id])
|
url: str = reverse("twitch:channel_detail", args=[channel.twitch_id])
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||||
|
|
||||||
|
|
@ -1068,10 +1215,11 @@ class TestSEOMetaTags:
|
||||||
|
|
||||||
def test_noindex_pages_have_robots_directive(self, client: Client) -> None:
|
def test_noindex_pages_have_robots_directive(self, client: Client) -> None:
|
||||||
"""Test that pages with noindex have proper robots directive."""
|
"""Test that pages with noindex have proper robots directive."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:dataset_backups"))
|
response: _MonkeyPatchedWSGIResponse = client.get(
|
||||||
|
reverse("twitch:dataset_backups"),
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "robots_directive" in response.context
|
assert "robots_directive" in response.context
|
||||||
assert "noindex" in response.context["robots_directive"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|
@ -1085,14 +1233,21 @@ class TestSitemapView:
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: A dictionary containing the created organization, game, channel, campaign, and badge set.
|
dict[str, Any]: A dictionary containing the created organization, game, channel, campaign, and badge set.
|
||||||
"""
|
"""
|
||||||
org: Organization = Organization.objects.create(twitch_id="org1", name="Test Org")
|
org: Organization = Organization.objects.create(
|
||||||
|
twitch_id="org1",
|
||||||
|
name="Test Org",
|
||||||
|
)
|
||||||
game: Game = Game.objects.create(
|
game: Game = Game.objects.create(
|
||||||
twitch_id="game1",
|
twitch_id="game1",
|
||||||
name="test_game",
|
name="test_game",
|
||||||
display_name="Test Game",
|
display_name="Test Game",
|
||||||
)
|
)
|
||||||
game.owners.add(org)
|
game.owners.add(org)
|
||||||
channel: Channel = Channel.objects.create(twitch_id="ch1", name="ch1", display_name="Channel 1")
|
channel: Channel = Channel.objects.create(
|
||||||
|
twitch_id="ch1",
|
||||||
|
name="ch1",
|
||||||
|
display_name="Channel 1",
|
||||||
|
)
|
||||||
campaign: DropCampaign = DropCampaign.objects.create(
|
campaign: DropCampaign = DropCampaign.objects.create(
|
||||||
twitch_id="camp1",
|
twitch_id="camp1",
|
||||||
name="Test Campaign",
|
name="Test Campaign",
|
||||||
|
|
@ -1109,31 +1264,50 @@ class TestSitemapView:
|
||||||
"badge": badge,
|
"badge": badge,
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_sitemap_view_returns_xml(self, client: Client, sample_entities: dict[str, Any]) -> None:
|
def test_sitemap_view_returns_xml(
|
||||||
|
self,
|
||||||
|
client: Client,
|
||||||
|
sample_entities: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
"""Test sitemap view returns XML content."""
|
"""Test sitemap view returns XML content."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response["Content-Type"] == "application/xml"
|
assert response["Content-Type"] == "application/xml"
|
||||||
|
|
||||||
def test_sitemap_contains_xml_declaration(self, client: Client, sample_entities: dict[str, Any]) -> None:
|
def test_sitemap_contains_xml_declaration(
|
||||||
|
self,
|
||||||
|
client: Client,
|
||||||
|
sample_entities: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
"""Test sitemap contains proper XML declaration."""
|
"""Test sitemap contains proper XML declaration."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>')
|
assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>')
|
||||||
|
|
||||||
def test_sitemap_contains_urlset(self, client: Client, sample_entities: dict[str, Any]) -> None:
|
def test_sitemap_contains_urlset(
|
||||||
|
self,
|
||||||
|
client: Client,
|
||||||
|
sample_entities: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
"""Test sitemap contains urlset element."""
|
"""Test sitemap contains urlset element."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
||||||
content: str = response.content.decode()
|
content: str = response.content.decode()
|
||||||
assert "<urlset" in content
|
assert "<urlset" in content
|
||||||
assert "</urlset>" in content
|
assert "</urlset>" in content
|
||||||
|
|
||||||
def test_sitemap_contains_static_pages(self, client: Client, sample_entities: dict[str, Any]) -> None:
|
def test_sitemap_contains_static_pages(
|
||||||
|
self,
|
||||||
|
client: Client,
|
||||||
|
sample_entities: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
"""Test sitemap includes static pages."""
|
"""Test sitemap includes static pages."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
||||||
content: str = response.content.decode()
|
content: str = response.content.decode()
|
||||||
# Check for some static pages
|
# Check for some static pages
|
||||||
assert "<loc>http://testserver/</loc>" in content or "<loc>http://localhost:8000/</loc>" in content
|
assert (
|
||||||
|
"<loc>http://testserver/</loc>" in content
|
||||||
|
or "<loc>http://localhost:8000/</loc>" in content
|
||||||
|
)
|
||||||
assert "/campaigns/" in content
|
assert "/campaigns/" in content
|
||||||
assert "/games/" in content
|
assert "/games/" in content
|
||||||
|
|
||||||
|
|
@ -1192,21 +1366,33 @@ class TestSitemapView:
|
||||||
content: str = response.content.decode()
|
content: str = response.content.decode()
|
||||||
assert f"/badges/{badge.set_id}/" in content # pyright: ignore[reportAttributeAccessIssue]
|
assert f"/badges/{badge.set_id}/" in content # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
def test_sitemap_includes_priority(self, client: Client, sample_entities: dict[str, Any]) -> None:
|
def test_sitemap_includes_priority(
|
||||||
|
self,
|
||||||
|
client: Client,
|
||||||
|
sample_entities: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
"""Test sitemap includes priority values."""
|
"""Test sitemap includes priority values."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
||||||
content: str = response.content.decode()
|
content: str = response.content.decode()
|
||||||
assert "<priority>" in content
|
assert "<priority>" in content
|
||||||
assert "</priority>" in content
|
assert "</priority>" in content
|
||||||
|
|
||||||
def test_sitemap_includes_changefreq(self, client: Client, sample_entities: dict[str, Any]) -> None:
|
def test_sitemap_includes_changefreq(
|
||||||
|
self,
|
||||||
|
client: Client,
|
||||||
|
sample_entities: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
"""Test sitemap includes changefreq values."""
|
"""Test sitemap includes changefreq values."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
||||||
content: str = response.content.decode()
|
content: str = response.content.decode()
|
||||||
assert "<changefreq>" in content
|
assert "<changefreq>" in content
|
||||||
assert "</changefreq>" in content
|
assert "</changefreq>" in content
|
||||||
|
|
||||||
def test_sitemap_includes_lastmod(self, client: Client, sample_entities: dict[str, Any]) -> None:
|
def test_sitemap_includes_lastmod(
|
||||||
|
self,
|
||||||
|
client: Client,
|
||||||
|
sample_entities: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
"""Test sitemap includes lastmod for detail pages."""
|
"""Test sitemap includes lastmod for detail pages."""
|
||||||
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
|
||||||
content: str = response.content.decode()
|
content: str = response.content.decode()
|
||||||
|
|
@ -1275,7 +1461,10 @@ class TestSEOPaginationLinks:
|
||||||
def test_campaign_list_first_page_has_next(self, client: Client) -> None:
|
def test_campaign_list_first_page_has_next(self, client: Client) -> None:
|
||||||
"""Test campaign list first page has next link."""
|
"""Test campaign list first page has next link."""
|
||||||
# Create a game and multiple campaigns to trigger pagination
|
# Create a game and multiple campaigns to trigger pagination
|
||||||
org: Organization = Organization.objects.create(twitch_id="org1", name="Test Org")
|
org: Organization = Organization.objects.create(
|
||||||
|
twitch_id="org1",
|
||||||
|
name="Test Org",
|
||||||
|
)
|
||||||
game = Game.objects.create(
|
game = Game.objects.create(
|
||||||
twitch_id="game1",
|
twitch_id="game1",
|
||||||
name="test_game",
|
name="test_game",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
@ -23,9 +21,17 @@ urlpatterns: list[URLPattern] = [
|
||||||
path("badges/", views.badge_list_view, name="badge_list"),
|
path("badges/", views.badge_list_view, name="badge_list"),
|
||||||
path("badges/<str:set_id>/", views.badge_set_detail_view, name="badge_set_detail"),
|
path("badges/<str:set_id>/", views.badge_set_detail_view, name="badge_set_detail"),
|
||||||
path("campaigns/", views.drop_campaign_list_view, name="campaign_list"),
|
path("campaigns/", views.drop_campaign_list_view, name="campaign_list"),
|
||||||
path("campaigns/<str:twitch_id>/", views.drop_campaign_detail_view, name="campaign_detail"),
|
path(
|
||||||
|
"campaigns/<str:twitch_id>/",
|
||||||
|
views.drop_campaign_detail_view,
|
||||||
|
name="campaign_detail",
|
||||||
|
),
|
||||||
path("channels/", views.ChannelListView.as_view(), name="channel_list"),
|
path("channels/", views.ChannelListView.as_view(), name="channel_list"),
|
||||||
path("channels/<str:twitch_id>/", views.ChannelDetailView.as_view(), name="channel_detail"),
|
path(
|
||||||
|
"channels/<str:twitch_id>/",
|
||||||
|
views.ChannelDetailView.as_view(),
|
||||||
|
name="channel_detail",
|
||||||
|
),
|
||||||
path("debug/", views.debug_view, name="debug"),
|
path("debug/", views.debug_view, name="debug"),
|
||||||
path("datasets/", views.dataset_backups_view, name="dataset_backups"),
|
path("datasets/", views.dataset_backups_view, name="dataset_backups"),
|
||||||
path(
|
path(
|
||||||
|
|
@ -39,20 +45,56 @@ urlpatterns: list[URLPattern] = [
|
||||||
path("games/list/", views.GamesListView.as_view(), name="games_list"),
|
path("games/list/", views.GamesListView.as_view(), name="games_list"),
|
||||||
path("games/<str:twitch_id>/", views.GameDetailView.as_view(), name="game_detail"),
|
path("games/<str:twitch_id>/", views.GameDetailView.as_view(), name="game_detail"),
|
||||||
path("organizations/", views.org_list_view, name="org_list"),
|
path("organizations/", views.org_list_view, name="org_list"),
|
||||||
path("organizations/<str:twitch_id>/", views.organization_detail_view, name="organization_detail"),
|
path(
|
||||||
path("reward-campaigns/", views.reward_campaign_list_view, name="reward_campaign_list"),
|
"organizations/<str:twitch_id>/",
|
||||||
path("reward-campaigns/<str:twitch_id>/", views.reward_campaign_detail_view, name="reward_campaign_detail"),
|
views.organization_detail_view,
|
||||||
|
name="organization_detail",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"reward-campaigns/",
|
||||||
|
views.reward_campaign_list_view,
|
||||||
|
name="reward_campaign_list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"reward-campaigns/<str:twitch_id>/",
|
||||||
|
views.reward_campaign_detail_view,
|
||||||
|
name="reward_campaign_detail",
|
||||||
|
),
|
||||||
path("search/", views.search_view, name="search"),
|
path("search/", views.search_view, name="search"),
|
||||||
path("export/campaigns/csv/", views.export_campaigns_csv, name="export_campaigns_csv"),
|
path(
|
||||||
path("export/campaigns/json/", views.export_campaigns_json, name="export_campaigns_json"),
|
"export/campaigns/csv/",
|
||||||
|
views.export_campaigns_csv,
|
||||||
|
name="export_campaigns_csv",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"export/campaigns/json/",
|
||||||
|
views.export_campaigns_json,
|
||||||
|
name="export_campaigns_json",
|
||||||
|
),
|
||||||
path("export/games/csv/", views.export_games_csv, name="export_games_csv"),
|
path("export/games/csv/", views.export_games_csv, name="export_games_csv"),
|
||||||
path("export/games/json/", views.export_games_json, name="export_games_json"),
|
path("export/games/json/", views.export_games_json, name="export_games_json"),
|
||||||
path("export/organizations/csv/", views.export_organizations_csv, name="export_organizations_csv"),
|
path(
|
||||||
path("export/organizations/json/", views.export_organizations_json, name="export_organizations_json"),
|
"export/organizations/csv/",
|
||||||
|
views.export_organizations_csv,
|
||||||
|
name="export_organizations_csv",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"export/organizations/json/",
|
||||||
|
views.export_organizations_json,
|
||||||
|
name="export_organizations_json",
|
||||||
|
),
|
||||||
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
|
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
|
||||||
path("rss/games/", GameFeed(), name="game_feed"),
|
path("rss/games/", GameFeed(), name="game_feed"),
|
||||||
path("rss/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"),
|
path(
|
||||||
|
"rss/games/<str:twitch_id>/campaigns/",
|
||||||
|
GameCampaignFeed(),
|
||||||
|
name="game_campaign_feed",
|
||||||
|
),
|
||||||
path("rss/organizations/", OrganizationRSSFeed(), name="organization_feed"),
|
path("rss/organizations/", OrganizationRSSFeed(), name="organization_feed"),
|
||||||
path("rss/organizations/<str:twitch_id>/campaigns/", OrganizationCampaignFeed(), name="organization_campaign_feed"),
|
path(
|
||||||
|
"rss/organizations/<str:twitch_id>/campaigns/",
|
||||||
|
OrganizationCampaignFeed(),
|
||||||
|
name="organization_campaign_feed",
|
||||||
|
),
|
||||||
path("rss/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed"),
|
path("rss/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from urllib.parse import ParseResult
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from urllib.parse import urlunparse
|
from urllib.parse import urlunparse
|
||||||
|
|
||||||
|
|
@ -12,10 +9,13 @@ from django.utils import timezone
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from urllib.parse import ParseResult
|
||||||
|
|
||||||
TWITCH_BOX_ART_HOST = "static-cdn.jtvnw.net"
|
TWITCH_BOX_ART_HOST = "static-cdn.jtvnw.net"
|
||||||
TWITCH_BOX_ART_PATH_PREFIX = "/ttv-boxart/"
|
TWITCH_BOX_ART_PATH_PREFIX = "/ttv-boxart/"
|
||||||
TWITCH_BOX_ART_SIZE_PATTERN: re.Pattern[str] = re.compile(r"-(\{width\}|\d+)x(\{height\}|\d+)(?=\.[A-Za-z0-9]+$)")
|
TWITCH_BOX_ART_SIZE_PATTERN: re.Pattern[str] = re.compile(
|
||||||
|
r"-(\{width\}|\d+)x(\{height\}|\d+)(?=\.[A-Za-z0-9]+$)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_twitch_box_art_url(url: str) -> bool:
|
def is_twitch_box_art_url(url: str) -> bool:
|
||||||
|
|
@ -24,7 +24,9 @@ def is_twitch_box_art_url(url: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
parsed: ParseResult = urlparse(url)
|
parsed: ParseResult = urlparse(url)
|
||||||
return parsed.netloc == TWITCH_BOX_ART_HOST and parsed.path.startswith(TWITCH_BOX_ART_PATH_PREFIX)
|
return parsed.netloc == TWITCH_BOX_ART_HOST and parsed.path.startswith(
|
||||||
|
TWITCH_BOX_ART_PATH_PREFIX,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def normalize_twitch_box_art_url(url: str) -> str:
|
def normalize_twitch_box_art_url(url: str) -> str:
|
||||||
|
|
@ -44,7 +46,10 @@ def normalize_twitch_box_art_url(url: str) -> str:
|
||||||
return url
|
return url
|
||||||
|
|
||||||
parsed: ParseResult = urlparse(url)
|
parsed: ParseResult = urlparse(url)
|
||||||
if parsed.netloc != TWITCH_BOX_ART_HOST or not parsed.path.startswith(TWITCH_BOX_ART_PATH_PREFIX):
|
if parsed.netloc != TWITCH_BOX_ART_HOST:
|
||||||
|
return url
|
||||||
|
|
||||||
|
if not parsed.path.startswith(TWITCH_BOX_ART_PATH_PREFIX):
|
||||||
return url
|
return url
|
||||||
|
|
||||||
normalized_path: str = TWITCH_BOX_ART_SIZE_PATTERN.sub("", parsed.path)
|
normalized_path: str = TWITCH_BOX_ART_SIZE_PATTERN.sub("", parsed.path)
|
||||||
|
|
|
||||||
604
twitch/views.py
604
twitch/views.py
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
|
@ -29,7 +27,6 @@ from django.db.models.functions import Trim
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.http import FileResponse
|
from django.http import FileResponse
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.template.defaultfilters import filesizeformat
|
from django.template.defaultfilters import filesizeformat
|
||||||
|
|
@ -64,13 +61,14 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
from debug_toolbar.utils import QueryDict
|
from debug_toolbar.utils import QueryDict
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("ttvdrops.views")
|
logger: logging.Logger = logging.getLogger("ttvdrops.views")
|
||||||
|
|
||||||
MIN_QUERY_LENGTH_FOR_FTS = 3
|
MIN_QUERY_LENGTH_FOR_FTS = 3
|
||||||
MIN_SEARCH_RANK = 0.05
|
MIN_SEARCH_RANK = 0.05
|
||||||
DEFAULT_SITE_DESCRIPTION = "Twitch Drops Tracker - Track your Twitch drops and campaigns easily."
|
DEFAULT_SITE_DESCRIPTION = "Archive of Twitch drops, campaigns, rewards, and more."
|
||||||
|
|
||||||
|
|
||||||
def _truncate_description(text: str, max_length: int = 160) -> str:
|
def _truncate_description(text: str, max_length: int = 160) -> str:
|
||||||
|
|
@ -124,6 +122,12 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
|
||||||
Returns:
|
Returns:
|
||||||
Dict with SEO context variables to pass to render().
|
Dict with SEO context variables to pass to render().
|
||||||
"""
|
"""
|
||||||
|
# TODO(TheLovinator): Instead of having so many parameters, # noqa: TD003
|
||||||
|
# consider having a single "seo_info" parameter that
|
||||||
|
# can contain all of these optional fields. This would make
|
||||||
|
# it easier to extend in the future without changing the
|
||||||
|
# function signature.
|
||||||
|
|
||||||
context: dict[str, Any] = {
|
context: dict[str, Any] = {
|
||||||
"page_title": page_title,
|
"page_title": page_title,
|
||||||
"page_description": page_description or DEFAULT_SITE_DESCRIPTION,
|
"page_description": page_description or DEFAULT_SITE_DESCRIPTION,
|
||||||
|
|
@ -148,9 +152,7 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
def _build_breadcrumb_schema(
|
def _build_breadcrumb_schema(items: list[dict[str, str | int]]) -> dict[str, Any]:
|
||||||
items: list[dict[str, str | int]],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Build a BreadcrumbList schema for structured data.
|
"""Build a BreadcrumbList schema for structured data.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -160,6 +162,8 @@ def _build_breadcrumb_schema(
|
||||||
Returns:
|
Returns:
|
||||||
BreadcrumbList schema dict.
|
BreadcrumbList schema dict.
|
||||||
"""
|
"""
|
||||||
|
# TODO(TheLovinator): Replace dict with something more structured, like a dataclass or namedtuple, for better type safety and readability. # noqa: TD003
|
||||||
|
|
||||||
breadcrumb_items: list[dict[str, str | int]] = []
|
breadcrumb_items: list[dict[str, str | int]] = []
|
||||||
for position, item in enumerate(items, start=1):
|
for position, item in enumerate(items, start=1):
|
||||||
breadcrumb_items.append({
|
breadcrumb_items.append({
|
||||||
|
|
@ -216,7 +220,9 @@ def _build_pagination_info(
|
||||||
|
|
||||||
|
|
||||||
def emote_gallery_view(request: HttpRequest) -> HttpResponse:
|
def emote_gallery_view(request: HttpRequest) -> HttpResponse:
|
||||||
"""View to display all emote images (distribution_type='EMOTE'), clickable to their campaign.
|
"""View to display all emote images.
|
||||||
|
|
||||||
|
Emotes are associated with DropBenefits of type "EMOTE".
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The HTTP request.
|
request: The HTTP request.
|
||||||
|
|
@ -240,7 +246,10 @@ def emote_gallery_view(request: HttpRequest) -> HttpResponse:
|
||||||
emotes: list[dict[str, str | DropCampaign]] = []
|
emotes: list[dict[str, str | DropCampaign]] = []
|
||||||
for benefit in emote_benefits:
|
for benefit in emote_benefits:
|
||||||
# Find the first drop with a campaign for this benefit
|
# Find the first drop with a campaign for this benefit
|
||||||
drop: TimeBasedDrop | None = next((d for d in getattr(benefit, "_emote_drops", []) if d.campaign), None)
|
drop: TimeBasedDrop | None = next(
|
||||||
|
(d for d in getattr(benefit, "_emote_drops", []) if d.campaign),
|
||||||
|
None,
|
||||||
|
)
|
||||||
if drop and drop.campaign:
|
if drop and drop.campaign:
|
||||||
emotes.append({
|
emotes.append({
|
||||||
"image_url": benefit.image_best_url,
|
"image_url": benefit.image_best_url,
|
||||||
|
|
@ -248,13 +257,10 @@ def emote_gallery_view(request: HttpRequest) -> HttpResponse:
|
||||||
})
|
})
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title="Twitch Emotes Gallery",
|
page_title="Twitch Emotes",
|
||||||
page_description="Browse all Twitch drop emotes and find the campaigns that award them.",
|
page_description="List of all Twitch emotes available as rewards.",
|
||||||
)
|
)
|
||||||
context: dict[str, Any] = {
|
context: dict[str, Any] = {"emotes": emotes, **seo_context}
|
||||||
"emotes": emotes,
|
|
||||||
**seo_context,
|
|
||||||
}
|
|
||||||
return render(request, "twitch/emote_gallery.html", context)
|
return render(request, "twitch/emote_gallery.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -273,19 +279,29 @@ def search_view(request: HttpRequest) -> HttpResponse:
|
||||||
|
|
||||||
if query:
|
if query:
|
||||||
if len(query) < MIN_QUERY_LENGTH_FOR_FTS:
|
if len(query) < MIN_QUERY_LENGTH_FOR_FTS:
|
||||||
results["organizations"] = Organization.objects.filter(name__istartswith=query)
|
results["organizations"] = Organization.objects.filter(
|
||||||
results["games"] = Game.objects.filter(Q(name__istartswith=query) | Q(display_name__istartswith=query))
|
name__istartswith=query,
|
||||||
|
)
|
||||||
|
results["games"] = Game.objects.filter(
|
||||||
|
Q(name__istartswith=query) | Q(display_name__istartswith=query),
|
||||||
|
)
|
||||||
results["campaigns"] = DropCampaign.objects.filter(
|
results["campaigns"] = DropCampaign.objects.filter(
|
||||||
Q(name__istartswith=query) | Q(description__icontains=query),
|
Q(name__istartswith=query) | Q(description__icontains=query),
|
||||||
).select_related("game")
|
).select_related("game")
|
||||||
results["drops"] = TimeBasedDrop.objects.filter(name__istartswith=query).select_related("campaign")
|
results["drops"] = TimeBasedDrop.objects.filter(
|
||||||
results["benefits"] = DropBenefit.objects.filter(name__istartswith=query).prefetch_related(
|
name__istartswith=query,
|
||||||
"drops__campaign",
|
).select_related("campaign")
|
||||||
)
|
results["benefits"] = DropBenefit.objects.filter(
|
||||||
|
name__istartswith=query,
|
||||||
|
).prefetch_related("drops__campaign")
|
||||||
results["reward_campaigns"] = RewardCampaign.objects.filter(
|
results["reward_campaigns"] = RewardCampaign.objects.filter(
|
||||||
Q(name__istartswith=query) | Q(brand__istartswith=query) | Q(summary__icontains=query),
|
Q(name__istartswith=query)
|
||||||
|
| Q(brand__istartswith=query)
|
||||||
|
| Q(summary__icontains=query),
|
||||||
).select_related("game")
|
).select_related("game")
|
||||||
results["badge_sets"] = ChatBadgeSet.objects.filter(set_id__istartswith=query)
|
results["badge_sets"] = ChatBadgeSet.objects.filter(
|
||||||
|
set_id__istartswith=query,
|
||||||
|
)
|
||||||
results["badges"] = ChatBadge.objects.filter(
|
results["badges"] = ChatBadge.objects.filter(
|
||||||
Q(title__istartswith=query) | Q(description__icontains=query),
|
Q(title__istartswith=query) | Q(description__icontains=query),
|
||||||
).select_related("badge_set")
|
).select_related("badge_set")
|
||||||
|
|
@ -306,18 +322,28 @@ def search_view(request: HttpRequest) -> HttpResponse:
|
||||||
name__icontains=query,
|
name__icontains=query,
|
||||||
).prefetch_related("drops__campaign")
|
).prefetch_related("drops__campaign")
|
||||||
results["reward_campaigns"] = RewardCampaign.objects.filter(
|
results["reward_campaigns"] = RewardCampaign.objects.filter(
|
||||||
Q(name__icontains=query) | Q(brand__icontains=query) | Q(summary__icontains=query),
|
Q(name__icontains=query)
|
||||||
|
| Q(brand__icontains=query)
|
||||||
|
| Q(summary__icontains=query),
|
||||||
).select_related("game")
|
).select_related("game")
|
||||||
results["badge_sets"] = ChatBadgeSet.objects.filter(set_id__icontains=query)
|
results["badge_sets"] = ChatBadgeSet.objects.filter(set_id__icontains=query)
|
||||||
results["badges"] = ChatBadge.objects.filter(
|
results["badges"] = ChatBadge.objects.filter(
|
||||||
Q(title__icontains=query) | Q(description__icontains=query),
|
Q(title__icontains=query) | Q(description__icontains=query),
|
||||||
).select_related("badge_set")
|
).select_related("badge_set")
|
||||||
|
|
||||||
|
total_results_count: int = sum(len(qs) for qs in results.values())
|
||||||
|
|
||||||
|
# TODO(TheLovinator): Make the description more informative by including counts of each result type, e.g. "Found 5 games, 3 campaigns, and 10 drops for 'rust'." # noqa: TD003
|
||||||
|
if query:
|
||||||
|
page_title: str = f"Search Results for '{query}'"[:60]
|
||||||
|
page_description: str = f"Found {total_results_count} results for '{query}'."
|
||||||
|
else:
|
||||||
|
page_title = "Search"
|
||||||
|
page_description = "Search for drops, games, channels, and organizations."
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title=f"Search Results for '{query}'" if query else "Search",
|
page_title=page_title,
|
||||||
page_description=f"Search results for '{query}' across Twitch drops, campaigns, games, and more."
|
page_description=page_description,
|
||||||
if query
|
|
||||||
else "Search for Twitch drops, campaigns, games, channels, and organizations.",
|
|
||||||
)
|
)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
|
|
@ -342,12 +368,7 @@ def org_list_view(request: HttpRequest) -> HttpResponse:
|
||||||
serialized_orgs: str = serialize(
|
serialized_orgs: str = serialize(
|
||||||
"json",
|
"json",
|
||||||
orgs,
|
orgs,
|
||||||
fields=(
|
fields=("twitch_id", "name", "added_at", "updated_at"),
|
||||||
"twitch_id",
|
|
||||||
"name",
|
|
||||||
"added_at",
|
|
||||||
"updated_at",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
orgs_data: list[dict] = json.loads(serialized_orgs)
|
orgs_data: list[dict] = json.loads(serialized_orgs)
|
||||||
|
|
||||||
|
|
@ -356,13 +377,13 @@ def org_list_view(request: HttpRequest) -> HttpResponse:
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "CollectionPage",
|
"@type": "CollectionPage",
|
||||||
"name": "Twitch Organizations",
|
"name": "Twitch Organizations",
|
||||||
"description": "Browse all Twitch organizations that offer drop campaigns and rewards.",
|
"description": "List of Twitch organizations.",
|
||||||
"url": request.build_absolute_uri("/organizations/"),
|
"url": request.build_absolute_uri("/organizations/"),
|
||||||
}
|
}
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title="Twitch Organizations",
|
page_title="Twitch Organizations",
|
||||||
page_description="Browse all Twitch organizations that offer drop campaigns and rewards.",
|
page_description="List of Twitch organizations.",
|
||||||
schema_data=collection_schema,
|
schema_data=collection_schema,
|
||||||
)
|
)
|
||||||
context: dict[str, Any] = {
|
context: dict[str, Any] = {
|
||||||
|
|
@ -375,7 +396,7 @@ def org_list_view(request: HttpRequest) -> HttpResponse:
|
||||||
|
|
||||||
|
|
||||||
# MARK: /organizations/<twitch_id>/
|
# MARK: /organizations/<twitch_id>/
|
||||||
def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse:
|
def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914
|
||||||
"""Function-based view for organization detail.
|
"""Function-based view for organization detail.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -399,12 +420,7 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
|
||||||
serialized_org: str = serialize(
|
serialized_org: str = serialize(
|
||||||
"json",
|
"json",
|
||||||
[organization],
|
[organization],
|
||||||
fields=(
|
fields=("twitch_id", "name", "added_at", "updated_at"),
|
||||||
"twitch_id",
|
|
||||||
"name",
|
|
||||||
"added_at",
|
|
||||||
"updated_at",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
org_data: list[dict] = json.loads(serialized_org)
|
org_data: list[dict] = json.loads(serialized_org)
|
||||||
|
|
||||||
|
|
@ -427,13 +443,17 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
|
||||||
|
|
||||||
org_name: str = organization.name or organization.twitch_id
|
org_name: str = organization.name or organization.twitch_id
|
||||||
games_count: int = games.count()
|
games_count: int = games.count()
|
||||||
org_description: str = f"{org_name} offers {games_count} game(s) with Twitch drop campaigns and rewards."
|
s: Literal["", "s"] = "" if games_count == 1 else "s"
|
||||||
|
org_description: str = f"{org_name} has {games_count} game{s}."
|
||||||
|
|
||||||
|
url: str = request.build_absolute_uri(
|
||||||
|
reverse("twitch:organization_detail", args=[organization.twitch_id]),
|
||||||
|
)
|
||||||
org_schema: dict[str, str | dict[str, str]] = {
|
org_schema: dict[str, str | dict[str, str]] = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": org_name,
|
"name": org_name,
|
||||||
"url": request.build_absolute_uri(reverse("twitch:organization_detail", args=[organization.twitch_id])),
|
"url": url,
|
||||||
"description": org_description,
|
"description": org_description,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,7 +463,9 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
|
||||||
{"name": "Organizations", "url": request.build_absolute_uri("/organizations/")},
|
{"name": "Organizations", "url": request.build_absolute_uri("/organizations/")},
|
||||||
{
|
{
|
||||||
"name": org_name,
|
"name": org_name,
|
||||||
"url": request.build_absolute_uri(reverse("twitch:organization_detail", args=[organization.twitch_id])),
|
"url": request.build_absolute_uri(
|
||||||
|
reverse("twitch:organization_detail", args=[organization.twitch_id]),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -452,7 +474,7 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
|
||||||
page_description=org_description,
|
page_description=org_description,
|
||||||
schema_data=org_schema,
|
schema_data=org_schema,
|
||||||
breadcrumb_schema=breadcrumb_schema,
|
breadcrumb_schema=breadcrumb_schema,
|
||||||
modified_date=organization.updated_at.isoformat() if organization.updated_at else None,
|
modified_date=organization.updated_at.isoformat(),
|
||||||
)
|
)
|
||||||
context: dict[str, Any] = {
|
context: dict[str, Any] = {
|
||||||
"organization": organization,
|
"organization": organization,
|
||||||
|
|
@ -512,9 +534,9 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
|
||||||
except Game.DoesNotExist:
|
except Game.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
description = "Browse all Twitch drop campaigns with active drops, upcoming campaigns, and rewards."
|
description = "Browse Twitch drop campaigns"
|
||||||
if status_filter == "active":
|
if status_filter == "active":
|
||||||
description = "Browse currently active Twitch drop campaigns with rewards available now."
|
description = "Browse active Twitch drop campaigns."
|
||||||
elif status_filter == "upcoming":
|
elif status_filter == "upcoming":
|
||||||
description = "View upcoming Twitch drop campaigns starting soon."
|
description = "View upcoming Twitch drop campaigns starting soon."
|
||||||
elif status_filter == "expired":
|
elif status_filter == "expired":
|
||||||
|
|
@ -529,7 +551,11 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
|
||||||
elif game_filter:
|
elif game_filter:
|
||||||
base_url += f"?game={game_filter}"
|
base_url += f"?game={game_filter}"
|
||||||
|
|
||||||
pagination_info: list[dict[str, str]] | None = _build_pagination_info(request, campaigns, base_url)
|
pagination_info: list[dict[str, str]] | None = _build_pagination_info(
|
||||||
|
request,
|
||||||
|
campaigns,
|
||||||
|
base_url,
|
||||||
|
)
|
||||||
|
|
||||||
# CollectionPage schema for campaign list
|
# CollectionPage schema for campaign list
|
||||||
collection_schema: dict[str, str] = {
|
collection_schema: dict[str, str] = {
|
||||||
|
|
@ -587,6 +613,9 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse:
|
||||||
Returns:
|
Returns:
|
||||||
HttpResponse: The rendered dataset backups page.
|
HttpResponse: The rendered dataset backups page.
|
||||||
"""
|
"""
|
||||||
|
# TODO(TheLovinator): Instead of only using sql we should also support other formats like parquet, csv, or json. # noqa: TD003
|
||||||
|
# TODO(TheLovinator): Upload to s3 instead. # noqa: TD003
|
||||||
|
|
||||||
datasets_root: Path = settings.DATA_DIR / "datasets"
|
datasets_root: Path = settings.DATA_DIR / "datasets"
|
||||||
search_dirs: list[Path] = [datasets_root]
|
search_dirs: list[Path] = [datasets_root]
|
||||||
seen_paths: set[str] = set()
|
seen_paths: set[str] = set()
|
||||||
|
|
@ -626,9 +655,8 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse:
|
||||||
datasets.sort(key=operator.itemgetter("updated_at"), reverse=True)
|
datasets.sort(key=operator.itemgetter("updated_at"), reverse=True)
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title="Database Backups - TTVDrops",
|
page_title="Twitch Dataset",
|
||||||
page_description="Download database backups and datasets containing Twitch drops, campaigns, and related data.",
|
page_description="Database backups and datasets available for download.",
|
||||||
robots_directive="noindex, follow",
|
|
||||||
)
|
)
|
||||||
context: dict[str, Any] = {
|
context: dict[str, Any] = {
|
||||||
"datasets": datasets,
|
"datasets": datasets,
|
||||||
|
|
@ -639,7 +667,10 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse:
|
||||||
return render(request, "twitch/dataset_backups.html", context)
|
return render(request, "twitch/dataset_backups.html", context)
|
||||||
|
|
||||||
|
|
||||||
def dataset_backup_download_view(request: HttpRequest, relative_path: str) -> FileResponse: # noqa: ARG001
|
def dataset_backup_download_view(
|
||||||
|
request: HttpRequest, # noqa: ARG001
|
||||||
|
relative_path: str,
|
||||||
|
) -> FileResponse:
|
||||||
"""Download a dataset backup from the data directory.
|
"""Download a dataset backup from the data directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -652,7 +683,8 @@ def dataset_backup_download_view(request: HttpRequest, relative_path: str) -> Fi
|
||||||
Raises:
|
Raises:
|
||||||
Http404: When the file is not found or is outside the data directory.
|
Http404: When the file is not found or is outside the data directory.
|
||||||
"""
|
"""
|
||||||
allowed_endings = (".zst",)
|
# TODO(TheLovinator): Use s3 instead of local disk. # noqa: TD003
|
||||||
|
|
||||||
datasets_root: Path = settings.DATA_DIR / "datasets"
|
datasets_root: Path = settings.DATA_DIR / "datasets"
|
||||||
requested_path: Path = (datasets_root / relative_path).resolve()
|
requested_path: Path = (datasets_root / relative_path).resolve()
|
||||||
data_root: Path = datasets_root.resolve()
|
data_root: Path = datasets_root.resolve()
|
||||||
|
|
@ -665,7 +697,7 @@ def dataset_backup_download_view(request: HttpRequest, relative_path: str) -> Fi
|
||||||
if not requested_path.exists() or not requested_path.is_file():
|
if not requested_path.exists() or not requested_path.is_file():
|
||||||
msg = "File not found"
|
msg = "File not found"
|
||||||
raise Http404(msg)
|
raise Http404(msg)
|
||||||
if not requested_path.name.endswith(allowed_endings):
|
if not requested_path.name.endswith(".zst"):
|
||||||
msg = "File not found"
|
msg = "File not found"
|
||||||
raise Http404(msg)
|
raise Http404(msg)
|
||||||
|
|
||||||
|
|
@ -676,7 +708,10 @@ def dataset_backup_download_view(request: HttpRequest, relative_path: str) -> Fi
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _enhance_drops_with_context(drops: QuerySet[TimeBasedDrop], now: datetime.datetime) -> list[dict[str, Any]]:
|
def _enhance_drops_with_context(
|
||||||
|
drops: QuerySet[TimeBasedDrop],
|
||||||
|
now: datetime.datetime,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
"""Helper to enhance drops with countdown and context.
|
"""Helper to enhance drops with countdown and context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -684,7 +719,7 @@ def _enhance_drops_with_context(drops: QuerySet[TimeBasedDrop], now: datetime.da
|
||||||
now: Current datetime.
|
now: Current datetime.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of dicts with drop, local_start, local_end, timezone_name, and countdown_text.
|
List of dicts with drop and additional context for display.
|
||||||
"""
|
"""
|
||||||
enhanced: list[dict[str, Any]] = []
|
enhanced: list[dict[str, Any]] = []
|
||||||
for drop in drops:
|
for drop in drops:
|
||||||
|
|
@ -737,9 +772,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
||||||
queryset=Channel.objects.order_by("display_name"),
|
queryset=Channel.objects.order_by("display_name"),
|
||||||
to_attr="channels_ordered",
|
to_attr="channels_ordered",
|
||||||
),
|
),
|
||||||
).get(
|
).get(twitch_id=twitch_id)
|
||||||
twitch_id=twitch_id,
|
|
||||||
)
|
|
||||||
except DropCampaign.DoesNotExist as exc:
|
except DropCampaign.DoesNotExist as exc:
|
||||||
msg = "No campaign found matching the query"
|
msg = "No campaign found matching the query"
|
||||||
raise Http404(msg) from exc
|
raise Http404(msg) from exc
|
||||||
|
|
@ -781,7 +814,10 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
||||||
if benefit.distribution_type == "BADGE" and benefit.name
|
if benefit.distribution_type == "BADGE" and benefit.name
|
||||||
}
|
}
|
||||||
badge_descriptions_by_title: dict[str, str] = dict(
|
badge_descriptions_by_title: dict[str, str] = dict(
|
||||||
ChatBadge.objects.filter(title__in=badge_benefit_names).values_list("title", "description"),
|
ChatBadge.objects.filter(title__in=badge_benefit_names).values_list(
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
serialized_drops = serialize(
|
serialized_drops = serialize(
|
||||||
|
|
@ -829,7 +865,9 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
||||||
if fields.get("description"):
|
if fields.get("description"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
badge_description: str | None = badge_descriptions_by_title.get(fields.get("name", ""))
|
badge_description: str | None = badge_descriptions_by_title.get(
|
||||||
|
fields.get("name", ""),
|
||||||
|
)
|
||||||
if badge_description:
|
if badge_description:
|
||||||
fields["description"] = badge_description
|
fields["description"] = badge_description
|
||||||
|
|
||||||
|
|
@ -845,7 +883,9 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
||||||
awarded_badge = None
|
awarded_badge = None
|
||||||
for benefit in drop.benefits.all():
|
for benefit in drop.benefits.all():
|
||||||
if benefit.distribution_type == "BADGE":
|
if benefit.distribution_type == "BADGE":
|
||||||
awarded_badge: ChatBadge | None = ChatBadge.objects.filter(title=benefit.name).first()
|
awarded_badge: ChatBadge | None = ChatBadge.objects.filter(
|
||||||
|
title=benefit.name,
|
||||||
|
).first()
|
||||||
break
|
break
|
||||||
enhanced_drop["awarded_badge"] = awarded_badge
|
enhanced_drop["awarded_badge"] = awarded_badge
|
||||||
|
|
||||||
|
|
@ -865,20 +905,29 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
||||||
else f"Twitch drop campaign: {campaign_name}"
|
else f"Twitch drop campaign: {campaign_name}"
|
||||||
)
|
)
|
||||||
campaign_image: str | None = campaign.image_best_url
|
campaign_image: str | None = campaign.image_best_url
|
||||||
campaign_image_width: int | None = campaign.image_width if campaign.image_file else None
|
campaign_image_width: int | None = (
|
||||||
campaign_image_height: int | None = campaign.image_height if campaign.image_file else None
|
campaign.image_width if campaign.image_file else None
|
||||||
|
)
|
||||||
|
campaign_image_height: int | None = (
|
||||||
|
campaign.image_height if campaign.image_file else None
|
||||||
|
)
|
||||||
|
|
||||||
|
url: str = request.build_absolute_uri(
|
||||||
|
reverse("twitch:campaign_detail", args=[campaign.twitch_id]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO(TheLovinator): If the campaign has specific allowed channels, we could list those as potential locations instead of just linking to Twitch homepage. # noqa: TD003
|
||||||
campaign_schema: dict[str, str | dict[str, str]] = {
|
campaign_schema: dict[str, str | dict[str, str]] = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Event",
|
"@type": "Event",
|
||||||
"name": campaign_name,
|
"name": campaign_name,
|
||||||
"description": campaign_description,
|
"description": campaign_description,
|
||||||
"url": request.build_absolute_uri(reverse("twitch:campaign_detail", args=[campaign.twitch_id])),
|
"url": url,
|
||||||
"eventStatus": "https://schema.org/EventScheduled",
|
"eventStatus": "https://schema.org/EventScheduled",
|
||||||
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
|
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
|
||||||
"location": {
|
"location": {
|
||||||
"@type": "VirtualLocation",
|
"@type": "VirtualLocation",
|
||||||
"url": "https://www.twitch.tv",
|
"url": "https://www.twitch.tv/",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if campaign.start_at:
|
if campaign.start_at:
|
||||||
|
|
@ -896,17 +945,24 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
||||||
}
|
}
|
||||||
|
|
||||||
# Breadcrumb schema for navigation
|
# Breadcrumb schema for navigation
|
||||||
game_name: str = campaign.game.display_name or campaign.game.name or campaign.game.twitch_id
|
# TODO(TheLovinator): We should have a game.get_display_name() method that encapsulates the logic of choosing between display_name, name, and twitch_id. # noqa: TD003
|
||||||
|
game_name: str = (
|
||||||
|
campaign.game.display_name or campaign.game.name or campaign.game.twitch_id
|
||||||
|
)
|
||||||
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
|
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
|
||||||
{"name": "Home", "url": request.build_absolute_uri("/")},
|
{"name": "Home", "url": request.build_absolute_uri("/")},
|
||||||
{"name": "Games", "url": request.build_absolute_uri("/games/")},
|
{"name": "Games", "url": request.build_absolute_uri("/games/")},
|
||||||
{
|
{
|
||||||
"name": game_name,
|
"name": game_name,
|
||||||
"url": request.build_absolute_uri(reverse("twitch:game_detail", args=[campaign.game.twitch_id])),
|
"url": request.build_absolute_uri(
|
||||||
|
reverse("twitch:game_detail", args=[campaign.game.twitch_id]),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": campaign_name,
|
"name": campaign_name,
|
||||||
"url": request.build_absolute_uri(reverse("twitch:campaign_detail", args=[campaign.twitch_id])),
|
"url": request.build_absolute_uri(
|
||||||
|
reverse("twitch:campaign_detail", args=[campaign.twitch_id]),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -990,7 +1046,9 @@ class GamesGridView(ListView):
|
||||||
.order_by("display_name")
|
.order_by("display_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
games_by_org: defaultdict[Organization, list[dict[str, Game]]] = defaultdict(list)
|
games_by_org: defaultdict[Organization, list[dict[str, Game]]] = defaultdict(
|
||||||
|
list,
|
||||||
|
)
|
||||||
for game in games_with_campaigns:
|
for game in games_with_campaigns:
|
||||||
for org in game.owners.all():
|
for org in game.owners.all():
|
||||||
games_by_org[org].append({"game": game})
|
games_by_org[org].append({"game": game})
|
||||||
|
|
@ -1003,14 +1061,14 @@ class GamesGridView(ListView):
|
||||||
collection_schema: dict[str, str] = {
|
collection_schema: dict[str, str] = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "CollectionPage",
|
"@type": "CollectionPage",
|
||||||
"name": "Twitch Drop Games",
|
"name": "Twitch Games",
|
||||||
"description": "Browse all Twitch games with active drop campaigns and rewards.",
|
"description": "Twitch games that had or have Twitch drops.",
|
||||||
"url": self.request.build_absolute_uri("/games/"),
|
"url": self.request.build_absolute_uri("/games/"),
|
||||||
}
|
}
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title="Twitch Drop Games",
|
page_title="Twitch Games",
|
||||||
page_description="Browse all Twitch games with active drop campaigns and rewards.",
|
page_description="Twitch games that had or have Twitch drops.",
|
||||||
schema_data=collection_schema,
|
schema_data=collection_schema,
|
||||||
)
|
)
|
||||||
context.update(seo_context)
|
context.update(seo_context)
|
||||||
|
|
@ -1085,7 +1143,8 @@ class GameDetailView(DetailView):
|
||||||
|
|
||||||
# Bulk-load all matching ChatBadge instances to avoid N+1 queries
|
# Bulk-load all matching ChatBadge instances to avoid N+1 queries
|
||||||
badges_by_title: dict[str, ChatBadge] = {
|
badges_by_title: dict[str, ChatBadge] = {
|
||||||
badge.title: badge for badge in ChatBadge.objects.filter(title__in=benefit_badge_titles)
|
badge.title: badge
|
||||||
|
for badge in ChatBadge.objects.filter(title__in=benefit_badge_titles)
|
||||||
}
|
}
|
||||||
|
|
||||||
for drop in drops_list:
|
for drop in drops_list:
|
||||||
|
|
@ -1122,19 +1181,31 @@ class GameDetailView(DetailView):
|
||||||
and campaign.end_at >= now
|
and campaign.end_at >= now
|
||||||
]
|
]
|
||||||
active_campaigns.sort(
|
active_campaigns.sort(
|
||||||
key=lambda c: c.end_at if c.end_at is not None else datetime.datetime.max.replace(tzinfo=datetime.UTC),
|
key=lambda c: (
|
||||||
|
c.end_at
|
||||||
|
if c.end_at is not None
|
||||||
|
else datetime.datetime.max.replace(tzinfo=datetime.UTC)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
upcoming_campaigns: list[DropCampaign] = [
|
upcoming_campaigns: list[DropCampaign] = [
|
||||||
campaign for campaign in all_campaigns if campaign.start_at is not None and campaign.start_at > now
|
campaign
|
||||||
|
for campaign in all_campaigns
|
||||||
|
if campaign.start_at is not None and campaign.start_at > now
|
||||||
]
|
]
|
||||||
|
|
||||||
upcoming_campaigns.sort(
|
upcoming_campaigns.sort(
|
||||||
key=lambda c: c.start_at if c.start_at is not None else datetime.datetime.max.replace(tzinfo=datetime.UTC),
|
key=lambda c: (
|
||||||
|
c.start_at
|
||||||
|
if c.start_at is not None
|
||||||
|
else datetime.datetime.max.replace(tzinfo=datetime.UTC)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
expired_campaigns: list[DropCampaign] = [
|
expired_campaigns: list[DropCampaign] = [
|
||||||
campaign for campaign in all_campaigns if campaign.end_at is not None and campaign.end_at < now
|
campaign
|
||||||
|
for campaign in all_campaigns
|
||||||
|
if campaign.end_at is not None and campaign.end_at < now
|
||||||
]
|
]
|
||||||
|
|
||||||
serialized_game: str = serialize(
|
serialized_game: str = serialize(
|
||||||
|
|
@ -1173,27 +1244,27 @@ class GameDetailView(DetailView):
|
||||||
"updated_at",
|
"updated_at",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
campaigns_data: list[dict[str, Any]] = json.loads(
|
campaigns_data: list[dict[str, Any]] = json.loads(serialized_campaigns)
|
||||||
serialized_campaigns,
|
|
||||||
)
|
|
||||||
game_data[0]["fields"]["campaigns"] = campaigns_data
|
game_data[0]["fields"]["campaigns"] = campaigns_data
|
||||||
|
|
||||||
owners: list[Organization] = list(game.owners.all())
|
owners: list[Organization] = list(game.owners.all())
|
||||||
|
|
||||||
game_name: str = game.display_name or game.name or game.twitch_id
|
game_name: str = game.display_name or game.name or game.twitch_id
|
||||||
game_description: str = (
|
game_description: str = f"Twitch drop campaigns for {game_name}."
|
||||||
f"Twitch drop campaigns for {game_name}. View active, upcoming, and completed drop rewards."
|
|
||||||
)
|
|
||||||
game_image: str | None = game.box_art_best_url
|
game_image: str | None = game.box_art_best_url
|
||||||
game_image_width: int | None = game.box_art_width if game.box_art_file else None
|
game_image_width: int | None = game.box_art_width if game.box_art_file else None
|
||||||
game_image_height: int | None = game.box_art_height if game.box_art_file else None
|
game_image_height: int | None = (
|
||||||
|
game.box_art_height if game.box_art_file else None
|
||||||
|
)
|
||||||
|
|
||||||
game_schema: dict[str, Any] = {
|
game_schema: dict[str, Any] = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "VideoGame",
|
"@type": "VideoGame",
|
||||||
"name": game_name,
|
"name": game_name,
|
||||||
"description": game_description,
|
"description": game_description,
|
||||||
"url": self.request.build_absolute_uri(reverse("twitch:game_detail", args=[game.twitch_id])),
|
"url": self.request.build_absolute_uri(
|
||||||
|
reverse("twitch:game_detail", args=[game.twitch_id]),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if game.box_art_best_url:
|
if game.box_art_best_url:
|
||||||
game_schema["image"] = game.box_art_best_url
|
game_schema["image"] = game.box_art_best_url
|
||||||
|
|
@ -1209,7 +1280,9 @@ class GameDetailView(DetailView):
|
||||||
{"name": "Games", "url": self.request.build_absolute_uri("/games/")},
|
{"name": "Games", "url": self.request.build_absolute_uri("/games/")},
|
||||||
{
|
{
|
||||||
"name": game_name,
|
"name": game_name,
|
||||||
"url": self.request.build_absolute_uri(reverse("twitch:game_detail", args=[game.twitch_id])),
|
"url": self.request.build_absolute_uri(
|
||||||
|
reverse("twitch:game_detail", args=[game.twitch_id]),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -1223,19 +1296,17 @@ class GameDetailView(DetailView):
|
||||||
breadcrumb_schema=breadcrumb_schema,
|
breadcrumb_schema=breadcrumb_schema,
|
||||||
modified_date=game.updated_at.isoformat() if game.updated_at else None,
|
modified_date=game.updated_at.isoformat() if game.updated_at else None,
|
||||||
)
|
)
|
||||||
context.update(
|
context.update({
|
||||||
{
|
"active_campaigns": active_campaigns,
|
||||||
"active_campaigns": active_campaigns,
|
"upcoming_campaigns": upcoming_campaigns,
|
||||||
"upcoming_campaigns": upcoming_campaigns,
|
"expired_campaigns": expired_campaigns,
|
||||||
"expired_campaigns": expired_campaigns,
|
"owner": owners[0] if owners else None,
|
||||||
"owner": owners[0] if owners else None,
|
"owners": owners,
|
||||||
"owners": owners,
|
"drop_awarded_badges": drop_awarded_badges,
|
||||||
"drop_awarded_badges": drop_awarded_badges,
|
"now": now,
|
||||||
"now": now,
|
"game_data": format_and_color_json(game_data[0]),
|
||||||
"game_data": format_and_color_json(game_data[0]),
|
**seo_context,
|
||||||
**seo_context,
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
@ -1266,8 +1337,8 @@ def dashboard(request: HttpRequest) -> HttpResponse:
|
||||||
.order_by("-start_at")
|
.order_by("-start_at")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Preserve insertion order (newest campaigns first). Group by game so games with multiple owners
|
# Preserve insertion order (newest campaigns first).
|
||||||
# don't render duplicate campaign cards.
|
# Group by game so games with multiple owners don't render duplicate campaign cards.
|
||||||
campaigns_by_game: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
campaigns_by_game: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
||||||
|
|
||||||
for campaign in active_campaigns:
|
for campaign in active_campaigns:
|
||||||
|
|
@ -1296,6 +1367,7 @@ def dashboard(request: HttpRequest) -> HttpResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
# WebSite schema with SearchAction for sitelinks search box
|
# WebSite schema with SearchAction for sitelinks search box
|
||||||
|
# TODO(TheLovinator): Should this be on all pages instead of just the dashboard? # noqa: TD003
|
||||||
website_schema: dict[str, str | dict[str, str | dict[str, str]]] = {
|
website_schema: dict[str, str | dict[str, str | dict[str, str]]] = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebSite",
|
"@type": "WebSite",
|
||||||
|
|
@ -1305,15 +1377,17 @@ def dashboard(request: HttpRequest) -> HttpResponse:
|
||||||
"@type": "SearchAction",
|
"@type": "SearchAction",
|
||||||
"target": {
|
"target": {
|
||||||
"@type": "EntryPoint",
|
"@type": "EntryPoint",
|
||||||
"urlTemplate": request.build_absolute_uri("/search/?q={search_term_string}"),
|
"urlTemplate": request.build_absolute_uri(
|
||||||
|
"/search/?q={search_term_string}",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"query-input": "required name=search_term_string",
|
"query-input": "required name=search_term_string",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title="ttvdrops Dashboard",
|
page_title="Twitch Drops",
|
||||||
page_description="Dashboard showing active Twitch drop campaigns, rewards, and quests. Track all current drops and campaigns.", # noqa: E501
|
page_description="Overview of active Twitch drop campaigns and rewards.",
|
||||||
og_type="website",
|
og_type="website",
|
||||||
schema_data=website_schema,
|
schema_data=website_schema,
|
||||||
)
|
)
|
||||||
|
|
@ -1372,11 +1446,11 @@ def reward_campaign_list_view(request: HttpRequest) -> HttpResponse:
|
||||||
if status_filter:
|
if status_filter:
|
||||||
title += f" ({status_filter.capitalize()})"
|
title += f" ({status_filter.capitalize()})"
|
||||||
|
|
||||||
description = "Browse all Twitch reward campaigns with active quests and rewards."
|
description = "Twitch rewards."
|
||||||
if status_filter == "active":
|
if status_filter == "active":
|
||||||
description = "Browse currently active Twitch reward campaigns with quests and rewards available now."
|
description = "Browse active Twitch reward campaigns."
|
||||||
elif status_filter == "upcoming":
|
elif status_filter == "upcoming":
|
||||||
description = "View upcoming Twitch reward campaigns starting soon."
|
description = "Browse upcoming Twitch reward campaigns."
|
||||||
elif status_filter == "expired":
|
elif status_filter == "expired":
|
||||||
description = "Browse expired Twitch reward campaigns."
|
description = "Browse expired Twitch reward campaigns."
|
||||||
|
|
||||||
|
|
@ -1389,7 +1463,11 @@ def reward_campaign_list_view(request: HttpRequest) -> HttpResponse:
|
||||||
elif game_filter:
|
elif game_filter:
|
||||||
base_url += f"?game={game_filter}"
|
base_url += f"?game={game_filter}"
|
||||||
|
|
||||||
pagination_info: list[dict[str, str]] | None = _build_pagination_info(request, reward_campaigns, base_url)
|
pagination_info: list[dict[str, str]] | None = _build_pagination_info(
|
||||||
|
request,
|
||||||
|
reward_campaigns,
|
||||||
|
base_url,
|
||||||
|
)
|
||||||
|
|
||||||
# CollectionPage schema for reward campaigns list
|
# CollectionPage schema for reward campaigns list
|
||||||
collection_schema: dict[str, str | dict[str, str | dict[str, str]]] = {
|
collection_schema: dict[str, str | dict[str, str | dict[str, str]]] = {
|
||||||
|
|
@ -1434,9 +1512,9 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
|
||||||
Http404: If the reward campaign is not found.
|
Http404: If the reward campaign is not found.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
reward_campaign: RewardCampaign = RewardCampaign.objects.select_related("game").get(
|
reward_campaign: RewardCampaign = RewardCampaign.objects.select_related(
|
||||||
twitch_id=twitch_id,
|
"game",
|
||||||
)
|
).get(twitch_id=twitch_id)
|
||||||
except RewardCampaign.DoesNotExist as exc:
|
except RewardCampaign.DoesNotExist as exc:
|
||||||
msg = "No reward campaign found matching the query"
|
msg = "No reward campaign found matching the query"
|
||||||
raise Http404(msg) from exc
|
raise Http404(msg) from exc
|
||||||
|
|
@ -1469,7 +1547,7 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
|
||||||
campaign_description: str = (
|
campaign_description: str = (
|
||||||
_truncate_description(reward_campaign.summary)
|
_truncate_description(reward_campaign.summary)
|
||||||
if reward_campaign.summary
|
if reward_campaign.summary
|
||||||
else f"Twitch reward campaign: {campaign_name}"
|
else f"{campaign_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
campaign_schema: dict[str, str | dict[str, str]] = {
|
campaign_schema: dict[str, str | dict[str, str]] = {
|
||||||
|
|
@ -1477,13 +1555,12 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
|
||||||
"@type": "Event",
|
"@type": "Event",
|
||||||
"name": campaign_name,
|
"name": campaign_name,
|
||||||
"description": campaign_description,
|
"description": campaign_description,
|
||||||
"url": request.build_absolute_uri(reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id])),
|
"url": request.build_absolute_uri(
|
||||||
|
reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id]),
|
||||||
|
),
|
||||||
"eventStatus": "https://schema.org/EventScheduled",
|
"eventStatus": "https://schema.org/EventScheduled",
|
||||||
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
|
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
|
||||||
"location": {
|
"location": {"@type": "VirtualLocation", "url": "https://www.twitch.tv"},
|
||||||
"@type": "VirtualLocation",
|
|
||||||
"url": "https://www.twitch.tv",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
if reward_campaign.starts_at:
|
if reward_campaign.starts_at:
|
||||||
campaign_schema["startDate"] = reward_campaign.starts_at.isoformat()
|
campaign_schema["startDate"] = reward_campaign.starts_at.isoformat()
|
||||||
|
|
@ -1499,11 +1576,17 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
|
||||||
# Breadcrumb schema
|
# Breadcrumb schema
|
||||||
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
|
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
|
||||||
{"name": "Home", "url": request.build_absolute_uri("/")},
|
{"name": "Home", "url": request.build_absolute_uri("/")},
|
||||||
{"name": "Reward Campaigns", "url": request.build_absolute_uri("/reward-campaigns/")},
|
{
|
||||||
|
"name": "Reward Campaigns",
|
||||||
|
"url": request.build_absolute_uri("/reward-campaigns/"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": campaign_name,
|
"name": campaign_name,
|
||||||
"url": request.build_absolute_uri(
|
"url": request.build_absolute_uri(
|
||||||
reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id]),
|
reverse(
|
||||||
|
"twitch:reward_campaign_detail",
|
||||||
|
args=[reward_campaign.twitch_id],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
@ -1513,7 +1596,7 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
|
||||||
page_description=campaign_description,
|
page_description=campaign_description,
|
||||||
schema_data=campaign_schema,
|
schema_data=campaign_schema,
|
||||||
breadcrumb_schema=breadcrumb_schema,
|
breadcrumb_schema=breadcrumb_schema,
|
||||||
modified_date=reward_campaign.updated_at.isoformat() if reward_campaign.updated_at else None,
|
modified_date=reward_campaign.updated_at.isoformat(),
|
||||||
)
|
)
|
||||||
context: dict[str, Any] = {
|
context: dict[str, Any] = {
|
||||||
"reward_campaign": reward_campaign,
|
"reward_campaign": reward_campaign,
|
||||||
|
|
@ -1544,7 +1627,9 @@ def debug_view(request: HttpRequest) -> HttpResponse:
|
||||||
broken_image_campaigns: QuerySet[DropCampaign] = (
|
broken_image_campaigns: QuerySet[DropCampaign] = (
|
||||||
DropCampaign.objects
|
DropCampaign.objects
|
||||||
.filter(
|
.filter(
|
||||||
Q(image_url__isnull=True) | Q(image_url__exact="") | ~Q(image_url__startswith="http"),
|
Q(image_url__isnull=True)
|
||||||
|
| Q(image_url__exact="")
|
||||||
|
| ~Q(image_url__startswith="http"),
|
||||||
)
|
)
|
||||||
.exclude(
|
.exclude(
|
||||||
Exists(
|
Exists(
|
||||||
|
|
@ -1560,15 +1645,15 @@ def debug_view(request: HttpRequest) -> HttpResponse:
|
||||||
broken_benefit_images: QuerySet[DropBenefit] = DropBenefit.objects.annotate(
|
broken_benefit_images: QuerySet[DropBenefit] = DropBenefit.objects.annotate(
|
||||||
trimmed_url=Trim("image_asset_url"),
|
trimmed_url=Trim("image_asset_url"),
|
||||||
).filter(
|
).filter(
|
||||||
Q(image_asset_url__isnull=True) | Q(trimmed_url__exact="") | ~Q(image_asset_url__startswith="http"),
|
Q(image_asset_url__isnull=True)
|
||||||
|
| Q(trimmed_url__exact="")
|
||||||
|
| ~Q(image_asset_url__startswith="http"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Time-based drops without any benefits
|
# Time-based drops without any benefits
|
||||||
drops_without_benefits: QuerySet[TimeBasedDrop] = TimeBasedDrop.objects.filter(
|
drops_without_benefits: QuerySet[TimeBasedDrop] = TimeBasedDrop.objects.filter(
|
||||||
benefits__isnull=True,
|
benefits__isnull=True,
|
||||||
).select_related(
|
).select_related("campaign__game")
|
||||||
"campaign__game",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Campaigns with invalid dates (start after end or missing either)
|
# Campaigns with invalid dates (start after end or missing either)
|
||||||
invalid_date_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter(
|
invalid_date_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter(
|
||||||
|
|
@ -1585,12 +1670,14 @@ def debug_view(request: HttpRequest) -> HttpResponse:
|
||||||
.order_by("game__display_name", "name")
|
.order_by("game__display_name", "name")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Active campaigns with no images at all (no direct URL and no benefit image fallbacks)
|
# Active campaigns with no images at all
|
||||||
active_missing_image: QuerySet[DropCampaign] = (
|
active_missing_image: QuerySet[DropCampaign] = (
|
||||||
DropCampaign.objects
|
DropCampaign.objects
|
||||||
.filter(start_at__lte=now, end_at__gte=now)
|
.filter(start_at__lte=now, end_at__gte=now)
|
||||||
.filter(
|
.filter(
|
||||||
Q(image_url__isnull=True) | Q(image_url__exact="") | ~Q(image_url__startswith="http"),
|
Q(image_url__isnull=True)
|
||||||
|
| Q(image_url__exact="")
|
||||||
|
| ~Q(image_url__startswith="http"),
|
||||||
)
|
)
|
||||||
.exclude(
|
.exclude(
|
||||||
Exists(
|
Exists(
|
||||||
|
|
@ -1608,29 +1695,34 @@ def debug_view(request: HttpRequest) -> HttpResponse:
|
||||||
for campaign in DropCampaign.objects.only("operation_names"):
|
for campaign in DropCampaign.objects.only("operation_names"):
|
||||||
for op_name in campaign.operation_names:
|
for op_name in campaign.operation_names:
|
||||||
if op_name and op_name.strip():
|
if op_name and op_name.strip():
|
||||||
operation_names_counter[op_name.strip()] = operation_names_counter.get(op_name.strip(), 0) + 1
|
operation_names_counter[op_name.strip()] = (
|
||||||
|
operation_names_counter.get(op_name.strip(), 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
operation_names_with_counts: list[dict[str, Any]] = [
|
operation_names_with_counts: list[dict[str, Any]] = [
|
||||||
{"trimmed_op": op_name, "count": count} for op_name, count in sorted(operation_names_counter.items())
|
{"trimmed_op": op_name, "count": count}
|
||||||
|
for op_name, count in sorted(operation_names_counter.items())
|
||||||
]
|
]
|
||||||
|
|
||||||
# Campaigns missing DropCampaignDetails operation name
|
# Campaigns missing DropCampaignDetails operation name
|
||||||
# SQLite doesn't support JSON contains lookup, so we handle it in Python for compatibility
|
# Need to handle SQLite separately since it doesn't support JSONField lookups
|
||||||
|
# Sqlite is used when testing
|
||||||
if connection.vendor == "sqlite":
|
if connection.vendor == "sqlite":
|
||||||
# For SQLite, fetch all campaigns and filter in Python
|
all_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.select_related(
|
||||||
all_campaigns: QuerySet[DropCampaign] = DropCampaign.objects.select_related("game").order_by(
|
"game",
|
||||||
"game__display_name",
|
).order_by("game__display_name", "name")
|
||||||
"name",
|
|
||||||
)
|
|
||||||
campaigns_missing_dropcampaigndetails: list[DropCampaign] = [
|
campaigns_missing_dropcampaigndetails: list[DropCampaign] = [
|
||||||
c for c in all_campaigns if c.operation_names is None or "DropCampaignDetails" not in c.operation_names
|
c
|
||||||
|
for c in all_campaigns
|
||||||
|
if c.operation_names is None
|
||||||
|
or "DropCampaignDetails" not in c.operation_names
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# For PostgreSQL, use the efficient contains lookup
|
|
||||||
campaigns_missing_dropcampaigndetails: list[DropCampaign] = list(
|
campaigns_missing_dropcampaigndetails: list[DropCampaign] = list(
|
||||||
DropCampaign.objects
|
DropCampaign.objects
|
||||||
.filter(
|
.filter(
|
||||||
Q(operation_names__isnull=True) | ~Q(operation_names__contains=["DropCampaignDetails"]),
|
Q(operation_names__isnull=True)
|
||||||
|
| ~Q(operation_names__contains=["DropCampaignDetails"]),
|
||||||
)
|
)
|
||||||
.select_related("game")
|
.select_related("game")
|
||||||
.order_by("game__display_name", "name"),
|
.order_by("game__display_name", "name"),
|
||||||
|
|
@ -1650,17 +1742,13 @@ def debug_view(request: HttpRequest) -> HttpResponse:
|
||||||
}
|
}
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title="Debug - TTVDrops",
|
page_title="Debug",
|
||||||
page_description="Debug page showing data inconsistencies and potential issues in the TTVDrops database.",
|
page_description="Debug view showing potentially broken or inconsistent data.",
|
||||||
robots_directive="noindex, nofollow",
|
robots_directive="noindex, nofollow",
|
||||||
)
|
)
|
||||||
context.update(seo_context)
|
context.update(seo_context)
|
||||||
|
|
||||||
return render(
|
return render(request, "twitch/debug.html", context)
|
||||||
request,
|
|
||||||
"twitch/debug.html",
|
|
||||||
context,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# MARK: /games/list/
|
# MARK: /games/list/
|
||||||
|
|
@ -1684,7 +1772,7 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
|
||||||
def absolute(path: str) -> str:
|
def absolute(path: str) -> str:
|
||||||
try:
|
try:
|
||||||
return request.build_absolute_uri(path)
|
return request.build_absolute_uri(path)
|
||||||
except Exception: # pragma: no cover - defensive logging for docs only
|
except Exception:
|
||||||
logger.exception("Failed to build absolute URL for %s", path)
|
logger.exception("Failed to build absolute URL for %s", path)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
@ -1700,7 +1788,7 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
|
||||||
trimmed = trimmed[:second_item] + trimmed[end_channel:]
|
trimmed = trimmed[:second_item] + trimmed[end_channel:]
|
||||||
formatted: str = trimmed.replace("><", ">\n<")
|
formatted: str = trimmed.replace("><", ">\n<")
|
||||||
return "\n".join(line for line in formatted.splitlines() if line.strip())
|
return "\n".join(line for line in formatted.splitlines() if line.strip())
|
||||||
except Exception: # pragma: no cover - defensive formatting for docs only
|
except Exception:
|
||||||
logger.exception("Failed to pretty-print RSS example")
|
logger.exception("Failed to pretty-print RSS example")
|
||||||
return xml_str
|
return xml_str
|
||||||
|
|
||||||
|
|
@ -1714,8 +1802,11 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
|
||||||
|
|
||||||
response: HttpResponse = feed_view(limited_request, *args)
|
response: HttpResponse = feed_view(limited_request, *args)
|
||||||
return _pretty_example(response.content.decode("utf-8"))
|
return _pretty_example(response.content.decode("utf-8"))
|
||||||
except Exception: # pragma: no cover - defensive logging for docs only
|
except Exception:
|
||||||
logger.exception("Failed to render %s for RSS docs", feed_view.__class__.__name__)
|
logger.exception(
|
||||||
|
"Failed to render %s for RSS docs",
|
||||||
|
feed_view.__class__.__name__,
|
||||||
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
feeds: list[dict[str, str]] = [
|
feeds: list[dict[str, str]] = [
|
||||||
|
|
@ -1755,30 +1846,40 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
|
||||||
"title": "Campaigns for a Single Game",
|
"title": "Campaigns for a Single Game",
|
||||||
"description": "Latest drop campaigns for one game.",
|
"description": "Latest drop campaigns for one game.",
|
||||||
"url": (
|
"url": (
|
||||||
absolute(reverse("twitch:game_campaign_feed", args=[sample_game.twitch_id]))
|
absolute(
|
||||||
|
reverse("twitch:game_campaign_feed", args=[sample_game.twitch_id]),
|
||||||
|
)
|
||||||
if sample_game
|
if sample_game
|
||||||
else absolute("/rss/games/<game_id>/campaigns/")
|
else absolute("/rss/games/<game_id>/campaigns/")
|
||||||
),
|
),
|
||||||
"has_sample": bool(sample_game),
|
"has_sample": bool(sample_game),
|
||||||
"example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id) if sample_game else "",
|
"example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id)
|
||||||
|
if sample_game
|
||||||
|
else "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Campaigns for an Organization",
|
"title": "Campaigns for an Organization",
|
||||||
"description": "Drop campaigns across games owned by one organization.",
|
"description": "Drop campaigns across games owned by one organization.",
|
||||||
"url": (
|
"url": (
|
||||||
absolute(reverse("twitch:organization_campaign_feed", args=[sample_org.twitch_id]))
|
absolute(
|
||||||
|
reverse(
|
||||||
|
"twitch:organization_campaign_feed",
|
||||||
|
args=[sample_org.twitch_id],
|
||||||
|
),
|
||||||
|
)
|
||||||
if sample_org
|
if sample_org
|
||||||
else absolute("/rss/organizations/<org_id>/campaigns/")
|
else absolute("/rss/organizations/<org_id>/campaigns/")
|
||||||
),
|
),
|
||||||
"has_sample": bool(sample_org),
|
"has_sample": bool(sample_org),
|
||||||
"example_xml": render_feed(OrganizationCampaignFeed(), sample_org.twitch_id) if sample_org else "",
|
"example_xml": render_feed(OrganizationCampaignFeed(), sample_org.twitch_id)
|
||||||
|
if sample_org
|
||||||
|
else "",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title="RSS Feeds - TTVDrops",
|
page_title="Twitch RSS Feeds",
|
||||||
page_description="Available RSS feeds for Twitch drops, campaigns, games, organizations, and rewards.",
|
page_description="RSS feeds for Twitch drops.",
|
||||||
robots_directive="noindex, follow",
|
|
||||||
)
|
)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
|
|
@ -1812,9 +1913,15 @@ class ChannelListView(ListView):
|
||||||
search_query: str | None = self.request.GET.get("search")
|
search_query: str | None = self.request.GET.get("search")
|
||||||
|
|
||||||
if search_query:
|
if search_query:
|
||||||
queryset = queryset.filter(Q(name__icontains=search_query) | Q(display_name__icontains=search_query))
|
queryset = queryset.filter(
|
||||||
|
Q(name__icontains=search_query)
|
||||||
|
| Q(display_name__icontains=search_query),
|
||||||
|
)
|
||||||
|
|
||||||
return queryset.annotate(campaign_count=Count("allowed_campaigns")).order_by("-campaign_count", "name")
|
return queryset.annotate(campaign_count=Count("allowed_campaigns")).order_by(
|
||||||
|
"-campaign_count",
|
||||||
|
"name",
|
||||||
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs) -> dict[str, Any]:
|
def get_context_data(self, **kwargs) -> dict[str, Any]:
|
||||||
"""Add additional context data.
|
"""Add additional context data.
|
||||||
|
|
@ -1835,7 +1942,9 @@ class ChannelListView(ListView):
|
||||||
|
|
||||||
page_obj: Page | None = context.get("page_obj")
|
page_obj: Page | None = context.get("page_obj")
|
||||||
pagination_info: list[dict[str, str]] | None = (
|
pagination_info: list[dict[str, str]] | None = (
|
||||||
_build_pagination_info(self.request, page_obj, base_url) if isinstance(page_obj, Page) else None
|
_build_pagination_info(self.request, page_obj, base_url)
|
||||||
|
if isinstance(page_obj, Page)
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
# CollectionPage schema for channels list
|
# CollectionPage schema for channels list
|
||||||
|
|
@ -1843,13 +1952,13 @@ class ChannelListView(ListView):
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "CollectionPage",
|
"@type": "CollectionPage",
|
||||||
"name": "Twitch Channels",
|
"name": "Twitch Channels",
|
||||||
"description": "Browse Twitch channels participating in drop campaigns and find their available rewards.",
|
"description": "List of Twitch channels participating in drop campaigns.",
|
||||||
"url": self.request.build_absolute_uri("/channels/"),
|
"url": self.request.build_absolute_uri("/channels/"),
|
||||||
}
|
}
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title="Twitch Channels",
|
page_title="Twitch Channels",
|
||||||
page_description="Browse Twitch channels participating in drop campaigns and find their available rewards.",
|
page_description="List of Twitch channels participating in drop campaigns.",
|
||||||
pagination_info=pagination_info,
|
pagination_info=pagination_info,
|
||||||
schema_data=collection_schema,
|
schema_data=collection_schema,
|
||||||
)
|
)
|
||||||
|
|
@ -1931,30 +2040,36 @@ class ChannelDetailView(DetailView):
|
||||||
and campaign.end_at >= now
|
and campaign.end_at >= now
|
||||||
]
|
]
|
||||||
active_campaigns.sort(
|
active_campaigns.sort(
|
||||||
key=lambda c: c.end_at if c.end_at is not None else datetime.datetime.max.replace(tzinfo=datetime.UTC),
|
key=lambda c: (
|
||||||
|
c.end_at
|
||||||
|
if c.end_at is not None
|
||||||
|
else datetime.datetime.max.replace(tzinfo=datetime.UTC)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
upcoming_campaigns: list[DropCampaign] = [
|
upcoming_campaigns: list[DropCampaign] = [
|
||||||
campaign for campaign in all_campaigns if campaign.start_at is not None and campaign.start_at > now
|
campaign
|
||||||
|
for campaign in all_campaigns
|
||||||
|
if campaign.start_at is not None and campaign.start_at > now
|
||||||
]
|
]
|
||||||
upcoming_campaigns.sort(
|
upcoming_campaigns.sort(
|
||||||
key=lambda c: c.start_at if c.start_at is not None else datetime.datetime.max.replace(tzinfo=datetime.UTC),
|
key=lambda c: (
|
||||||
|
c.start_at
|
||||||
|
if c.start_at is not None
|
||||||
|
else datetime.datetime.max.replace(tzinfo=datetime.UTC)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
expired_campaigns: list[DropCampaign] = [
|
expired_campaigns: list[DropCampaign] = [
|
||||||
campaign for campaign in all_campaigns if campaign.end_at is not None and campaign.end_at < now
|
campaign
|
||||||
|
for campaign in all_campaigns
|
||||||
|
if campaign.end_at is not None and campaign.end_at < now
|
||||||
]
|
]
|
||||||
|
|
||||||
serialized_channel: str = serialize(
|
serialized_channel: str = serialize(
|
||||||
"json",
|
"json",
|
||||||
[channel],
|
[channel],
|
||||||
fields=(
|
fields=("twitch_id", "name", "display_name", "added_at", "updated_at"),
|
||||||
"twitch_id",
|
|
||||||
"name",
|
|
||||||
"display_name",
|
|
||||||
"added_at",
|
|
||||||
"updated_at",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
channel_data: list[dict[str, Any]] = json.loads(serialized_channel)
|
channel_data: list[dict[str, Any]] = json.loads(serialized_channel)
|
||||||
|
|
||||||
|
|
@ -1978,15 +2093,20 @@ class ChannelDetailView(DetailView):
|
||||||
campaigns_data: list[dict[str, Any]] = json.loads(serialized_campaigns)
|
campaigns_data: list[dict[str, Any]] = json.loads(serialized_campaigns)
|
||||||
channel_data[0]["fields"]["campaigns"] = campaigns_data
|
channel_data[0]["fields"]["campaigns"] = campaigns_data
|
||||||
|
|
||||||
channel_name: str = channel.display_name or channel.name or channel.twitch_id
|
name: str = channel.display_name or channel.name or channel.twitch_id
|
||||||
channel_description: str = f"Twitch channel {channel_name} participating in drop campaigns. View active, upcoming, and expired campaign rewards." # noqa: E501
|
total_campaigns: int = len(all_campaigns)
|
||||||
|
description: str = f"{name} participates in {total_campaigns} drop campaign"
|
||||||
|
if total_campaigns > 1:
|
||||||
|
description += "s"
|
||||||
|
|
||||||
channel_schema: dict[str, Any] = {
|
channel_schema: dict[str, Any] = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "BroadcastChannel",
|
"@type": "BroadcastChannel",
|
||||||
"name": channel_name,
|
"name": name,
|
||||||
"description": channel_description,
|
"description": description,
|
||||||
"url": self.request.build_absolute_uri(reverse("twitch:channel_detail", args=[channel.twitch_id])),
|
"url": self.request.build_absolute_uri(
|
||||||
|
reverse("twitch:channel_detail", args=[channel.twitch_id]),
|
||||||
|
),
|
||||||
"broadcastChannelId": channel.twitch_id,
|
"broadcastChannelId": channel.twitch_id,
|
||||||
"providerName": "Twitch",
|
"providerName": "Twitch",
|
||||||
}
|
}
|
||||||
|
|
@ -1996,28 +2116,30 @@ class ChannelDetailView(DetailView):
|
||||||
{"name": "Home", "url": self.request.build_absolute_uri("/")},
|
{"name": "Home", "url": self.request.build_absolute_uri("/")},
|
||||||
{"name": "Channels", "url": self.request.build_absolute_uri("/channels/")},
|
{"name": "Channels", "url": self.request.build_absolute_uri("/channels/")},
|
||||||
{
|
{
|
||||||
"name": channel_name,
|
"name": name,
|
||||||
"url": self.request.build_absolute_uri(reverse("twitch:channel_detail", args=[channel.twitch_id])),
|
"url": self.request.build_absolute_uri(
|
||||||
|
reverse("twitch:channel_detail", args=[channel.twitch_id]),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title=channel_name,
|
page_title=name,
|
||||||
page_description=channel_description,
|
page_description=description,
|
||||||
schema_data=channel_schema,
|
schema_data=channel_schema,
|
||||||
breadcrumb_schema=breadcrumb_schema,
|
breadcrumb_schema=breadcrumb_schema,
|
||||||
modified_date=channel.updated_at.isoformat() if channel.updated_at else None,
|
modified_date=channel.updated_at.isoformat()
|
||||||
)
|
if channel.updated_at
|
||||||
context.update(
|
else None,
|
||||||
{
|
|
||||||
"active_campaigns": active_campaigns,
|
|
||||||
"upcoming_campaigns": upcoming_campaigns,
|
|
||||||
"expired_campaigns": expired_campaigns,
|
|
||||||
"now": now,
|
|
||||||
"channel_data": format_and_color_json(channel_data[0]),
|
|
||||||
**seo_context,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
context.update({
|
||||||
|
"active_campaigns": active_campaigns,
|
||||||
|
"upcoming_campaigns": upcoming_campaigns,
|
||||||
|
"expired_campaigns": expired_campaigns,
|
||||||
|
"now": now,
|
||||||
|
"channel_data": format_and_color_json(channel_data[0]),
|
||||||
|
**seo_context,
|
||||||
|
})
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
@ -2036,10 +2158,7 @@ def badge_list_view(request: HttpRequest) -> HttpResponse:
|
||||||
ChatBadgeSet.objects
|
ChatBadgeSet.objects
|
||||||
.all()
|
.all()
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch("badges", queryset=ChatBadge.objects.order_by("badge_id")),
|
||||||
"badges",
|
|
||||||
queryset=ChatBadge.objects.order_by("badge_id"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.order_by("set_id")
|
.order_by("set_id")
|
||||||
)
|
)
|
||||||
|
|
@ -2057,14 +2176,14 @@ def badge_list_view(request: HttpRequest) -> HttpResponse:
|
||||||
collection_schema: dict[str, str] = {
|
collection_schema: dict[str, str] = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "CollectionPage",
|
"@type": "CollectionPage",
|
||||||
"name": "Twitch Chat Badges",
|
"name": "Twitch chat badges",
|
||||||
"description": "Browse all Twitch chat badges awarded through drop campaigns and their associated rewards.",
|
"description": "List of Twitch chat badges awarded through drop campaigns.",
|
||||||
"url": request.build_absolute_uri("/badges/"),
|
"url": request.build_absolute_uri("/badges/"),
|
||||||
}
|
}
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
page_title="Twitch Chat Badges",
|
page_title="Twitch Chat Badges",
|
||||||
page_description="Browse all Twitch chat badges awarded through drop campaigns and their associated rewards.",
|
page_description="List of Twitch chat badges awarded through drop campaigns.",
|
||||||
schema_data=collection_schema,
|
schema_data=collection_schema,
|
||||||
)
|
)
|
||||||
context: dict[str, Any] = {
|
context: dict[str, Any] = {
|
||||||
|
|
@ -2092,10 +2211,7 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
badge_set: ChatBadgeSet = ChatBadgeSet.objects.prefetch_related(
|
badge_set: ChatBadgeSet = ChatBadgeSet.objects.prefetch_related(
|
||||||
Prefetch(
|
Prefetch("badges", queryset=ChatBadge.objects.order_by("badge_id")),
|
||||||
"badges",
|
|
||||||
queryset=ChatBadge.objects.order_by("badge_id"),
|
|
||||||
),
|
|
||||||
).get(set_id=set_id)
|
).get(set_id=set_id)
|
||||||
except ChatBadgeSet.DoesNotExist as exc:
|
except ChatBadgeSet.DoesNotExist as exc:
|
||||||
msg = "No badge set found matching the query"
|
msg = "No badge set found matching the query"
|
||||||
|
|
@ -2118,11 +2234,7 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse:
|
||||||
serialized_set: str = serialize(
|
serialized_set: str = serialize(
|
||||||
"json",
|
"json",
|
||||||
[badge_set],
|
[badge_set],
|
||||||
fields=(
|
fields=("set_id", "added_at", "updated_at"),
|
||||||
"set_id",
|
|
||||||
"added_at",
|
|
||||||
"updated_at",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
set_data: list[dict[str, Any]] = json.loads(serialized_set)
|
set_data: list[dict[str, Any]] = json.loads(serialized_set)
|
||||||
|
|
||||||
|
|
@ -2147,16 +2259,16 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse:
|
||||||
set_data[0]["fields"]["badges"] = badges_data
|
set_data[0]["fields"]["badges"] = badges_data
|
||||||
|
|
||||||
badge_set_name: str = badge_set.set_id
|
badge_set_name: str = badge_set.set_id
|
||||||
badge_set_description: str = (
|
badge_set_description: str = f"Twitch chat badge set {badge_set_name} with {badges.count()} badge{'s' if badges.count() != 1 else ''} awarded through drop campaigns."
|
||||||
f"Twitch chat badge set {badge_set_name} with {badges.count()} badge(s) awarded through drop campaigns."
|
|
||||||
)
|
|
||||||
|
|
||||||
badge_schema: dict[str, Any] = {
|
badge_schema: dict[str, Any] = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "ItemList",
|
"@type": "ItemList",
|
||||||
"name": badge_set_name,
|
"name": badge_set_name,
|
||||||
"description": badge_set_description,
|
"description": badge_set_description,
|
||||||
"url": request.build_absolute_uri(reverse("twitch:badge_set_detail", args=[badge_set.set_id])),
|
"url": request.build_absolute_uri(
|
||||||
|
reverse("twitch:badge_set_detail", args=[badge_set.set_id]),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
seo_context: dict[str, Any] = _build_seo_context(
|
seo_context: dict[str, Any] = _build_seo_context(
|
||||||
|
|
@ -2303,7 +2415,7 @@ def export_campaigns_json(request: HttpRequest) -> HttpResponse:
|
||||||
"details_url": campaign.details_url,
|
"details_url": campaign.details_url,
|
||||||
"account_link_url": campaign.account_link_url,
|
"account_link_url": campaign.account_link_url,
|
||||||
"added_at": campaign.added_at.isoformat() if campaign.added_at else None,
|
"added_at": campaign.added_at.isoformat() if campaign.added_at else None,
|
||||||
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
|
"updated_at": campaign.updated_at.isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Create JSON response
|
# Create JSON response
|
||||||
|
|
@ -2407,12 +2519,7 @@ def export_organizations_csv(request: HttpRequest) -> HttpResponse: # noqa: ARG
|
||||||
response["Content-Disposition"] = "attachment; filename=organizations.csv"
|
response["Content-Disposition"] = "attachment; filename=organizations.csv"
|
||||||
|
|
||||||
writer = csv.writer(response)
|
writer = csv.writer(response)
|
||||||
writer.writerow([
|
writer.writerow(["Twitch ID", "Name", "Added At", "Updated At"])
|
||||||
"Twitch ID",
|
|
||||||
"Name",
|
|
||||||
"Added At",
|
|
||||||
"Updated At",
|
|
||||||
])
|
|
||||||
|
|
||||||
for org in queryset:
|
for org in queryset:
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
|
|
@ -2458,7 +2565,7 @@ def export_organizations_json(request: HttpRequest) -> HttpResponse: # noqa: AR
|
||||||
|
|
||||||
|
|
||||||
# MARK: /sitemap.xml
|
# MARK: /sitemap.xml
|
||||||
def sitemap_view(request: HttpRequest) -> HttpResponse:
|
def sitemap_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0915
|
||||||
"""Generate a dynamic XML sitemap for search engines.
|
"""Generate a dynamic XML sitemap for search engines.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -2476,9 +2583,17 @@ def sitemap_view(request: HttpRequest) -> HttpResponse:
|
||||||
sitemap_urls.extend([
|
sitemap_urls.extend([
|
||||||
{"url": f"{base_url}/", "priority": "1.0", "changefreq": "daily"},
|
{"url": f"{base_url}/", "priority": "1.0", "changefreq": "daily"},
|
||||||
{"url": f"{base_url}/campaigns/", "priority": "0.9", "changefreq": "daily"},
|
{"url": f"{base_url}/campaigns/", "priority": "0.9", "changefreq": "daily"},
|
||||||
{"url": f"{base_url}/reward-campaigns/", "priority": "0.9", "changefreq": "daily"},
|
{
|
||||||
|
"url": f"{base_url}/reward-campaigns/",
|
||||||
|
"priority": "0.9",
|
||||||
|
"changefreq": "daily",
|
||||||
|
},
|
||||||
{"url": f"{base_url}/games/", "priority": "0.9", "changefreq": "weekly"},
|
{"url": f"{base_url}/games/", "priority": "0.9", "changefreq": "weekly"},
|
||||||
{"url": f"{base_url}/organizations/", "priority": "0.8", "changefreq": "weekly"},
|
{
|
||||||
|
"url": f"{base_url}/organizations/",
|
||||||
|
"priority": "0.8",
|
||||||
|
"changefreq": "weekly",
|
||||||
|
},
|
||||||
{"url": f"{base_url}/channels/", "priority": "0.8", "changefreq": "weekly"},
|
{"url": f"{base_url}/channels/", "priority": "0.8", "changefreq": "weekly"},
|
||||||
{"url": f"{base_url}/badges/", "priority": "0.7", "changefreq": "monthly"},
|
{"url": f"{base_url}/badges/", "priority": "0.7", "changefreq": "monthly"},
|
||||||
{"url": f"{base_url}/emotes/", "priority": "0.7", "changefreq": "monthly"},
|
{"url": f"{base_url}/emotes/", "priority": "0.7", "changefreq": "monthly"},
|
||||||
|
|
@ -2500,8 +2615,10 @@ def sitemap_view(request: HttpRequest) -> HttpResponse:
|
||||||
# Dynamic detail pages - Campaigns
|
# Dynamic detail pages - Campaigns
|
||||||
campaigns: QuerySet[DropCampaign] = DropCampaign.objects.all()
|
campaigns: QuerySet[DropCampaign] = DropCampaign.objects.all()
|
||||||
for campaign in campaigns:
|
for campaign in campaigns:
|
||||||
|
resource_url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
|
||||||
|
full_url: str = f"{base_url}{resource_url}"
|
||||||
entry: dict[str, str | dict[str, str]] = {
|
entry: dict[str, str | dict[str, str]] = {
|
||||||
"url": f"{base_url}{reverse('twitch:campaign_detail', args=[campaign.twitch_id])}",
|
"url": full_url,
|
||||||
"priority": "0.7",
|
"priority": "0.7",
|
||||||
"changefreq": "weekly",
|
"changefreq": "weekly",
|
||||||
}
|
}
|
||||||
|
|
@ -2512,8 +2629,10 @@ def sitemap_view(request: HttpRequest) -> HttpResponse:
|
||||||
# Dynamic detail pages - Organizations
|
# Dynamic detail pages - Organizations
|
||||||
orgs: QuerySet[Organization] = Organization.objects.all()
|
orgs: QuerySet[Organization] = Organization.objects.all()
|
||||||
for org in orgs:
|
for org in orgs:
|
||||||
|
resource_url = reverse("twitch:organization_detail", args=[org.twitch_id])
|
||||||
|
full_url: str = f"{base_url}{resource_url}"
|
||||||
entry: dict[str, str | dict[str, str]] = {
|
entry: dict[str, str | dict[str, str]] = {
|
||||||
"url": f"{base_url}{reverse('twitch:organization_detail', args=[org.twitch_id])}",
|
"url": full_url,
|
||||||
"priority": "0.7",
|
"priority": "0.7",
|
||||||
"changefreq": "weekly",
|
"changefreq": "weekly",
|
||||||
}
|
}
|
||||||
|
|
@ -2524,8 +2643,10 @@ def sitemap_view(request: HttpRequest) -> HttpResponse:
|
||||||
# Dynamic detail pages - Channels
|
# Dynamic detail pages - Channels
|
||||||
channels: QuerySet[Channel] = Channel.objects.all()
|
channels: QuerySet[Channel] = Channel.objects.all()
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
|
resource_url = reverse("twitch:channel_detail", args=[channel.twitch_id])
|
||||||
|
full_url: str = f"{base_url}{resource_url}"
|
||||||
entry: dict[str, str | dict[str, str]] = {
|
entry: dict[str, str | dict[str, str]] = {
|
||||||
"url": f"{base_url}{reverse('twitch:channel_detail', args=[channel.twitch_id])}",
|
"url": full_url,
|
||||||
"priority": "0.6",
|
"priority": "0.6",
|
||||||
"changefreq": "weekly",
|
"changefreq": "weekly",
|
||||||
}
|
}
|
||||||
|
|
@ -2535,20 +2656,27 @@ def sitemap_view(request: HttpRequest) -> HttpResponse:
|
||||||
|
|
||||||
# Dynamic detail pages - Badges
|
# Dynamic detail pages - Badges
|
||||||
badge_sets: QuerySet[ChatBadgeSet] = ChatBadgeSet.objects.all()
|
badge_sets: QuerySet[ChatBadgeSet] = ChatBadgeSet.objects.all()
|
||||||
sitemap_urls.extend(
|
for badge_set in badge_sets:
|
||||||
{
|
resource_url = reverse("twitch:badge_set_detail", args=[badge_set.set_id])
|
||||||
"url": f"{base_url}{reverse('twitch:badge_set_detail', args=[badge_set.set_id])}",
|
full_url: str = f"{base_url}{resource_url}"
|
||||||
|
sitemap_urls.append({
|
||||||
|
"url": full_url,
|
||||||
"priority": "0.5",
|
"priority": "0.5",
|
||||||
"changefreq": "monthly",
|
"changefreq": "monthly",
|
||||||
}
|
})
|
||||||
for badge_set in badge_sets
|
|
||||||
)
|
|
||||||
|
|
||||||
# Dynamic detail pages - Reward Campaigns
|
# Dynamic detail pages - Reward Campaigns
|
||||||
reward_campaigns: QuerySet[RewardCampaign] = RewardCampaign.objects.all()
|
reward_campaigns: QuerySet[RewardCampaign] = RewardCampaign.objects.all()
|
||||||
for reward_campaign in reward_campaigns:
|
for reward_campaign in reward_campaigns:
|
||||||
|
resource_url = reverse(
|
||||||
|
"twitch:reward_campaign_detail",
|
||||||
|
args=[
|
||||||
|
reward_campaign.twitch_id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
full_url: str = f"{base_url}{resource_url}"
|
||||||
entry: dict[str, str | dict[str, str]] = {
|
entry: dict[str, str | dict[str, str]] = {
|
||||||
"url": f"{base_url}{reverse('twitch:reward_campaign_detail', args=[reward_campaign.twitch_id])}",
|
"url": full_url,
|
||||||
"priority": "0.6",
|
"priority": "0.6",
|
||||||
"changefreq": "weekly",
|
"changefreq": "weekly",
|
||||||
}
|
}
|
||||||
|
|
@ -2565,7 +2693,9 @@ def sitemap_view(request: HttpRequest) -> HttpResponse:
|
||||||
xml_content += f" <loc>{url_entry['url']}</loc>\n"
|
xml_content += f" <loc>{url_entry['url']}</loc>\n"
|
||||||
if url_entry.get("lastmod"):
|
if url_entry.get("lastmod"):
|
||||||
xml_content += f" <lastmod>{url_entry['lastmod']}</lastmod>\n"
|
xml_content += f" <lastmod>{url_entry['lastmod']}</lastmod>\n"
|
||||||
xml_content += f" <changefreq>{url_entry.get('changefreq', 'monthly')}</changefreq>\n"
|
xml_content += (
|
||||||
|
f" <changefreq>{url_entry.get('changefreq', 'monthly')}</changefreq>\n"
|
||||||
|
)
|
||||||
xml_content += f" <priority>{url_entry.get('priority', '0.5')}</priority>\n"
|
xml_content += f" <priority>{url_entry.get('priority', '0.5')}</priority>\n"
|
||||||
xml_content += " </url>\n"
|
xml_content += " </url>\n"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue