Compare commits

..

No commits in common. "dcde07e1e6d802a10cbfd2770e2a2663d15ffed5" and "58d0ddeb433ca0d32e878677f046bd78d9abbb9c" have entirely different histories.

63 changed files with 288 additions and 1950 deletions

View file

@ -1,5 +1,2 @@
# Changed line-length back to default # Changed line-length back to default
1118c03c1b21e217bb66ee2811c423fe3624d546 1118c03c1b21e217bb66ee2811c423fe3624d546
# Ruff ignoring when from IDs to human-readable names
1424978854b50d8b8ab5fd1b7a1d36292524b521

View file

@ -21,13 +21,13 @@ repos:
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/adamchainz/django-upgrade - repo: https://github.com/adamchainz/django-upgrade
rev: 1.31.1 rev: 1.30.0
hooks: hooks:
- id: django-upgrade - id: django-upgrade
args: [--target-version, "6.0"] args: [--target-version, "6.0"]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.22 rev: v0.15.17
hooks: hooks:
- id: ruff-check - id: ruff-check
args: ["--fix", "--exit-non-zero-on-fix"] args: ["--fix", "--exit-non-zero-on-fix"]

View file

@ -144,7 +144,7 @@ class Command(BaseCommand):
Args: Args:
campaign_no (int): The campaign number to import. campaign_no (int): The campaign number to import.
""" """
api_version: str = "v2" # TODO(TheLovinator): Add support for v1 API # ruff:ignore[missing-todo-link] api_version: str = "v2" # TODO(TheLovinator): Add support for v1 API # noqa: TD003
url: str = f"https://api.chzzk.naver.com/service/{api_version}/drops/campaigns/{campaign_no}" url: str = f"https://api.chzzk.naver.com/service/{api_version}/drops/campaigns/{campaign_no}"
resp: requests.Response = requests.get( resp: requests.Response = requests.get(
url, url,

View file

@ -9,7 +9,7 @@ logger: logging.Logger = logging.getLogger("ttvdrops.tasks")
@shared_task(bind=True, queue="imports", max_retries=3, default_retry_delay=60) @shared_task(bind=True, queue="imports", max_retries=3, default_retry_delay=60)
def import_chzzk_campaign_task(self, campaign_no: int) -> None: # ruff:ignore[missing-type-function-argument] def import_chzzk_campaign_task(self, campaign_no: int) -> None: # noqa: ANN001
"""Import a single Chzzk campaign by its campaign number.""" """Import a single Chzzk campaign by its campaign number."""
try: try:
call_command("import_chzzk_campaign", str(campaign_no)) call_command("import_chzzk_campaign", str(campaign_no))
@ -18,7 +18,7 @@ def import_chzzk_campaign_task(self, campaign_no: int) -> None: # ruff:ignore[m
@shared_task(bind=True, queue="api-fetches", max_retries=3, default_retry_delay=120) @shared_task(bind=True, queue="api-fetches", max_retries=3, default_retry_delay=120)
def discover_chzzk_campaigns(self) -> None: # ruff:ignore[missing-type-function-argument] def discover_chzzk_campaigns(self) -> None: # noqa: ANN001
"""Discover and import the latest Chzzk campaigns (equivalent to --latest flag).""" """Discover and import the latest Chzzk campaigns (equivalent to --latest flag)."""
try: try:
call_command("import_chzzk_campaign", latest=True) call_command("import_chzzk_campaign", latest=True)

View file

@ -1,19 +0,0 @@
"""Combined v1 API router for all platforms."""
from ninja import NinjaAPI
from kick.api import api as kick_router
from twitch.api import api as twitch_router
api_v1 = NinjaAPI(
title="TTVDrops API",
version="1.0.0",
urls_namespace="api-v1",
docs_url="docs/",
)
api_v1.add_router("/twitch/", twitch_router, url_name_prefix="twitch-api-v1")
api_v1.add_router("/kick/", kick_router, url_name_prefix="kick-api-v1")
app_name = "api-v1"
urlpatterns = api_v1.urls[0]

View file

@ -23,10 +23,10 @@ classes = [
] ]
for cls in classes: for cls in classes:
setattr( # ruff:ignore[set-attr-with-constant] setattr( # noqa: B010
cls, cls,
"__class_getitem__", "__class_getitem__",
classmethod(lambda cls, *args, **kwargs: cls), # ruff:ignore[unused-lambda-argument] classmethod(lambda cls, *args, **kwargs: cls), # noqa: ARG005
) )

View file

@ -16,7 +16,7 @@ def celery_app() -> Generator[Celery, Any]:
Yields: Yields:
Celery: A Celery app instance configured for testing. Celery: A Celery app instance configured for testing.
""" """
with patch("os.environ.setdefault") as mock_setenv: # ruff:ignore[unused-variable] with patch("os.environ.setdefault") as mock_setenv: # noqa: F841
app = Celery("config") app = Celery("config")
app.config_from_object("django.conf:settings", namespace="CELERY") app.config_from_object("django.conf:settings", namespace="CELERY")
yield app yield app

View file

@ -276,25 +276,6 @@ class SiteEndpointSmokeTest(TestCase):
("twitch:export_organizations_csv", {}, 200), ("twitch:export_organizations_csv", {}, 200),
("twitch:export_organizations_json", {}, 200), ("twitch:export_organizations_json", {}, 200),
# Kick endpoints # Kick endpoints
("api-v1:kick-api-v1_list_campaigns", {}, 200),
(
"api-v1:kick-api-v1_get_campaign",
{"kick_id": self.kick_campaign.kick_id},
200,
),
("api-v1:kick-api-v1_list_games", {}, 200),
(
"api-v1:kick-api-v1_get_game",
{"kick_id": self.kick_category.kick_id},
200,
),
("api-v1:kick-api-v1_list_organizations", {}, 200),
(
"api-v1:kick-api-v1_get_organization",
{"kick_id": self.kick_org.kick_id},
200,
),
("api-v1:kick-api-v1_stats", {}, 200),
("kick:dashboard", {}, 200), ("kick:dashboard", {}, 200),
("kick:campaign_list", {}, 200), ("kick:campaign_list", {}, 200),
("kick:campaign_detail", {"kick_id": self.kick_campaign.kick_id}, 200), ("kick:campaign_detail", {"kick_id": self.kick_campaign.kick_id}, 200),

View file

@ -43,11 +43,6 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=core_views.sitemap_youtube_view, view=core_views.sitemap_youtube_view,
name="sitemap-youtube", name="sitemap-youtube",
), ),
# API v1
path(
"api/v1/",
include("config.api", namespace="api-v1"),
),
# Core app # Core app
path(route="", view=include("core.urls", namespace="core")), path(route="", view=include("core.urls", namespace="core")),
# Twitch app # Twitch app

View file

@ -7,7 +7,7 @@ if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True) # noqa: RUF076 — intentional: project-wide N+1 detection with @pytest.mark.no_zeal escape hatch
def use_zeal(request: pytest.FixtureRequest) -> Generator[None]: def use_zeal(request: pytest.FixtureRequest) -> Generator[None]:
"""Enable Zeal N+1 detection context for each pytest test. """Enable Zeal N+1 detection context for each pytest test.

View file

@ -9,7 +9,7 @@ logger: logging.Logger = logging.getLogger("ttvdrops.tasks")
@shared_task(bind=True, queue="default", max_retries=3, default_retry_delay=300) @shared_task(bind=True, queue="default", max_retries=3, default_retry_delay=300)
def submit_indexnow_task(self) -> None: # ruff:ignore[missing-type-function-argument] def submit_indexnow_task(self) -> None: # noqa: ANN001
"""Submit all site URLs to the IndexNow search index.""" """Submit all site URLs to the IndexNow search index."""
try: try:
call_command("submit_indexnow") call_command("submit_indexnow")

View file

@ -1,4 +1,4 @@
import xml.etree.ElementTree as ET # ruff:ignore[suspicious-xml-etree-import] import xml.etree.ElementTree as ET # noqa: S405
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.urls import reverse from django.urls import reverse
@ -9,7 +9,7 @@ if TYPE_CHECKING:
def _extract_locs(xml_bytes: bytes) -> list[str]: def _extract_locs(xml_bytes: bytes) -> list[str]:
root = ET.fromstring(xml_bytes) # ruff:ignore[suspicious-xml-element-tree-usage] root = ET.fromstring(xml_bytes) # noqa: S314
ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"} ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
return [el.text for el in root.findall(".//s:loc", ns) if el.text] return [el.text for el in root.findall(".//s:loc", ns) if el.text]

View file

@ -1,7 +1,7 @@
from typing import Any from typing import Any
from xml.etree.ElementTree import Element # ruff:ignore[suspicious-xml-etree-import] from xml.etree.ElementTree import Element # noqa: S405
from xml.etree.ElementTree import SubElement # ruff:ignore[suspicious-xml-etree-import] from xml.etree.ElementTree import SubElement # noqa: S405
from xml.etree.ElementTree import tostring # ruff:ignore[suspicious-xml-etree-import] from xml.etree.ElementTree import tostring # noqa: S405
from django.conf import settings from django.conf import settings

View file

@ -53,7 +53,7 @@ MIN_SEARCH_RANK = 0.05
DEFAULT_SITE_DESCRIPTION = "Archive of Twitch drops, campaigns, rewards, and more." DEFAULT_SITE_DESCRIPTION = "Archive of Twitch drops, campaigns, rewards, and more."
def _build_seo_context( # ruff:ignore[too-many-arguments, too-many-positional-arguments] def _build_seo_context( # noqa: PLR0913, PLR0917
page_title: str = "ttvdrops", page_title: str = "ttvdrops",
page_description: str | None = None, page_description: str | None = None,
page_url: str | None = None, page_url: str | None = None,
@ -91,7 +91,7 @@ def _build_seo_context( # ruff:ignore[too-many-arguments, too-many-positional-a
if page_url and not page_url.startswith("http"): if page_url and not page_url.startswith("http"):
page_url = f"{settings.BASE_URL}{page_url}" page_url = f"{settings.BASE_URL}{page_url}"
# TODO(TheLovinator): Instead of having so many parameters, # ruff:ignore[missing-todo-link] # TODO(TheLovinator): Instead of having so many parameters, # noqa: TD003
# consider having a single "seo_info" parameter that # consider having a single "seo_info" parameter that
# can contain all of these optional fields. This would make # can contain all of these optional fields. This would make
# it easier to extend in the future without changing the # it easier to extend in the future without changing the
@ -533,7 +533,7 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
# MARK: /debug/ # MARK: /debug/
def debug_view(request: HttpRequest) -> HttpResponse: # ruff:ignore[too-many-locals] def debug_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914
"""Debug view showing potentially broken or inconsistent data. """Debug view showing potentially broken or inconsistent data.
Returns: Returns:
@ -705,28 +705,6 @@ def debug_view(request: HttpRequest) -> HttpResponse: # ruff:ignore[too-many-lo
miner_only_rewards: QuerySet[RewardCampaign] = RewardCampaign.objects.filter( miner_only_rewards: QuerySet[RewardCampaign] = RewardCampaign.objects.filter(
twitch_id__in=miner_only_reward_ids, twitch_id__in=miner_only_reward_ids,
).order_by("name")[:200] ).order_by("name")[:200]
# ── Unknown-status campaigns ────────────────────────────────────────
# Kick campaigns with incomplete dates
kick_unknown_campaigns: QuerySet[KickDropCampaign] = (
KickDropCampaign.objects
.filter(
Q(starts_at__isnull=True) | Q(ends_at__isnull=True),
is_fully_imported=True,
)
.select_related("category", "organization")
.order_by("name")
)
# Twitch reward campaigns with incomplete dates
twitch_reward_unknown_campaigns: QuerySet[RewardCampaign] = (
RewardCampaign.objects
.filter(
Q(starts_at__isnull=True) | Q(ends_at__isnull=True),
)
.select_related("game")
.order_by("name")
)
context: dict[str, Any] = { context: dict[str, Any] = {
"now": now, "now": now,
"games_without_owner": games_without_owner, "games_without_owner": games_without_owner,
@ -747,9 +725,6 @@ def debug_view(request: HttpRequest) -> HttpResponse: # ruff:ignore[too-many-lo
"miner_only_rewards_count": len(miner_only_reward_ids), "miner_only_rewards_count": len(miner_only_reward_ids),
"sunkibot_only_rewards": sunkibot_only_rewards, "sunkibot_only_rewards": sunkibot_only_rewards,
"miner_only_rewards": miner_only_rewards, "miner_only_rewards": miner_only_rewards,
# Unknown-status campaigns
"kick_unknown_campaigns": kick_unknown_campaigns,
"twitch_reward_unknown_campaigns": twitch_reward_unknown_campaigns,
} }
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
@ -772,8 +747,8 @@ 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. # ruff:ignore[missing-todo-link] # 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. # ruff:ignore[missing-todo-link] # TODO(TheLovinator): Upload to s3 instead. # noqa: TD003
# TODO(TheLovinator): https://developers.google.com/search/docs/appearance/structured-data/dataset#json-ld # TODO(TheLovinator): https://developers.google.com/search/docs/appearance/structured-data/dataset#json-ld
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]
@ -889,7 +864,7 @@ def dataset_backup_download_view(
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.
""" """
# TODO(TheLovinator): Use s3 instead of local disk. # ruff:ignore[missing-todo-link] # 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()
@ -994,7 +969,7 @@ def search_view(request: HttpRequest) -> HttpResponse:
total_results_count: int = sum(len(qs) for qs in results.values()) 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'." # ruff:ignore[missing-todo-link] # 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: if query:
page_title: str = f"Search Results for '{query}'"[:60] page_title: str = f"Search Results for '{query}'"[:60]
page_description: str = f"Found {total_results_count} results for '{query}'." page_description: str = f"Found {total_results_count} results for '{query}'."

View file

@ -1,610 +0,0 @@
from __future__ import annotations
import datetime # ruff: ignore[typing-only-standard-library-import]
from typing import TYPE_CHECKING
from typing import Literal
from django.db import models
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from ninja import Router
from ninja import Schema
from kick.models import KickCategory
from kick.models import KickChannel
from kick.models import KickDropCampaign
from kick.models import KickOrganization
from kick.models import KickUser
if TYPE_CHECKING:
from django.db.models import QuerySet
from django.http import HttpRequest
from kick.models import KickReward
CampaignStatus = Literal["active", "upcoming", "expired", "unknown"]
CampaignStatusFilter = Literal["active", "upcoming", "expired", "unknown"]
VALID_STATUS_FILTERS: frozenset[str] = frozenset({
"active",
"upcoming",
"expired",
"unknown",
})
DEFAULT_PAGE_SIZE = 100
MAX_PAGE_SIZE = 500
api = Router()
# MARK: Schemas
class V1StatsSchema(Schema):
"""Aggregate counts for Kick drops."""
total_campaigns: int
active: int
upcoming: int
expired: int
partial_dates: int
total_organizations: int
total_categories: int
total_channels: int
class V1CategorySchema(Schema):
"""Kick category (game) nested in a campaign."""
kick_id: int
name: str
slug: str
image_url: str
class V1OrganizationSummarySchema(Schema):
"""Kick organization nested in a campaign."""
kick_id: str
name: str
logo_url: str
url: str
class V1UserSchema(Schema):
"""Kick user nested in a channel."""
kick_id: int
username: str
profile_picture: str
class V1ChannelSchema(Schema):
"""Kick channel participating in a drop campaign."""
kick_id: int
slug: str
url: str
user: V1UserSchema | None
class V1RewardSchema(Schema):
"""Reward earned from a Kick drop campaign."""
kick_id: str
name: str
image_url: str
required_minutes_watched: int
class V1CampaignSummarySchema(Schema):
"""Compact Kick drop campaign for list views."""
kick_id: str
name: str
status: str
image_url: str
starts_at: datetime.datetime | None
ends_at: datetime.datetime | None
category: V1CategorySchema | None
organization: V1OrganizationSummarySchema | None
reward_count: int
is_fully_imported: bool
class V1CampaignDetailSchema(V1CampaignSummarySchema):
"""Full Kick drop campaign detail."""
connect_url: str
url: str
channels: list[V1ChannelSchema]
rewards: list[V1RewardSchema]
added_at: datetime.datetime | None
updated_at: datetime.datetime | None
class V1PaginationSchema(Schema):
"""Common pagination fields."""
total: int
page: int
page_size: int
class V1CampaignListSchema(V1PaginationSchema):
"""Paginated campaign list."""
items: list[V1CampaignDetailSchema]
class V1OrganizationListItemSchema(Schema):
"""Kick organization in a paginated list."""
kick_id: str
name: str
logo_url: str
url: str
campaign_count: int
active_campaign_count: int
class V1OrganizationListSchema(V1PaginationSchema):
"""Paginated organization list."""
items: list[V1OrganizationListItemSchema]
class V1OrganizationDetailSchema(Schema):
"""Kick organization with its campaigns."""
kick_id: str
name: str
logo_url: str
url: str
campaign_count: int
active_campaign_count: int
campaigns: list[V1CampaignSummarySchema]
class V1CategoryListItemSchema(Schema):
"""Kick category (game) in a paginated list."""
kick_id: int
name: str
slug: str
image_url: str
campaign_count: int
active_campaign_count: int
class V1CategoryListSchema(V1PaginationSchema):
"""Paginated category list."""
items: list[V1CategoryListItemSchema]
class V1CategoryDetailSchema(Schema):
"""Kick category (game) with its campaigns."""
kick_id: int
name: str
slug: str
image_url: str
campaign_count: int
active_campaign_count: int
campaigns: list[V1CampaignSummarySchema]
# MARK: Helpers
def _paginate[ModelT: models.Model](
queryset: QuerySet[ModelT, ModelT],
*,
page: int,
page_size: int,
) -> tuple[list[ModelT], int, int, int]:
page = max(page, 1)
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
offset = (page - 1) * page_size
total = queryset.count()
items = list(queryset[offset : offset + page_size]) if offset < total else []
return items, total, page, page_size
def _campaign_status(
campaign: KickDropCampaign,
now: datetime.datetime,
) -> CampaignStatus:
if campaign.starts_at and campaign.ends_at:
if campaign.starts_at <= now <= campaign.ends_at:
return "active"
if campaign.starts_at > now:
return "upcoming"
return "expired"
return "unknown"
def _apply_status_filter(
queryset: QuerySet[KickDropCampaign],
status: str | None,
now: datetime.datetime,
) -> QuerySet[KickDropCampaign]:
if status == "active":
return queryset.filter(starts_at__lte=now, ends_at__gte=now)
if status == "upcoming":
return queryset.filter(starts_at__gt=now, ends_at__isnull=False)
if status == "expired":
return queryset.filter(starts_at__lte=now, ends_at__lt=now)
if status == "unknown":
return queryset.filter(
models.Q(starts_at__isnull=True) | models.Q(ends_at__isnull=True),
)
return queryset
# MARK: Serializers
def _serialize_category(category: KickCategory | None) -> V1CategorySchema | None:
if category is None:
return None
return V1CategorySchema(
kick_id=category.kick_id,
name=category.name,
slug=category.slug,
image_url=category.image_url,
)
def _serialize_organization_summary(
organization: KickOrganization | None,
) -> V1OrganizationSummarySchema | None:
if organization is None:
return None
return V1OrganizationSummarySchema(
kick_id=organization.kick_id,
name=organization.name,
logo_url=organization.logo_url,
url=organization.url,
)
def _serialize_user(user: KickUser | None) -> V1UserSchema | None:
if user is None:
return None
return V1UserSchema(
kick_id=user.kick_id,
username=user.username,
profile_picture=user.profile_picture,
)
def _serialize_channel(channel: KickChannel) -> V1ChannelSchema:
return V1ChannelSchema(
kick_id=channel.kick_id,
slug=channel.slug,
url=channel.channel_url,
user=_serialize_user(channel.user),
)
def _serialize_reward(reward: KickReward) -> V1RewardSchema:
return V1RewardSchema(
kick_id=reward.kick_id,
name=reward.name,
image_url=reward.full_image_url,
required_minutes_watched=reward.required_units,
)
def _serialize_campaign_summary(
campaign: KickDropCampaign,
now: datetime.datetime,
) -> V1CampaignSummarySchema:
return V1CampaignSummarySchema(
kick_id=campaign.kick_id,
name=campaign.name,
status=_campaign_status(campaign, now),
image_url=campaign.image_url,
starts_at=campaign.starts_at,
ends_at=campaign.ends_at,
category=_serialize_category(campaign.category),
organization=_serialize_organization_summary(campaign.organization),
reward_count=campaign.rewards.count(), # pyright: ignore[reportAttributeAccessIssue]
is_fully_imported=campaign.is_fully_imported,
)
def _serialize_campaign(
campaign: KickDropCampaign,
now: datetime.datetime,
) -> V1CampaignDetailSchema:
return V1CampaignDetailSchema(
kick_id=campaign.kick_id,
name=campaign.name,
status=_campaign_status(campaign, now),
image_url=campaign.image_url,
starts_at=campaign.starts_at,
ends_at=campaign.ends_at,
category=_serialize_category(campaign.category),
organization=_serialize_organization_summary(campaign.organization),
reward_count=campaign.rewards.count(), # pyright: ignore[reportAttributeAccessIssue]
is_fully_imported=campaign.is_fully_imported,
connect_url=campaign.connect_url,
url=campaign.url,
channels=[_serialize_channel(channel) for channel in campaign.channels.all()],
rewards=[
_serialize_reward(reward)
for reward in campaign.rewards.all() # pyright: ignore[reportAttributeAccessIssue]
],
added_at=campaign.added_at,
updated_at=campaign.updated_at,
)
# MARK: Endpoints
@api.get("/", response=V1StatsSchema)
def stats(request: HttpRequest) -> V1StatsSchema:
"""Return aggregate counts for Kick drops."""
now: datetime.datetime = timezone.now()
total_campaigns: int = KickDropCampaign.objects.filter(
is_fully_imported=True,
).count()
active: int = KickDropCampaign.objects.filter(
is_fully_imported=True,
starts_at__lte=now,
ends_at__gte=now,
).count()
upcoming: int = KickDropCampaign.objects.filter(
is_fully_imported=True,
starts_at__gt=now,
ends_at__isnull=False,
).count()
expired: int = KickDropCampaign.objects.filter(
is_fully_imported=True,
starts_at__lte=now,
ends_at__lt=now,
).count()
return V1StatsSchema(
total_campaigns=total_campaigns,
active=active,
upcoming=upcoming,
expired=expired,
partial_dates=total_campaigns - active - upcoming - expired,
total_organizations=KickOrganization.objects.count(),
total_categories=KickCategory.objects.count(),
total_channels=KickChannel.objects.count(),
)
@api.get("/campaigns/", response=V1CampaignListSchema)
def list_campaigns( # ruff:ignore[too-many-positional-arguments]
request: HttpRequest,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
status: CampaignStatusFilter | None = None,
game: int | None = None,
organization: str | None = None,
search: str | None = None,
) -> V1CampaignListSchema:
"""Return paginated Kick drop campaigns."""
now: datetime.datetime = timezone.now()
queryset: QuerySet[KickDropCampaign] = (
KickDropCampaign.objects
.filter(is_fully_imported=True)
.select_related("category", "organization")
.prefetch_related("channels__user", "rewards")
.order_by("-starts_at", "kick_id")
)
if game is not None:
queryset = queryset.filter(category__kick_id=game)
if organization is not None:
queryset = queryset.filter(organization__kick_id=organization)
if search is not None:
queryset = queryset.filter(name__icontains=search)
queryset = _apply_status_filter(queryset, status, now)
items, total, current_page, current_page_size = _paginate(
queryset,
page=page,
page_size=page_size,
)
return V1CampaignListSchema(
total=total,
page=current_page,
page_size=current_page_size,
items=[_serialize_campaign(campaign, now) for campaign in items],
)
@api.get("/campaigns/{kick_id}/", response=V1CampaignDetailSchema)
def get_campaign(
request: HttpRequest,
kick_id: str,
) -> V1CampaignDetailSchema:
"""Return a single Kick drop campaign.
Raises:
Http404: If the campaign does not exist or is not fully imported.
"""
try:
campaign: KickDropCampaign = (
KickDropCampaign.objects
.filter(is_fully_imported=True)
.select_related("category", "organization")
.prefetch_related("channels__user", "rewards")
.get(kick_id=kick_id)
)
except KickDropCampaign.DoesNotExist as exc:
msg = "Campaign not found"
raise Http404(msg) from exc
return _serialize_campaign(campaign, timezone.now())
@api.get("/games/", response=V1CategoryListSchema)
def list_games(
request: HttpRequest,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
search: str | None = None,
) -> V1CategoryListSchema:
"""Return paginated Kick categories (games)."""
now: datetime.datetime = timezone.now()
queryset: QuerySet[KickCategory] = KickCategory.objects.annotate(
campaign_count=models.Count(
"campaigns",
filter=models.Q(campaigns__is_fully_imported=True),
),
active_campaign_count=models.Count(
"campaigns",
filter=models.Q(
campaigns__is_fully_imported=True,
campaigns__starts_at__lte=now,
campaigns__ends_at__gte=now,
),
),
).order_by("name")
if search is not None:
queryset = queryset.filter(name__icontains=search)
items, total, current_page, current_page_size = _paginate(
queryset,
page=page,
page_size=page_size,
)
return V1CategoryListSchema(
total=total,
page=current_page,
page_size=current_page_size,
items=[
V1CategoryListItemSchema(
kick_id=cat.kick_id,
name=cat.name,
slug=cat.slug,
image_url=cat.image_url,
campaign_count=cat.campaign_count, # pyright: ignore[reportAttributeAccessIssue]
active_campaign_count=cat.active_campaign_count, # pyright: ignore[reportAttributeAccessIssue]
)
for cat in items
],
)
@api.get("/games/{kick_id}/", response=V1CategoryDetailSchema)
def get_game(
request: HttpRequest,
kick_id: int,
) -> V1CategoryDetailSchema:
"""Return a single Kick category (game) with its campaigns."""
category: KickCategory = get_object_or_404(KickCategory, kick_id=kick_id)
now: datetime.datetime = timezone.now()
campaigns: list[KickDropCampaign] = list(
category.campaigns # pyright: ignore[reportAttributeAccessIssue]
.filter(is_fully_imported=True)
.select_related("organization")
.prefetch_related("rewards")
.order_by("-starts_at"),
)
return V1CategoryDetailSchema(
kick_id=category.kick_id,
name=category.name,
slug=category.slug,
image_url=category.image_url,
campaign_count=len(campaigns),
active_campaign_count=sum(
1 for c in campaigns if _campaign_status(c, now) == "active"
),
campaigns=[_serialize_campaign_summary(c, now) for c in campaigns],
)
@api.get("/organizations/", response=V1OrganizationListSchema)
def list_organizations(
request: HttpRequest,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
search: str | None = None,
) -> V1OrganizationListSchema:
"""Return paginated Kick organizations."""
now: datetime.datetime = timezone.now()
queryset: QuerySet[KickOrganization] = KickOrganization.objects.annotate(
campaign_count=models.Count(
"campaigns",
filter=models.Q(campaigns__is_fully_imported=True),
),
active_campaign_count=models.Count(
"campaigns",
filter=models.Q(
campaigns__is_fully_imported=True,
campaigns__starts_at__lte=now,
campaigns__ends_at__gte=now,
),
),
).order_by("name")
if search is not None:
queryset = queryset.filter(name__icontains=search)
items, total, current_page, current_page_size = _paginate(
queryset,
page=page,
page_size=page_size,
)
return V1OrganizationListSchema(
total=total,
page=current_page,
page_size=current_page_size,
items=[
V1OrganizationListItemSchema(
kick_id=org.kick_id,
name=org.name,
logo_url=org.logo_url,
url=org.url,
campaign_count=org.campaign_count, # pyright: ignore[reportAttributeAccessIssue]
active_campaign_count=org.active_campaign_count, # pyright: ignore[reportAttributeAccessIssue]
)
for org in items
],
)
@api.get("/organizations/{kick_id}/", response=V1OrganizationDetailSchema)
def get_organization(
request: HttpRequest,
kick_id: str,
) -> V1OrganizationDetailSchema:
"""Return a single Kick organization with its campaigns."""
organization: KickOrganization = get_object_or_404(
KickOrganization,
kick_id=kick_id,
)
now: datetime.datetime = timezone.now()
campaigns: list[KickDropCampaign] = list(
organization.campaigns # pyright: ignore[reportAttributeAccessIssue]
.filter(is_fully_imported=True)
.select_related("category")
.prefetch_related("rewards")
.order_by("-starts_at"),
)
return V1OrganizationDetailSchema(
kick_id=organization.kick_id,
name=organization.name,
logo_url=organization.logo_url,
url=organization.url,
campaign_count=len(campaigns),
active_campaign_count=sum(
1 for c in campaigns if _campaign_status(c, now) == "active"
),
campaigns=[_serialize_campaign_summary(c, now) for c in campaigns],
)

View file

@ -496,7 +496,7 @@ class KickCategoryCampaignFeed(TTVDropsBaseFeed):
self._limit = None self._limit = None
return super().__call__(request, *args, **kwargs) return super().__call__(request, *args, **kwargs)
def get_object(self, request: HttpRequest, kick_id: int) -> KickCategory: # ruff:ignore[unused-method-argument] def get_object(self, request: HttpRequest, kick_id: int) -> KickCategory: # noqa: ARG002
"""Return game object for this feed URL.""" """Return game object for this feed URL."""
return KickCategory.objects.get(kick_id=kick_id) return KickCategory.objects.get(kick_id=kick_id)

View file

@ -130,7 +130,7 @@ class Command(BaseCommand):
try: try:
payload: dict = response.json() payload: dict = response.json()
except Exception as exc: # ruff:ignore[blind-except] except Exception as exc: # noqa: BLE001
self.stderr.write(self.style.ERROR(f"Failed to parse JSON response: {exc}")) self.stderr.write(self.style.ERROR(f"Failed to parse JSON response: {exc}"))
return return
@ -166,7 +166,7 @@ class Command(BaseCommand):
self.style.SUCCESS(f"Imported {imported}/{len(campaigns)} campaign(s)."), self.style.SUCCESS(f"Imported {imported}/{len(campaigns)} campaign(s)."),
) )
def _import_campaign(self, data: KickDropCampaignSchema) -> None: # ruff:ignore[too-many-locals, too-many-statements] def _import_campaign(self, data: KickDropCampaignSchema) -> None: # noqa: PLR0914, PLR0915
"""Import a single campaign and all its related objects.""" """Import a single campaign and all its related objects."""
# Organization # Organization
org_data: KickOrganizationSchema = data.organization org_data: KickOrganizationSchema = data.organization

View file

@ -93,12 +93,12 @@ class KickCategory(auto_prefetch.Model):
@property @property
def get_absolute_url(self) -> str: def get_absolute_url(self) -> str:
"""The URL to the game detail page.""" """Return the URL to the game detail page."""
return reverse("kick:game_detail", args=[self.kick_id]) return reverse("kick:game_detail", args=[self.kick_id])
@property @property
def kick_url(self) -> str: def kick_url(self) -> str:
"""The URL to the game page on Kick.""" """Return the URL to the game page on Kick."""
return f"https://kick.com/category/{self.slug}" if self.slug else "" return f"https://kick.com/category/{self.slug}" if self.slug else ""
@ -136,7 +136,7 @@ class KickUser(auto_prefetch.Model):
@property @property
def kick_profile_url(self) -> str: def kick_profile_url(self) -> str:
"""The Kick profile URL for this user.""" """Return the Kick profile URL for this user."""
return f"https://kick.com/{self.username}" if self.username else "" return f"https://kick.com/{self.username}" if self.username else ""
@ -183,7 +183,7 @@ class KickChannel(auto_prefetch.Model):
@property @property
def channel_url(self) -> str: def channel_url(self) -> str:
"""The Kick channel URL.""" """Return the Kick channel URL."""
return f"https://kick.com/{self.slug}" if self.slug else "" return f"https://kick.com/{self.slug}" if self.slug else ""
@ -290,7 +290,7 @@ class KickDropCampaign(auto_prefetch.Model):
@property @property
def image_url(self) -> str: def image_url(self) -> str:
"""The image URL for the campaign.""" """Return the image URL for the campaign."""
# Image from first drop # Image from first drop
rewards_prefetched: list[KickReward] | None = getattr( rewards_prefetched: list[KickReward] | None = getattr(
self, self,
@ -356,7 +356,7 @@ class KickDropCampaign(auto_prefetch.Model):
@property @property
def merged_rewards(self) -> list[KickReward]: def merged_rewards(self) -> list[KickReward]:
"""Rewards de-duplicated by normalized name. """Return rewards de-duplicated by normalized name.
If both a base reward and a "(Con)" variant exist, prefer the base reward name. If both a base reward and a "(Con)" variant exist, prefer the base reward name.
""" """
@ -455,7 +455,7 @@ class KickReward(auto_prefetch.Model):
@property @property
def full_image_url(self) -> str: def full_image_url(self) -> str:
"""The absolute image URL for this reward. """Return the absolute image URL for this reward.
If the image_url is a relative path, prepend the Kick image base URL. If the image_url is a relative path, prepend the Kick image base URL.
""" """

View file

@ -1,4 +1,4 @@
from datetime import datetime # ruff:ignore[typing-only-standard-library-import] from datetime import datetime # noqa: TC003
from pydantic import BaseModel from pydantic import BaseModel
from pydantic import Field from pydantic import Field

View file

@ -9,7 +9,7 @@ logger = logging.getLogger("ttvdrops.tasks")
@shared_task(bind=True, queue="api-fetches", max_retries=3, default_retry_delay=120) @shared_task(bind=True, queue="api-fetches", max_retries=3, default_retry_delay=120)
def import_kick_drops(self) -> None: # ruff:ignore[missing-type-function-argument] def import_kick_drops(self) -> None: # noqa: ANN001
"""Fetch and upsert Kick drop campaigns from the public API.""" """Fetch and upsert Kick drop campaigns from the public API."""
try: try:
call_command("import_kick_drops") call_command("import_kick_drops")

View file

@ -1,810 +0,0 @@
from __future__ import annotations
from datetime import UTC
from datetime import datetime
from unittest import mock
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from kick.models import KickCategory
from kick.models import KickChannel
from kick.models import KickDropCampaign
from kick.models import KickOrganization
from kick.models import KickReward
from kick.models import KickUser
class KickCampaignApiTest(TestCase):
"""Tests for the public Kick campaign JSON API."""
@classmethod
def setUpTestData(cls) -> None:
"""Create a fully populated campaign for API contract tests."""
cls.organization = KickOrganization.objects.create(
kick_id="org-api",
name="API Organization",
logo_url="https://example.com/org.png",
url="https://example.com/org",
)
cls.category = KickCategory.objects.create(
kick_id=123,
name="API Game",
slug="api-game",
image_url="https://example.com/game.png",
)
cls.campaign = KickDropCampaign.objects.create(
kick_id="campaign-api",
name="API Campaign",
status="expired",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
connect_url="https://example.com/connect",
url="https://example.com/campaign",
organization=cls.organization,
category=cls.category,
rule_id=1,
rule_name="Watch to earn",
is_fully_imported=True,
)
KickDropCampaign.objects.create(
kick_id="campaign-not-imported",
name="Campaign Not Imported",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=cls.organization,
category=cls.category,
)
user = KickUser.objects.create(
kick_id=456,
username="api-streamer",
profile_picture="https://example.com/avatar.png",
)
channel = KickChannel.objects.create(
kick_id=789,
slug="api-streamer",
user=user,
)
cls.campaign.channels.add(channel)
KickReward.objects.create(
kick_id="reward-api",
name="API Reward",
image_url="drops/reward-image/reward.png",
required_units=30,
campaign=cls.campaign,
category=cls.category,
organization=cls.organization,
)
def test_list_returns_paginated_campaigns_with_nested_data(self) -> None:
"""A campaign includes the fields needed by downstream importers."""
with mock.patch(
"kick.api.timezone.now",
return_value=datetime(2026, 7, 15, tzinfo=UTC),
):
response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns"))
assert response.status_code == 200
assert response["Content-Type"].startswith("application/json")
data = response.json()
assert data["total"] == 1
assert data["page"] == 1
assert data["page_size"] == 100
item = data["items"][0]
assert item["kick_id"] == "campaign-api"
assert item["name"] == "API Campaign"
assert item["status"] == "active"
assert (
item["image_url"]
== "https://ext.cdn.kick.com/drops/reward-image/reward.png"
)
assert item["connect_url"] == "https://example.com/connect"
assert item["url"] == "https://example.com/campaign"
assert item["starts_at"] == "2026-07-01T00:00:00Z"
assert item["ends_at"] == "2026-08-01T00:00:00Z"
assert item["category"] == {
"kick_id": 123,
"name": "API Game",
"slug": "api-game",
"image_url": "https://example.com/game.png",
}
assert item["organization"] == {
"kick_id": "org-api",
"name": "API Organization",
"logo_url": "https://example.com/org.png",
"url": "https://example.com/org",
}
assert item["reward_count"] == 1
assert item["channels"] == [
{
"kick_id": 789,
"slug": "api-streamer",
"url": "https://kick.com/api-streamer",
"user": {
"kick_id": 456,
"username": "api-streamer",
"profile_picture": "https://example.com/avatar.png",
},
},
]
assert item["rewards"] == [
{
"kick_id": "reward-api",
"name": "API Reward",
"image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png",
"required_minutes_watched": 30,
},
]
assert item["is_fully_imported"] is True
assert "added_at" in item
assert "updated_at" in item
def test_filters_campaigns_by_game(self) -> None:
"""A Kick category ID can select a downstream import subset."""
other_category = KickCategory.objects.create(
kick_id=124,
name="Other Game",
)
KickDropCampaign.objects.create(
kick_id="other-campaign",
name="Other Campaign",
starts_at=datetime(2026, 8, 2, tzinfo=UTC),
ends_at=datetime(2026, 9, 1, tzinfo=UTC),
organization=self.organization,
category=other_category,
is_fully_imported=True,
)
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"game": self.category.kick_id},
)
assert response.status_code == 200
assert [item["kick_id"] for item in response.json()["items"]] == [
self.campaign.kick_id,
]
def test_filters_campaigns_by_computed_status(self) -> None:
"""All supported status filters are computed from campaign dates."""
KickDropCampaign.objects.create(
kick_id="upcoming-campaign",
name="Upcoming Campaign",
starts_at=datetime(2026, 8, 2, tzinfo=UTC),
ends_at=datetime(2026, 9, 1, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
KickDropCampaign.objects.create(
kick_id="expired-campaign",
name="Expired Campaign",
starts_at=datetime(2026, 6, 1, tzinfo=UTC),
ends_at=datetime(2026, 6, 2, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
expected_ids = {
"active": self.campaign.kick_id,
"upcoming": "upcoming-campaign",
"expired": "expired-campaign",
}
with mock.patch(
"kick.api.timezone.now",
return_value=datetime(2026, 7, 15, tzinfo=UTC),
):
for status, expected_id in expected_ids.items():
with self.subTest(status=status):
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": status},
)
assert response.status_code == 200
assert [item["kick_id"] for item in response.json()["items"]] == [
expected_id,
]
def test_status_filters_exclude_campaigns_with_partial_dates(self) -> None:
"""Filtered items never serialize with a contradictory unknown status."""
KickDropCampaign.objects.create(
kick_id="partial-upcoming-campaign",
name="Partial Upcoming Campaign",
starts_at=datetime(2026, 8, 2, tzinfo=UTC),
ends_at=None,
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
KickDropCampaign.objects.create(
kick_id="partial-expired-campaign",
name="Partial Expired Campaign",
starts_at=None,
ends_at=datetime(2026, 6, 2, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
with mock.patch(
"kick.api.timezone.now",
return_value=datetime(2026, 7, 15, tzinfo=UTC),
):
for status in ("upcoming", "expired"):
with self.subTest(status=status):
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": status},
)
assert all(
item["status"] == status for item in response.json()["items"]
)
def test_paginates_campaigns(self) -> None:
"""Page and page_size select a stable result slice."""
for index in range(2):
KickDropCampaign.objects.create(
kick_id=f"pagination-campaign-{index}",
name=f"Pagination Campaign {index}",
starts_at=datetime(2026, 6, index + 1, tzinfo=UTC),
ends_at=datetime(2026, 6, index + 2, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"page": 2, "page_size": 1},
)
data = response.json()
assert response.status_code == 200
assert data["total"] == 3
assert data["page"] == 2
assert data["page_size"] == 1
assert [item["kick_id"] for item in data["items"]] == [
"pagination-campaign-1",
]
def test_clamps_page_size(self) -> None:
"""A caller cannot request an unbounded response page."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"page_size": 1000},
)
assert response.status_code == 200
assert response.json()["page_size"] == 500
def test_clamps_pagination_minimums(self) -> None:
"""Zero and negative pagination values use the first one-item page."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"page": 0, "page_size": -1},
)
assert response.status_code == 200
assert response.json()["page"] == 1
assert response.json()["page_size"] == 1
def test_rejects_non_integer_pagination_values(self) -> None:
"""Malformed pagination values return field-specific validation errors."""
for field in ("page", "page_size"):
with self.subTest(field=field):
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{field: "not-an-integer"},
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", field]
def test_page_beyond_result_set_returns_empty_items(self) -> None:
"""An arbitrarily large page does not become a pathological DB offset."""
page = 10**100
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"page": page},
)
assert response.status_code == 200
assert response.json()["page"] == page
assert response.json()["items"] == []
def test_rejects_invalid_status(self) -> None:
"""Unknown status values return a client error instead of an empty result."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": "running"},
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "status"]
def test_rejects_empty_status(self) -> None:
"""An explicitly empty status is invalid rather than an omitted filter."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": ""},
)
assert response.status_code == 422
def test_rejects_non_get_methods(self) -> None:
"""The campaign feed is a read-only endpoint."""
response = self.client.post(reverse("api-v1:kick-api-v1_list_campaigns"))
assert response.status_code == 405
def test_rejects_invalid_game_id(self) -> None:
"""A non-numeric Kick category ID returns a validation response."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"game": "not-an-id"},
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "game"]
def test_returns_every_eligible_channel(self) -> None:
"""The JSON API does not apply the five-channel RSS display limit."""
for index in range(6):
channel = KickChannel.objects.create(
kick_id=1000 + index,
slug=f"extra-streamer-{index}",
)
self.campaign.channels.add(channel)
response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns"))
channels = response.json()["items"][0]["channels"]
assert len(channels) == 7
def test_returns_every_raw_reward_in_stable_order(self) -> None:
"""Rewards are neither merged nor truncated for downstream importers."""
KickReward.objects.create(
kick_id="reward-api-con",
name="API Reward (Con)",
required_units=30,
campaign=self.campaign,
)
KickReward.objects.create(
kick_id="reward-api-beta",
name="Beta Reward",
required_units=60,
campaign=self.campaign,
)
response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns"))
rewards = response.json()["items"][0]["rewards"]
assert [reward["name"] for reward in rewards] == [
"API Reward",
"API Reward (Con)",
"Beta Reward",
]
def test_empty_channels_remain_an_empty_list(self) -> None:
"""Missing channel data is not presented as proof of global eligibility."""
campaign = KickDropCampaign.objects.create(
kick_id="campaign-without-channels",
name="Campaign Without Channels",
starts_at=datetime(2026, 5, 1, tzinfo=UTC),
ends_at=datetime(2026, 5, 2, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns"))
item = next(
item
for item in response.json()["items"]
if item["kick_id"] == campaign.kick_id
)
assert item["channels"] == []
def test_query_count_does_not_grow_with_campaign_count(self) -> None:
"""Nested serialization avoids per-campaign database queries."""
def select_count() -> int:
with CaptureQueriesContext(connection) as queries:
response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns"))
assert response.status_code == 200
return sum(
query["sql"].lstrip().upper().startswith("SELECT")
for query in queries.captured_queries
)
baseline = select_count()
for index in range(8):
campaign = KickDropCampaign.objects.create(
kick_id=f"query-campaign-{index}",
name=f"Query Campaign {index}",
starts_at=datetime(2026, 4, 1, tzinfo=UTC),
ends_at=datetime(2026, 4, 2, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
KickReward.objects.create(
kick_id=f"query-reward-{index}",
name=f"Query Reward {index}",
required_units=15,
campaign=campaign,
)
scaled = select_count()
assert scaled == baseline
class KickCampaignDetailApiTest(TestCase):
"""Tests for the campaign detail endpoint."""
@classmethod
def setUpTestData(cls) -> None:
cls.organization = KickOrganization.objects.create(
kick_id="org-detail",
name="Detail Org",
)
cls.category = KickCategory.objects.create(
kick_id=200,
name="Detail Game",
slug="detail-game",
)
cls.campaign = KickDropCampaign.objects.create(
kick_id="campaign-detail",
name="Detail Campaign",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=cls.organization,
category=cls.category,
rule_id=1,
rule_name="Watch to earn",
is_fully_imported=True,
)
cls.user = KickUser.objects.create(
kick_id=457,
username="detail-streamer",
profile_picture="https://example.com/detail.png",
)
cls.channel = KickChannel.objects.create(
kick_id=790,
slug="detail-streamer",
user=cls.user,
)
cls.campaign.channels.add(cls.channel)
cls.reward = KickReward.objects.create(
kick_id="reward-detail",
name="Detail Reward",
required_units=45,
campaign=cls.campaign,
category=cls.category,
organization=cls.organization,
)
def test_detail_returns_campaign(self) -> None:
"""A fully imported campaign returns its full serialized payload."""
with mock.patch(
"kick.api.timezone.now",
return_value=datetime(2026, 7, 15, tzinfo=UTC),
):
response = self.client.get(
reverse(
"api-v1:kick-api-v1_get_campaign",
args=[self.campaign.kick_id],
),
)
assert response.status_code == 200
data = response.json()
assert data["kick_id"] == "campaign-detail"
assert data["status"] == "active"
assert data["name"] == "Detail Campaign"
assert len(data["channels"]) == 1
assert len(data["rewards"]) == 1
def test_detail_not_found(self) -> None:
"""A non-existent campaign ID returns 404."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_campaign", args=["nonexistent-id"]),
)
assert response.status_code == 404
def test_detail_not_fully_imported(self) -> None:
"""A campaign that is not fully imported returns 404."""
KickDropCampaign.objects.create(
kick_id="not-imported-detail",
name="Not Imported",
organization=self.organization,
category=self.category,
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
is_fully_imported=False,
)
response = self.client.get(
reverse("api-v1:kick-api-v1_get_campaign", args=["not-imported-detail"]),
)
assert response.status_code == 404
class KickCampaignSearchFilterTest(TestCase):
"""Tests for the search and organization filter on the campaign list."""
@classmethod
def setUpTestData(cls) -> None:
cls.org_a = KickOrganization.objects.create(
kick_id="org-a",
name="Studio Alpha",
)
cls.org_b = KickOrganization.objects.create(kick_id="org-b", name="Beta Corp")
cls.cat = KickCategory.objects.create(kick_id=300, name="Test Game")
cls.camp_alpha = KickDropCampaign.objects.create(
kick_id="alpha-camp",
name="Alpha Drop Event",
starts_at=datetime(2026, 6, 1, tzinfo=UTC),
ends_at=datetime(2026, 7, 1, tzinfo=UTC),
organization=cls.org_a,
category=cls.cat,
is_fully_imported=True,
)
cls.camp_beta = KickDropCampaign.objects.create(
kick_id="beta-camp",
name="Beta Giveaway",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=cls.org_b,
category=cls.cat,
is_fully_imported=True,
)
cls.camp_alpha2 = KickDropCampaign.objects.create(
kick_id="alpha-camp-2",
name="Alpha Second Wave",
starts_at=datetime(2026, 8, 1, tzinfo=UTC),
ends_at=datetime(2026, 9, 1, tzinfo=UTC),
organization=cls.org_a,
category=cls.cat,
is_fully_imported=True,
)
def test_filters_by_organization(self) -> None:
"""An organization kick_id returns only that org's campaigns."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"organization": "org-a"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert sorted(ids) == ["alpha-camp", "alpha-camp-2"]
def test_organization_filter_with_no_matches(self) -> None:
"""An org with no campaigns returns an empty list."""
KickOrganization.objects.create(kick_id="org-empty", name="Empty Org")
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"organization": "org-empty"},
)
assert response.status_code == 200
assert response.json()["items"] == []
def test_searches_by_name(self) -> None:
"""A search term filters campaigns by case-insensitive name match."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"search": "alpha"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert sorted(ids) == ["alpha-camp", "alpha-camp-2"]
def test_search_with_no_matches(self) -> None:
"""A search term with no matches returns an empty list."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"search": "zzzzz"},
)
assert response.status_code == 200
assert response.json()["items"] == []
def test_search_case_insensitive(self) -> None:
"""Search is case-insensitive."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"search": "ALPHA"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert sorted(ids) == ["alpha-camp", "alpha-camp-2"]
def test_organization_filter_with_search(self) -> None:
"""Organization filter and search can be combined."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"organization": "org-a", "search": "Second"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert ids == ["alpha-camp-2"]
class KickOrganizationApiTest(TestCase):
"""Tests for the organization API endpoints."""
@classmethod
def setUpTestData(cls) -> None:
cls.org_a = KickOrganization.objects.create(
kick_id="org-list-a",
name="Alpha Studios",
logo_url="https://example.com/alpha.png",
url="https://example.com/alpha",
)
cls.org_b = KickOrganization.objects.create(
kick_id="org-list-b",
name="Beta Games",
logo_url="https://example.com/beta.png",
url="https://example.com/beta",
)
cat = KickCategory.objects.create(kick_id=400, name="Game")
for org in (cls.org_a, cls.org_b):
KickDropCampaign.objects.create(
kick_id=f"camp-{org.kick_id}",
name=f"{org.name} Campaign",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=org,
category=cat,
is_fully_imported=True,
)
def test_list_returns_organizations(self) -> None:
"""Organization list returns all orgs with campaign counts."""
response = self.client.get(reverse("api-v1:kick-api-v1_list_organizations"))
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
names = [item["name"] for item in data["items"]]
assert names == ["Alpha Studios", "Beta Games"]
assert all(item["campaign_count"] == 1 for item in data["items"])
def test_detail_returns_organization_with_campaigns(self) -> None:
"""Organization detail returns org metadata and its campaigns."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_organization", args=["org-list-a"]),
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Alpha Studios"
assert data["campaign_count"] == 1
assert len(data["campaigns"]) == 1
assert data["campaigns"][0]["name"] == "Alpha Studios Campaign"
def test_detail_not_found(self) -> None:
"""A non-existent org returns 404."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_organization", args=["nonexistent"]),
)
assert response.status_code == 404
def test_list_pagination(self) -> None:
"""Organization list respects pagination."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_organizations"),
{"page": 1, "page_size": 1},
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["total"] == 2
def test_list_search(self) -> None:
"""Organization list supports search by name."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_organizations"),
{"search": "alpha"},
)
assert response.status_code == 200
names = [item["name"] for item in response.json()["items"]]
assert names == ["Alpha Studios"]
class KickCategoryApiTest(TestCase):
"""Tests for the category (game) API endpoints."""
@classmethod
def setUpTestData(cls) -> None:
cls.cat_a = KickCategory.objects.create(
kick_id=500,
name="Action Game",
slug="action-game",
image_url="https://example.com/action.png",
)
cls.cat_b = KickCategory.objects.create(
kick_id=501,
name="Racing Game",
slug="racing-game",
image_url="https://example.com/racing.png",
)
org = KickOrganization.objects.create(
kick_id="cat-org",
name="Category Org",
)
for cat in (cls.cat_a, cls.cat_b):
KickDropCampaign.objects.create(
kick_id=f"camp-{cat.kick_id}",
name=f"{cat.name} Campaign",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=org,
category=cat,
is_fully_imported=True,
)
def test_list_returns_categories(self) -> None:
"""Category list returns all categories with campaign counts."""
response = self.client.get(reverse("api-v1:kick-api-v1_list_games"))
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
names = [item["name"] for item in data["items"]]
assert names == ["Action Game", "Racing Game"]
assert all(item["campaign_count"] == 1 for item in data["items"])
def test_detail_returns_category_with_campaigns(self) -> None:
"""Category detail returns metadata and its campaigns."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_game", args=[500]),
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Action Game"
assert data["campaign_count"] == 1
assert len(data["campaigns"]) == 1
assert data["campaigns"][0]["name"] == "Action Game Campaign"
def test_detail_not_found(self) -> None:
"""A non-existent category returns 404."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_game", args=[99999]),
)
assert response.status_code == 404
def test_list_search(self) -> None:
"""Category list supports search by name."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_games"),
{"search": "action"},
)
assert response.status_code == 200
names = [item["name"] for item in response.json()["items"]]
assert names == ["Action Game"]
class KickStatsApiTest(TestCase):
"""Tests for the stats endpoint."""
@classmethod
def setUpTestData(cls) -> None:
KickOrganization.objects.create(kick_id="stats-org", name="Stats Org")
KickCategory.objects.create(kick_id=600, name="Stats Game")
KickChannel.objects.create(kick_id=800, slug="stats-channel")
def test_stats_returns_aggregate_counts(self) -> None:
"""Stats endpoint returns counts for all entity types."""
response = self.client.get(reverse("api-v1:kick-api-v1_stats"))
assert response.status_code == 200
data = response.json()
assert data["total_campaigns"] == 0
assert data["total_organizations"] == 1
assert data["total_categories"] == 1
assert data["total_channels"] == 1
for key in ("active", "upcoming", "expired", "partial_dates"):
assert key in data

View file

@ -463,7 +463,7 @@ class KickDropCampaignMergedRewardsTest(TestCase):
class ImportKickDropsCommandTest(TestCase): class ImportKickDropsCommandTest(TestCase):
"""Tests for the import_kick_drops management command.""" """Tests for the import_kick_drops management command."""
def _run_command(self, json_payload: dict, **options: Any) -> tuple[str, str]: # ruff:ignore[any-type] def _run_command(self, json_payload: dict, **options: Any) -> tuple[str, str]: # noqa: ANN401
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = json_payload mock_response.json.return_value = json_payload

View file

@ -1,7 +1,6 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.urls import path from django.urls import path
from django.views.generic.base import RedirectView
from kick import views from kick import views
from kick.feeds import KickCampaignAtomFeed from kick.feeds import KickCampaignAtomFeed
@ -30,14 +29,6 @@ urlpatterns: list[URLPattern | URLResolver] = [
view=views.dashboard, view=views.dashboard,
name="dashboard", name="dashboard",
), ),
# Redirect old Kick v1 campaign API to the new combined API
path(
route="api/v1/campaigns/",
view=RedirectView.as_view(
pattern_name="api-v1:kick-api-v1_list_campaigns",
permanent=True,
),
),
# /kick/campaigns/ # /kick/campaigns/
path( path(
route="campaigns/", route="campaigns/",

View file

@ -13,7 +13,7 @@ def main() -> None:
""" """
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
try: try:
from django.core.management import execute_from_command_line # ruff:ignore[import-outside-top-level, unsorted-imports] from django.core.management import execute_from_command_line # noqa: PLC0415
except ImportError as exc: except ImportError as exc:
msg = ( msg = (
"Couldn't import Django. Are you sure it's installed and " "Couldn't import Django. Are you sure it's installed and "

View file

@ -113,7 +113,6 @@ lint.ignore = [
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"**/tests/**" = [ "**/tests/**" = [
"ARG", "ARG",
"D102",
"FBT", "FBT",
"PLR0904", "PLR0904",
"PLR2004", "PLR2004",
@ -125,9 +124,6 @@ lint.ignore = [
"SLF001", "SLF001",
] ]
"**/migrations/**" = ["RUF012"] "**/migrations/**" = ["RUF012"]
"**/api.py" = [
"PLR0913", # Allow many arguments for API endpoint functions
]
[tool.djlint] [tool.djlint]
profile = "django" profile = "django"

View file

@ -203,7 +203,6 @@
<a href="{% url 'core:dataset_backups' %}">Dataset</a> | <a href="{% url 'core:dataset_backups' %}">Dataset</a> |
<a href="https://github.com/sponsors/TheLovinator1">Donate</a> | <a href="https://github.com/sponsors/TheLovinator1">Donate</a> |
<a href="https://github.com/TheLovinator1/ttvdrops">GitHub</a> | <a href="https://github.com/TheLovinator1/ttvdrops">GitHub</a> |
<a href="{% url 'api-v1:openapi-view' %}">API Docs</a> |
<form action="{% url 'core:search' %}" method="get" style="display: inline"> <form action="{% url 'core:search' %}" method="get" style="display: inline">
<input type="search" <input type="search"
name="q" name="q"
@ -221,6 +220,7 @@
<a href="{% url 'twitch:channel_list' %}">Channels</a> | <a href="{% url 'twitch:channel_list' %}">Channels</a> |
<a href="{% url 'twitch:badge_list' %}">Badges</a> | <a href="{% url 'twitch:badge_list' %}">Badges</a> |
<a href="{% url 'twitch:emote_gallery' %}">Emotes</a> | <a href="{% url 'twitch:emote_gallery' %}">Emotes</a> |
<a href="{% url 'twitch:twitch-api-v1:openapi-view' %}">API Docs</a> |
<a href="https://www.twitch.tv/drops/inventory">Inventory</a> <a href="https://www.twitch.tv/drops/inventory">Inventory</a>
<br /> <br />
<strong>Kick</strong> <strong>Kick</strong>
@ -234,7 +234,10 @@
<a href="{% url 'chzzk:campaign_list' %}">Campaigns</a> <a href="{% url 'chzzk:campaign_list' %}">Campaigns</a>
<br /> <br />
<strong>Other sites</strong> <strong>Other sites</strong>
<a href="#">Steam</a> |
<a href="{% url 'youtube:index' %}">YouTube</a> | <a href="{% url 'youtube:index' %}">YouTube</a> |
<a href="#">TikTok</a> |
<a href="#">Discord</a>
</nav> </nav>
<hr /> <hr />
{% if messages %} {% if messages %}

View file

@ -47,7 +47,7 @@
title="Atom feed for Twitch campaigns">[atom]</a> title="Atom feed for Twitch campaigns">[atom]</a>
<a href="{% url 'core:campaign_feed_discord' %}" <a href="{% url 'core:campaign_feed_discord' %}"
title="Discord feed for Twitch campaigns">[discord]</a> title="Discord feed for Twitch campaigns">[discord]</a>
<a href="{% url 'api-v1:twitch-api-v1_list_campaigns' %}" <a href="{% url 'twitch:twitch-api-v1:list_campaigns' %}"
title="Twitch campaigns API">[api]</a> title="Twitch campaigns API">[api]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>

View file

@ -7,121 +7,27 @@
<h1>Dataset Backups</h1> <h1>Dataset Backups</h1>
<section> <section>
<h2>About this dataset</h2> <h2>About this dataset</h2>
<p>This site tracks and publishes open drop campaign data from Twitch and Kick.</p> <p>This site tracks and publishes open Twitch and Kick drop campaign data.</p>
<p> <p>
Campaign metadata in the downloadable datasets and public JSON API is released under The exported datasets on this page are released under <strong>CC0</strong> so you can reuse them freely.
<strong>CC0</strong> so you can reuse it freely. The underlying source data is scraped from Twitch and Kick The underlying source data is scraped from Twitch/Kick APIs and pages, so we do not control the
APIs and pages; we do not control the upstream content and cannot guarantee its accuracy or upstream content and cannot guarantee upstream accuracy or permanence.
permanence. Linked third-party images, logos, and trademarks may remain subject to separate rights.
</p> </p>
<p>Note that some drops have missing or incomplete data due to Twitch API limitations.</p> <p>Note that some drops has missing or incomplete data due to Twitch API limitations.</p>
<p> <p>
Reward campaign data is sourced from Reward campaign data is sourced from
<a href="https://github.com/SunkwiBOT/twitch-drops-api">SunkwiBOT/twitch-drops-api</a>. <a href="https://github.com/SunkwiBOT/twitch-drops-api">SunkwiBOT/twitch-drops-api</a>.
Raw JSON exports are available in the same repository as Raw JSON exports are available in the repository:
<a href="https://github.com/SunkwiBOT/twitch-drops-api/blob/main/drops.json">drops.json</a> <a href="https://github.com/SunkwiBOT/twitch-drops-api/blob/main/drops.json">drops.json</a>
and and
<a href="https://github.com/SunkwiBOT/twitch-drops-api/blob/main/rewards.json">rewards.json</a>. <a href="https://github.com/SunkwiBOT/twitch-drops-api/blob/main/rewards.json">rewards.json</a>.
</p> </p>
<p> <p>
Kick data is fetched directly from the public Need a special format for your workflow or research pipeline?
<a href="https://web.kick.com/api/v1/drops/campaigns">Kick campaigns API</a> <a href="https://github.com/TheLovinator1/ttvdrops/issues">Contact me via GitHub issues</a>
on a recurring schedule.
</p>
<p>
Need a custom format for your workflow or research pipeline?
<a href="https://github.com/TheLovinator1/ttvdrops/issues">Open a GitHub issue</a>
and describe what you need. and describe what you need.
</p> </p>
</section> </section>
<section>
<h2>Kick JSON API</h2>
<p>
Fully imported Kick campaigns are also available through the public paginated
<a href="{% url 'api-v1:kick-api-v1_list_campaigns' %}">Kick campaign JSON API</a>.
Responses include campaign dates, game and organization metadata, rewards, and the full
channel list stored for each campaign.
</p>
<p>
Use <code>page</code> and <code>page_size</code> for pagination. Filter by Kick category ID with
<code>game</code>, or by the computed campaign state with
<code>status=active</code>, <code>status=upcoming</code>, <code>status=expired</code>,
or <code>status=unknown</code>.
Page numbers and page sizes have a minimum of 1, and the maximum page size is 500.
</p>
<pre><code>{
"total": 1,
"page": 1,
"page_size": 100,
"items": [{
"kick_id": "...",
"name": "...",
"status": "active",
"starts_at": "2026-07-01T00:00:00Z",
"ends_at": "2026-08-01T00:00:00Z",
"category": { "kick_id": 123, "name": "..." },
"organization": { "kick_id": "...", "name": "..." },
"channels": [],
"rewards": [{ "name": "...", "required_minutes_watched": 30 }]
}]
}</code></pre>
<p>
Timestamps use ISO 8601 in UTC and may be <code>null</code> when the source does not provide a date. Campaigns with
incomplete dates receive the computed status <code>unknown</code> and are excluded from status-based filtering.
Each item includes every stored channel and raw reward without RSS truncation or reward-name merging.
Consumers can stop pagination when <code>items</code> is empty or when
<code>page * page_size &gt;= total</code>.
</p>
<p>
An empty <code>channels</code> list means no channel restriction data is stored. It does not
by itself guarantee that a campaign is available on every channel.
</p>
</section>
<section>
<h2>Twitch JSON API</h2>
<p>
Fully imported Twitch campaigns are available through the public paginated
<a href="{% url 'api-v1:twitch-api-v1_list_campaigns' %}">Twitch campaign JSON API</a>.
Responses include campaign dates, game and organization metadata, allowed channels, drops,
and rewards for each campaign.
</p>
<p>
Use <code>page</code> and <code>page_size</code> for pagination. Filter by Twitch game ID with
<code>game</code>, or by the computed campaign state with
<code>status=active</code>, <code>status=upcoming</code>, <code>status=expired</code>,
or <code>status=unknown</code>.
Page numbers and page sizes have a minimum of 1, and the maximum page size is 500.
</p>
<pre><code>{
"total": 1,
"page": 1,
"page_size": 100,
"items": [{
"twitch_id": "...",
"name": "...",
"game": { "twitch_id": "...", "display_name": "..." },
"organization": { "twitch_id": "...", "name": "...", "display_name": "..." },
"start_at": "2026-07-01T00:00:00Z",
"end_at": "2026-08-01T00:00:00Z",
"status": "active",
"allowed_channels": [],
"drops": [{ "name": "...", "benefits": ["..."] }]
}]
}</code></pre>
<p>
Timestamps use ISO 8601 in UTC and may be <code>null</code> when the source does not provide a date. Campaigns with
incomplete dates receive the computed status <code>unknown</code> and are excluded from status-based filtering.
</p>
<p>
Additional endpoints include
<a href="{% url 'api-v1:twitch-api-v1_list_games' %}">games</a>,
<a href="{% url 'api-v1:twitch-api-v1_list_organizations' %}">organizations</a>,
<a href="{% url 'api-v1:twitch-api-v1_list_channels' %}">channels</a>,
<a href="{% url 'api-v1:twitch-api-v1_list_reward_campaigns' %}">reward campaigns</a>, and
<a href="{% url 'api-v1:twitch-api-v1_list_badges' %}">chat badges</a>
all public and paginated. Each list endpoint has a corresponding detail view by ID.
</p>
</section>
{% if datasets %} {% if datasets %}
<table> <table>
<thead> <thead>
@ -148,7 +54,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<p>{{ dataset_count }} dataset{% if dataset_count != 1 %}s{% endif %} available.</p> <p>Found {{ dataset_count }} datasets.</p>
{% else %} {% else %}
<p>No dataset backups found.</p> <p>No dataset backups found.</p>
{% endif %} {% endif %}

View file

@ -127,38 +127,6 @@
<p>None ✅</p> <p>None ✅</p>
{% endif %} {% endif %}
</section> </section>
<section>
<h2>Kick Campaigns With Unknown Status ({{ kick_unknown_campaigns|length }})</h2>
{% if kick_unknown_campaigns %}
<ul>
{% for c in kick_unknown_campaigns %}
<li>
<a href="{% url 'kick:campaign_detail' c.kick_id %}">{{ c.name }}</a>
(Game: <a href="{% url 'kick:game_detail' c.category.kick_id %}">{{ c.category.name }}</a>)
- Start: {{ c.starts_at|default:'(none)' }} / End: {{ c.ends_at|default:'(none)' }}
</li>
{% endfor %}
</ul>
{% else %}
<p>None ✅</p>
{% endif %}
</section>
<section>
<h2>Twitch Reward Campaigns With Unknown Status ({{ twitch_reward_unknown_campaigns|length }})</h2>
{% if twitch_reward_unknown_campaigns %}
<ul>
{% for c in twitch_reward_unknown_campaigns %}
<li>
<a href="{% url 'twitch:reward_campaign_detail' c.twitch_id %}">{{ c.name }}</a>
(Game: <a href="{% url 'twitch:game_detail' c.game.twitch_id %}">{{ c.game.display_name }}</a>)
- Start: {{ c.starts_at|default:'(none)' }} / End: {{ c.ends_at|default:'(none)' }}
</li>
{% endfor %}
</ul>
{% else %}
<p>None ✅</p>
{% endif %}
</section>
<section> <section>
<h2>Duplicate Campaign Names Per Game ({{ duplicate_name_campaigns|length }})</h2> <h2>Duplicate Campaign Names Per Game ({{ duplicate_name_campaigns|length }})</h2>
{% if duplicate_name_campaigns %} {% if duplicate_name_campaigns %}

View file

@ -18,7 +18,7 @@
</p> </p>
<p> <p>
Twitch JSON API documentation is available at Twitch JSON API documentation is available at
<a href="{% url 'api-v1:openapi-view' %}">/api/v1/docs/</a>. <a href="{% url 'twitch:twitch-api-v1:openapi-view' %}">/twitch/api/v1/docs</a>.
</p> </p>
<p> <p>
Twitch campaign feeds accept <code>?limit=50</code> to change item count and Twitch campaign feeds accept <code>?limit=50</code> to change item count and

View file

@ -121,7 +121,7 @@
<a href="{% url 'core:game_campaign_feed_discord' campaign.game.twitch_id %}" <a href="{% url 'core:game_campaign_feed_discord' campaign.game.twitch_id %}"
title="Discord feed for {{ campaign.game.display_name }} campaigns">[discord]</a> title="Discord feed for {{ campaign.game.display_name }} campaigns">[discord]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'api-v1:twitch-api-v1_get_campaign' campaign.twitch_id %}" <a href="{% url 'twitch:twitch-api-v1:get_campaign' campaign.twitch_id %}"
title="Twitch campaign API">[api]</a> title="Twitch campaign API">[api]</a>
{% endif %} {% endif %}
</div> </div>

View file

@ -33,7 +33,7 @@
title="Atom feed for all campaigns">[atom]</a> title="Atom feed for all campaigns">[atom]</a>
<a href="{% url 'core:campaign_feed_discord' %}" <a href="{% url 'core:campaign_feed_discord' %}"
title="Discord feed for all campaigns">[discord]</a> title="Discord feed for all campaigns">[discord]</a>
<a href="{% url 'api-v1:twitch-api-v1_list_campaigns' %}" <a href="{% url 'twitch:twitch-api-v1:list_campaigns' %}"
title="Twitch campaigns API">[api]</a> title="Twitch campaigns API">[api]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" <a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"

View file

@ -36,7 +36,7 @@
title="Atom feed for campaigns">[atom]</a> title="Atom feed for campaigns">[atom]</a>
<a href="{% url 'core:campaign_feed_discord' %}" <a href="{% url 'core:campaign_feed_discord' %}"
title="Discord feed for campaigns">[discord]</a> title="Discord feed for campaigns">[discord]</a>
<a href="{% url 'api-v1:twitch-api-v1_list_campaigns' %}" <a href="{% url 'twitch:twitch-api-v1:list_campaigns' %}"
title="Twitch campaigns API">[api]</a> title="Twitch campaigns API">[api]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>

View file

@ -91,7 +91,7 @@
title="Discord feed for {{ game.display_name }} rewards">[discord]</a> title="Discord feed for {{ game.display_name }} rewards">[discord]</a>
</div> </div>
<div> <div>
<a href="{% url 'api-v1:twitch-api-v1_get_game' game.twitch_id %}" <a href="{% url 'twitch:twitch-api-v1:get_game' game.twitch_id %}"
title="Twitch game API">[api]</a> title="Twitch game API">[api]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div> </div>

View file

@ -32,7 +32,7 @@
title="Atom feed for all games">[atom]</a> title="Atom feed for all games">[atom]</a>
<a href="{% url 'core:game_feed_discord' %}" <a href="{% url 'core:game_feed_discord' %}"
title="Discord feed for all games">[discord]</a> title="Discord feed for all games">[discord]</a>
<a href="{% url 'api-v1:twitch-api-v1_list_games' %}" <a href="{% url 'twitch:twitch-api-v1:list_games' %}"
title="Twitch games API">[api]</a> title="Twitch games API">[api]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:export_games_csv' %}" <a href="{% url 'twitch:export_games_csv' %}"

View file

@ -30,7 +30,7 @@
title="Atom feed for all games">[atom]</a> title="Atom feed for all games">[atom]</a>
<a href="{% url 'core:game_feed_discord' %}" <a href="{% url 'core:game_feed_discord' %}"
title="Discord feed for all games">[discord]</a> title="Discord feed for all games">[discord]</a>
<a href="{% url 'api-v1:twitch-api-v1_list_games' %}" <a href="{% url 'twitch:twitch-api-v1:list_games' %}"
title="Twitch games API">[api]</a> title="Twitch games API">[api]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:export_games_csv' %}" <a href="{% url 'twitch:export_games_csv' %}"

View file

@ -15,7 +15,7 @@
title="Atom feed for all organizations">[atom]</a> title="Atom feed for all organizations">[atom]</a>
<a href="{% url 'core:organization_feed_discord' %}" <a href="{% url 'core:organization_feed_discord' %}"
title="Discord feed for all organizations">[discord]</a> title="Discord feed for all organizations">[discord]</a>
<a href="{% url 'api-v1:twitch-api-v1_list_organizations' %}" <a href="{% url 'twitch:twitch-api-v1:list_organizations' %}"
title="Twitch organizations API">[api]</a> title="Twitch organizations API">[api]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:export_organizations_csv' %}" <a href="{% url 'twitch:export_organizations_csv' %}"

View file

@ -123,7 +123,7 @@
title="Atom feed for all reward campaigns">[atom]</a> title="Atom feed for all reward campaigns">[atom]</a>
<a href="{% url 'core:reward_campaign_feed_discord' %}" <a href="{% url 'core:reward_campaign_feed_discord' %}"
title="Discord feed for all reward campaigns">[discord]</a> title="Discord feed for all reward campaigns">[discord]</a>
<a href="{% url 'api-v1:twitch-api-v1_get_reward_campaign' reward_campaign.twitch_id %}" <a href="{% url 'twitch:twitch-api-v1:get_reward_campaign' reward_campaign.twitch_id %}"
title="Twitch reward campaign API">[api]</a> title="Twitch reward campaign API">[api]</a>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
{% if reward_campaign.external_url %} {% if reward_campaign.external_url %}

View file

@ -30,7 +30,7 @@
<div> <div>
<div> <div>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'api-v1:twitch-api-v1_list_reward_campaigns' %}" <a href="{% url 'twitch:twitch-api-v1:list_reward_campaigns' %}"
title="Twitch reward campaigns API">[api]</a> title="Twitch reward campaigns API">[api]</a>
</div> </div>
<div> <div>

View file

@ -16,7 +16,7 @@ import argparse
import hashlib import hashlib
import json import json
import shutil import shutil
import subprocess # ruff:ignore[suspicious-subprocess-import] import subprocess # noqa: S404
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -60,7 +60,7 @@ def run_git(git_dir: str, *args: str, check: bool = True) -> str:
if not git: if not git:
msg = "Git executable not found in PATH." msg = "Git executable not found in PATH."
raise FileNotFoundError(msg) raise FileNotFoundError(msg)
result: subprocess.CompletedProcess[str] = subprocess.run( # ruff:ignore[subprocess-without-shell-equals-true] result: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603
[git, "--git-dir", git_dir, *args], [git, "--git-dir", git_dir, *args],
capture_output=True, capture_output=True,
text=True, text=True,

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import datetime # ruff:ignore[typing-only-standard-library-import] import datetime # noqa: TC003
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Literal from typing import Literal
@ -9,7 +9,7 @@ from django.db import models
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from ninja import Router from ninja import NinjaAPI
from ninja import Schema from ninja import Schema
from twitch.models import Channel from twitch.models import Channel
@ -28,7 +28,7 @@ if TYPE_CHECKING:
from twitch.models import DropBenefit from twitch.models import DropBenefit
from twitch.models import TimeBasedDrop from twitch.models import TimeBasedDrop
V1StatusFilter = Literal["active", "upcoming", "expired", "unknown"] V1StatusFilter = Literal["active", "upcoming", "expired"]
DEFAULT_PAGE_SIZE = 100 DEFAULT_PAGE_SIZE = 100
MAX_PAGE_SIZE = 500 MAX_PAGE_SIZE = 500
@ -253,7 +253,11 @@ class V1ChatBadgeSetListSchema(V1PaginationSchema):
items: list[V1ChatBadgeSetSchema] items: list[V1ChatBadgeSetSchema]
api = Router() api = NinjaAPI(
title="TTVDrops Twitch API",
version="1.0.0",
urls_namespace="twitch:twitch-api-v1",
)
def _paginate[ModelT: models.Model]( def _paginate[ModelT: models.Model](
@ -301,11 +305,6 @@ def _apply_status_filter[ModelT: models.Model](
return queryset.filter(**{f"{start_field}__gt": now}) return queryset.filter(**{f"{start_field}__gt": now})
if status == "expired": if status == "expired":
return queryset.filter(**{f"{end_field}__lt": now}) return queryset.filter(**{f"{end_field}__lt": now})
if status == "unknown":
return queryset.filter(
models.Q(**{f"{start_field}__isnull": True})
| models.Q(**{f"{end_field}__isnull": True}),
)
return queryset return queryset

View file

@ -15,7 +15,7 @@ class TwitchConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "twitch" name = "twitch"
def ready(self) -> None: # ruff:ignore[undocumented-public-method] def ready(self) -> None: # noqa: D102
logger: logging.Logger = logging.getLogger("ttvdrops.apps") logger: logging.Logger = logging.getLogger("ttvdrops.apps")
# Patch FieldFile.open to swallow FileNotFoundError and provide # Patch FieldFile.open to swallow FileNotFoundError and provide
@ -39,18 +39,18 @@ class TwitchConfig(AppConfig):
# Register post_save signal handlers that dispatch image download tasks # Register post_save signal handlers that dispatch image download tasks
# when new Twitch records are created. # when new Twitch records are created.
from django.db.models.signals import m2m_changed # ruff:ignore[unsorted-imports, import-outside-top-level] from django.db.models.signals import m2m_changed # noqa: I001, PLC0415
from django.db.models.signals import post_save # ruff:ignore[import-outside-top-level] from django.db.models.signals import post_save # noqa: PLC0415
from twitch.models import DropBenefit # ruff:ignore[import-outside-top-level] from twitch.models import DropBenefit # noqa: PLC0415
from twitch.models import DropCampaign # ruff:ignore[import-outside-top-level] from twitch.models import DropCampaign # noqa: PLC0415
from twitch.models import Game # ruff:ignore[import-outside-top-level] from twitch.models import Game # noqa: PLC0415
from twitch.models import RewardCampaign # ruff:ignore[import-outside-top-level] from twitch.models import RewardCampaign # noqa: PLC0415
from twitch.signals import on_drop_benefit_saved # ruff:ignore[import-outside-top-level] from twitch.signals import on_drop_benefit_saved # noqa: PLC0415
from twitch.signals import on_drop_campaign_allow_channels_changed # ruff:ignore[import-outside-top-level] from twitch.signals import on_drop_campaign_allow_channels_changed # noqa: PLC0415
from twitch.signals import on_drop_campaign_saved # ruff:ignore[import-outside-top-level] from twitch.signals import on_drop_campaign_saved # noqa: PLC0415
from twitch.signals import on_game_saved # ruff:ignore[import-outside-top-level] from twitch.signals import on_game_saved # noqa: PLC0415
from twitch.signals import on_reward_campaign_saved # ruff:ignore[import-outside-top-level] from twitch.signals import on_reward_campaign_saved # noqa: PLC0415
post_save.connect(on_game_saved, sender=Game) post_save.connect(on_game_saved, sender=Game)
post_save.connect(on_drop_campaign_saved, sender=DropCampaign) post_save.connect(on_drop_campaign_saved, sender=DropCampaign)

View file

@ -22,7 +22,7 @@ from django.utils.html import format_html_join
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
from core.base_url import build_absolute_uri from core.base_url import build_absolute_uri
from core.base_url import get_current_site # ruff:ignore[redefined-while-unused] from core.base_url import get_current_site # noqa: F811
from twitch.models import Channel from twitch.models import Channel
from twitch.models import ChatBadge from twitch.models import ChatBadge
from twitch.models import DropCampaign from twitch.models import DropCampaign
@ -185,9 +185,9 @@ class TTVDropsBaseFeed(Feed):
Returns: Returns:
SyndicationFeed: The feed generator instance with the correct site and URL context for absolute URL generation. SyndicationFeed: The feed generator instance with the correct site and URL context for absolute URL generation.
""" """
# TODO(TheLovinator): Refactor to avoid this mess. # ruff:ignore[missing-todo-link] # TODO(TheLovinator): Refactor to avoid this mess. # noqa: TD003
try: try:
from django.contrib.sites import shortcuts as sites_shortcuts # ruff:ignore[unsorted-imports, import-outside-top-level] from django.contrib.sites import shortcuts as sites_shortcuts # noqa: I001, PLC0415
except ImportError: except ImportError:
sites_shortcuts = None sites_shortcuts = None
@ -575,7 +575,7 @@ def generate_channels_html(item: Model, *, hide_paid: bool = False) -> list[Safe
) )
if "twitch-chat-badges-guide" in getattr(game, "details_url", ""): if "twitch-chat-badges-guide" in getattr(game, "details_url", ""):
# TODO(TheLovinator): Improve detection of global emotes # ruff:ignore[missing-todo-link] # TODO(TheLovinator): Improve detection of global emotes # noqa: TD003
parts.append( parts.append(
format_html( format_html(
"{}", "{}",
@ -1177,7 +1177,7 @@ class GameCampaignFeed(TTVDropsBaseFeed):
self._hide_paid = _query_bool(request, "hide_paid") self._hide_paid = _query_bool(request, "hide_paid")
return super().__call__(request, *args, **kwargs) return super().__call__(request, *args, **kwargs)
def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # ruff:ignore[unused-method-argument] def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002
"""Retrieve the Game instance for the given Twitch ID. """Retrieve the Game instance for the given Twitch ID.
Returns: Returns:
@ -1727,7 +1727,7 @@ class GameRewardCampaignFeed(TTVDropsBaseFeed):
self._limit = None self._limit = None
return super().__call__(request, *args, **kwargs) return super().__call__(request, *args, **kwargs)
def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # ruff:ignore[unused-method-argument] def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002
"""Retrieve the Game instance for the given Twitch ID. """Retrieve the Game instance for the given Twitch ID.
Returns: Returns:

View file

@ -14,7 +14,7 @@ class Command(BaseCommand):
help = "Backfill image dimensions for existing cached images" help = "Backfill image dimensions for existing cached images"
def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument, too-many-statements] def handle(self, *args, **options) -> None: # noqa: ARG002, PLR0915
"""Execute the command.""" """Execute the command."""
total_updated = 0 total_updated = 0

View file

@ -2,7 +2,7 @@ import io
import json import json
import os import os
import shutil import shutil
import subprocess # ruff:ignore[suspicious-subprocess-import] import subprocess # noqa: S404
from compression import zstd from compression import zstd
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -191,11 +191,11 @@ def _write_table_rows(
connection: SQLite connection. connection: SQLite connection.
table: Table name. table: Table name.
""" """
cursor = connection.execute(f'SELECT * FROM "{table}"') # ruff:ignore[hardcoded-sql-expression] cursor = connection.execute(f'SELECT * FROM "{table}"') # noqa: S608
columns = [description[0] for description in cursor.description] columns = [description[0] for description in cursor.description]
for row in cursor.fetchall(): for row in cursor.fetchall():
values = ", ".join(_sql_literal(row[idx]) for idx in range(len(columns))) values = ", ".join(_sql_literal(row[idx]) for idx in range(len(columns)))
handle.write(f'INSERT INTO "{table}" VALUES ({values});\n') # ruff:ignore[hardcoded-sql-expression] handle.write(f'INSERT INTO "{table}" VALUES ({values});\n') # noqa: S608
def _write_indexes( def _write_indexes(
@ -268,7 +268,7 @@ def _write_postgres_dump(output_path: Path, tables: list[str]) -> None:
for table in tables: for table in tables:
cmd.extend(["-t", f"public.{table}"]) cmd.extend(["-t", f"public.{table}"])
process = subprocess.Popen( # ruff:ignore[subprocess-without-shell-equals-true] process = subprocess.Popen( # noqa: S603
cmd, cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
@ -339,7 +339,7 @@ def _write_json_dump(output_path: Path, tables: list[str]) -> None:
data: dict[str, list[dict]] = {} data: dict[str, list[dict]] = {}
with django_connection.cursor() as cursor: with django_connection.cursor() as cursor:
for table in tables: for table in tables:
cursor.execute(f'SELECT * FROM "{table}"') # ruff:ignore[hardcoded-sql-expression] cursor.execute(f'SELECT * FROM "{table}"') # noqa: S608
columns: list[str] = [col[0] for col in cursor.description] columns: list[str] = [col[0] for col in cursor.description]
rows = cursor.fetchall() rows = cursor.fetchall()
data[table] = [dict(zip(columns, row, strict=False)) for row in rows] data[table] = [dict(zip(columns, row, strict=False)) for row in rows]

View file

@ -403,7 +403,7 @@ def repair_partially_broken_json(raw_text: str) -> str:
wrapped: str = f"[{raw_text}]" wrapped: str = f"[{raw_text}]"
wrapped_data = json.loads(wrapped) wrapped_data = json.loads(wrapped)
# Validate that all items look like GraphQL responses # Validate that all items look like GraphQL responses
if isinstance(wrapped_data, list) and wrapped_data: # ruff:ignore[collapsible-if] 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( if all(
isinstance(item, dict) and ("data" in item or "extensions" in item) isinstance(item, dict) and ("data" in item or "extensions" in item)
@ -452,7 +452,7 @@ def repair_partially_broken_json(raw_text: str) -> str:
valid_lines: list[dict[str, Any]] = [] valid_lines: list[dict[str, Any]] = []
for line in lines: for line in lines:
line: str = line.strip() # ruff:ignore[redefined-loop-name] 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, logging=False) fixed_line: str = json_repair.repair_json(line, logging=False)
@ -749,7 +749,7 @@ class Command(BaseCommand):
return channel_obj return channel_obj
def process_responses( # ruff:ignore[too-many-statements] def process_responses( # noqa: PLR0915
self, self,
responses: list[dict[str, Any]], responses: list[dict[str, Any]],
file_path: Path, file_path: Path,
@ -1180,7 +1180,7 @@ class Command(BaseCommand):
msg: str = f"Path does not exist: {input_path}" msg: str = f"Path does not exist: {input_path}"
raise CommandError(msg) raise CommandError(msg)
def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument] def handle(self, *args, **options) -> None: # noqa: ARG002
"""Main entry point for the command.""" """Main entry point for the command."""
colorama_init(autoreset=True) colorama_init(autoreset=True)

View file

@ -49,7 +49,7 @@ class Command(BaseCommand):
), ),
) )
def handle(self, *args: Any, **options: Any) -> None: # ruff:ignore[any-type, unused-method-argument] def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002
"""Execute the command to detach the organization and optionally re-import data. """Execute the command to detach the organization and optionally re-import data.
Args: Args:

View file

@ -39,7 +39,7 @@ class Command(BaseCommand):
help="Re-download even if a local box art file already exists.", help="Re-download even if a local box art file already exists.",
) )
def handle( # ruff:ignore[too-many-locals, too-many-statements] def handle( # noqa: PLR0914, PLR0915
self, self,
*_args: str, *_args: str,
**options: str | bool | int | None, **options: str | bool | int | None,

View file

@ -50,7 +50,7 @@ class Command(BaseCommand):
help="Twitch Access Token (optional - will be obtained automatically if not provided)", help="Twitch Access Token (optional - will be obtained automatically if not provided)",
) )
def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument] def handle(self, *args, **options) -> None: # noqa: ARG002
"""Main entry point for the command. """Main entry point for the command.
Raises: Raises:

View file

@ -27,7 +27,7 @@ import hashlib
import json import json
import os import os
import pathlib import pathlib
import subprocess # ruff:ignore[suspicious-subprocess-import] import subprocess # noqa: S404
import tempfile import tempfile
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
@ -194,7 +194,7 @@ class Command(BaseCommand):
), ),
) )
def handle(self, *args: Any, **options: Any) -> None: # ruff:ignore[any-type, unused-method-argument, too-many-locals, too-many-statements] def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002, PLR0914, PLR0915
"""Main entry point for the command. """Main entry point for the command.
Raises: Raises:
@ -301,7 +301,7 @@ class Command(BaseCommand):
crash_on_error=crash_on_error, crash_on_error=crash_on_error,
skip_existing=skip_existing, skip_existing=skip_existing,
) )
except Exception as exc: # ruff:ignore[blind-except] except Exception as exc: # noqa: BLE001
msg = f"Failed to import drops.json: {exc}" msg = f"Failed to import drops.json: {exc}"
self.stderr.write(self.style.ERROR(msg)) self.stderr.write(self.style.ERROR(msg))
errors.append(msg) errors.append(msg)
@ -315,7 +315,7 @@ class Command(BaseCommand):
crash_on_error=crash_on_error, crash_on_error=crash_on_error,
skip_existing=skip_existing, skip_existing=skip_existing,
) )
except Exception as exc: # ruff:ignore[blind-except] except Exception as exc: # noqa: BLE001
msg = f"Failed to import rewards.json: {exc}" msg = f"Failed to import rewards.json: {exc}"
self.stderr.write(self.style.ERROR(msg)) self.stderr.write(self.style.ERROR(msg))
errors.append(msg) errors.append(msg)
@ -353,7 +353,7 @@ class Command(BaseCommand):
# Historical import via git clone # Historical import via git clone
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _process_historical( # ruff:ignore[too-many-arguments] def _process_historical( # noqa: PLR0913
self, self,
source: str, source: str,
*, *,
@ -423,7 +423,7 @@ class Command(BaseCommand):
max_commits=max_commits, max_commits=max_commits,
skip_existing=skip_existing, skip_existing=skip_existing,
) )
except Exception as exc: # ruff:ignore[blind-except] except Exception as exc: # noqa: BLE001
errors.append(str(exc)) errors.append(str(exc))
finally: finally:
if own_clone: if own_clone:
@ -431,7 +431,7 @@ class Command(BaseCommand):
return drops_count, rewards_count, errors return drops_count, rewards_count, errors
def _import_historical_file( # ruff:ignore[too-many-arguments] def _import_historical_file( # noqa: PLR0913
self, self,
git_dir: str, git_dir: str,
file_path: str, file_path: str,
@ -581,7 +581,7 @@ class Command(BaseCommand):
Stdout of the git command. Stdout of the git command.
""" """
cmd = ["git", "--git-dir", git_dir, *args] cmd = ["git", "--git-dir", git_dir, *args]
result = subprocess.run(cmd, capture_output=True, text=True, check=check) # ruff:ignore[subprocess-without-shell-equals-true] result = subprocess.run(cmd, capture_output=True, text=True, check=check) # noqa: S603
return result.stdout return result.stdout
def _rmtree(self, path: str) -> None: def _rmtree(self, path: str) -> None:
@ -659,7 +659,7 @@ class Command(BaseCommand):
) )
@staticmethod @staticmethod
def _strip_typename(data: Any) -> Any: # ruff:ignore[any-type] def _strip_typename(data: Any) -> Any: # noqa: ANN401
"""Recursively remove ``__typename`` keys from parsed JSON data. """Recursively remove ``__typename`` keys from parsed JSON data.
Old commits in the repo included GraphQL ``__typename`` metadata which Old commits in the repo included GraphQL ``__typename`` metadata which
@ -683,7 +683,7 @@ class Command(BaseCommand):
return [Command._strip_typename(item) for item in data] return [Command._strip_typename(item) for item in data]
return data return data
def _generate_report( # ruff:ignore[too-many-locals, too-many-statements] def _generate_report( # noqa: PLR0914, PLR0915
self, self,
drops_url: str, drops_url: str,
rewards_url: str, rewards_url: str,
@ -707,7 +707,7 @@ class Command(BaseCommand):
Raises: Raises:
ValidationError: If any item fails schema validation and crash_on_error is True. ValidationError: If any item fails schema validation and crash_on_error is True.
""" """
from collections import Counter # ruff:ignore[import-outside-top-level] from collections import Counter # noqa: PLC0415
self.stdout.write("=" * 60) self.stdout.write("=" * 60)
self.stdout.write(self.style.SUCCESS("COMPARISON REPORT")) self.stdout.write(self.style.SUCCESS("COMPARISON REPORT"))
@ -719,14 +719,14 @@ class Command(BaseCommand):
# Track differences for field-level comparison # Track differences for field-level comparison
sample_limit = 5 sample_limit = 5
for label, raw_url, model_cls, id_field in [ # ruff:ignore[too-many-nested-blocks] for label, raw_url, model_cls, id_field in [ # noqa: PLR1702
("drops", drops_url, DropCampaign, "twitch_id"), ("drops", drops_url, DropCampaign, "twitch_id"),
("rewards", rewards_url, RewardCampaign, "twitch_id"), ("rewards", rewards_url, RewardCampaign, "twitch_id"),
]: ]:
self.stdout.write(f"\n--- {label.upper()} ---") self.stdout.write(f"\n--- {label.upper()} ---")
try: try:
raw_data = self._fetch_json(raw_url, f"{label}.json") raw_data = self._fetch_json(raw_url, f"{label}.json")
except Exception as exc: # ruff:ignore[blind-except] except Exception as exc: # noqa: BLE001
self.stderr.write( self.stderr.write(
self.style.ERROR(f" Failed to fetch {label}.json: {exc}"), self.style.ERROR(f" Failed to fetch {label}.json: {exc}"),
) )
@ -894,7 +894,7 @@ class Command(BaseCommand):
self.stdout.write("\n" + "=" * 60) self.stdout.write("\n" + "=" * 60)
def _parse_and_import_drops( # ruff:ignore[too-many-arguments] def _parse_and_import_drops( # noqa: PLR0913
self, self,
raw_data: list[dict[str, Any]], raw_data: list[dict[str, Any]],
source: str, source: str,
@ -976,7 +976,7 @@ class Command(BaseCommand):
return total_campaigns return total_campaigns
def _process_sunkwibot_reward( # ruff:ignore[too-many-statements] def _process_sunkwibot_reward( # noqa: PLR0915
self, self,
reward: SunkwiBotRewardSchema, reward: SunkwiBotRewardSchema,
game_obj: Game, game_obj: Game,
@ -1133,7 +1133,7 @@ class Command(BaseCommand):
self, self,
tbd_schema: SunkwiBotTimeBasedDropSchema, tbd_schema: SunkwiBotTimeBasedDropSchema,
campaign_obj: DropCampaign, campaign_obj: DropCampaign,
source: str, # ruff:ignore[unused-method-argument] source: str, # noqa: ARG002
*, *,
verbose: bool, verbose: bool,
) -> None: ) -> None:
@ -1335,7 +1335,7 @@ class Command(BaseCommand):
skip_existing=skip_existing, skip_existing=skip_existing,
) )
def _parse_and_import_rewards( # ruff:ignore[too-many-arguments] def _parse_and_import_rewards( # noqa: PLR0913
self, self,
raw_data: list[dict[str, Any]], raw_data: list[dict[str, Any]],
source: str, source: str,
@ -1563,12 +1563,12 @@ class Command(BaseCommand):
self._org_cache[twitch_id] = org_obj self._org_cache[twitch_id] = org_obj
return org_obj return org_obj
def _get_or_create_game( # ruff:ignore[too-many-arguments] def _get_or_create_game( # noqa: PLR0913
self, self,
twitch_id: str, twitch_id: str,
display_name: str, display_name: str,
box_art_url: str, box_art_url: str,
source: str, # ruff:ignore[unused-method-argument] source: str, # noqa: ARG002
*, *,
verbose: bool, verbose: bool,
org_obj: Organization | None = None, org_obj: Organization | None = None,

View file

@ -37,7 +37,7 @@ class Command(BaseCommand):
help="Path to directory to watch for JSON files", help="Path to directory to watch for JSON files",
) )
def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument] def handle(self, *args, **options) -> None: # noqa: ARG002
"""Main entry point for the watch command. """Main entry point for the watch command.
Args: Args:
@ -87,7 +87,7 @@ class Command(BaseCommand):
importer_command: An instance of the BetterImportDropsCommand to handle the import logic. importer_command: An instance of the BetterImportDropsCommand to handle the import logic.
watch_path: The directory path to watch for JSON files. watch_path: The directory path to watch for JSON files.
""" """
# TODO(TheLovinator): Implement actual file watching using watchdog or similar library. # ruff:ignore[missing-todo-link] # TODO(TheLovinator): Implement actual file watching using watchdog or similar library. # noqa: TD003
json_files: list[Path] = [ json_files: list[Path] = [
f for f in watch_path.iterdir() if f.suffix == ".json" and f.is_file() f for f in watch_path.iterdir() if f.suffix == ".json" and f.is_file()
] ]

View file

@ -5,7 +5,7 @@ from django.db import migrations
from django.db import models from django.db import models
def migrate_operation_name_to_list(apps, schema_editor) -> None: # ruff:ignore[missing-type-function-argument] def migrate_operation_name_to_list(apps, schema_editor) -> None: # noqa: ANN001
"""Convert operation_name string values to operation_names list.""" """Convert operation_name string values to operation_names list."""
DropCampaign = apps.get_model("twitch", "DropCampaign") DropCampaign = apps.get_model("twitch", "DropCampaign")
for campaign in DropCampaign.objects.all(): for campaign in DropCampaign.objects.all():
@ -14,7 +14,7 @@ def migrate_operation_name_to_list(apps, schema_editor) -> None: # ruff:ignore[
campaign.save(update_fields=["operation_names"]) campaign.save(update_fields=["operation_names"])
def reverse_operation_names_to_string(apps, schema_editor) -> None: # ruff:ignore[missing-type-function-argument] def reverse_operation_names_to_string(apps, schema_editor) -> None: # noqa: ANN001
"""Convert operation_names list back to operation_name string (first item only).""" """Convert operation_names list back to operation_name string (first item only)."""
DropCampaign = apps.get_model("twitch", "DropCampaign") DropCampaign = apps.get_model("twitch", "DropCampaign")
for campaign in DropCampaign.objects.all(): for campaign in DropCampaign.objects.all():

View file

@ -1,7 +1,7 @@
from django.db import migrations from django.db import migrations
def mark_all_drops_fully_imported(apps, schema_editor) -> None: # ruff:ignore[missing-type-function-argument] def mark_all_drops_fully_imported(apps, schema_editor) -> None: # noqa: ANN001
"""Marks all existing DropCampaigns as fully imported. """Marks all existing DropCampaigns as fully imported.
This was needed to ensure that the Twitch API view only returns campaigns that are ready for display. This was needed to ensure that the Twitch API view only returns campaigns that are ready for display.

View file

@ -137,7 +137,7 @@ class Game(auto_prefetch.Model):
default="", default="",
) )
box_art = models.URLField( # ruff:ignore[django-nullable-model-string-field] box_art = models.URLField( # noqa: DJ001
verbose_name="Box art URL", verbose_name="Box art URL",
max_length=500, max_length=500,
blank=True, blank=True,
@ -231,12 +231,12 @@ class Game(auto_prefetch.Model):
@property @property
def organizations(self) -> models.QuerySet[Organization]: def organizations(self) -> models.QuerySet[Organization]:
"""Orgs that own games with campaigns for this game.""" """Return orgs that own games with campaigns for this game."""
return Organization.objects.filter(games__drop_campaigns__game=self).distinct() return Organization.objects.filter(games__drop_campaigns__game=self).distinct()
@property @property
def get_game_name(self) -> str: def get_game_name(self) -> str:
"""The best available name for the game.""" """Return the best available name for the game."""
if self.display_name: if self.display_name:
return self.display_name return self.display_name
if self.name: if self.name:
@ -247,8 +247,8 @@ class Game(auto_prefetch.Model):
@property @property
def twitch_directory_url(self) -> str: def twitch_directory_url(self) -> str:
"""Twitch directory URL with drops filter when slug exists.""" """Return Twitch directory URL with drops filter when slug exists."""
# TODO(TheLovinator): If no slug, get from Twitch API or IGDB? # ruff:ignore[missing-todo-link] # TODO(TheLovinator): If no slug, get from Twitch API or IGDB? # noqa: TD003
if self.slug: if self.slug:
return f"https://www.twitch.tv/directory/category/{self.slug}?filter=drops" return f"https://www.twitch.tv/directory/category/{self.slug}?filter=drops"
@ -256,7 +256,7 @@ class Game(auto_prefetch.Model):
@property @property
def box_art_best_url(self) -> str: def box_art_best_url(self) -> str:
"""The best available URL for the game's box art (local first).""" """Return the best available URL for the game's box art (local first)."""
try: try:
if self.box_art_file and getattr(self.box_art_file, "url", None): if self.box_art_file and getattr(self.box_art_file, "url", None):
return self.box_art_file.url return self.box_art_file.url
@ -271,7 +271,7 @@ class Game(auto_prefetch.Model):
@property @property
def dashboard_box_art_url(self) -> str: def dashboard_box_art_url(self) -> str:
"""Dashboard-safe box art URL without touching deferred image fields.""" """Return dashboard-safe box art URL without touching deferred image fields."""
return normalize_twitch_box_art_url(self.box_art or "") return normalize_twitch_box_art_url(self.box_art or "")
@classmethod @classmethod
@ -570,7 +570,7 @@ class Channel(auto_prefetch.Model):
@property @property
def preferred_name(self) -> str: def preferred_name(self) -> str:
"""Display name fallback used by channel-facing pages.""" """Return display name fallback used by channel-facing pages."""
return self.display_name or self.name or self.twitch_id return self.display_name or self.name or self.twitch_id
def detail_description(self, total_campaigns: int) -> str: def detail_description(self, total_campaigns: int) -> str:
@ -580,7 +580,7 @@ class Channel(auto_prefetch.Model):
# MARK: DropCampaign # MARK: DropCampaign
class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods] class DropCampaign(auto_prefetch.Model): # noqa: PLR0904
"""Represents a Twitch drop campaign.""" """Represents a Twitch drop campaign."""
twitch_id = models.TextField( twitch_id = models.TextField(
@ -801,10 +801,6 @@ class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods]
queryset = queryset.filter(start_at__gt=now) queryset = queryset.filter(start_at__gt=now)
elif status == "expired": elif status == "expired":
queryset = queryset.filter(end_at__lt=now) queryset = queryset.filter(end_at__lt=now)
elif status == "unknown":
queryset = queryset.filter(
Q(start_at__isnull=True) | Q(end_at__isnull=True),
)
return queryset return queryset
@classmethod @classmethod
@ -1270,7 +1266,7 @@ class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods]
@property @property
def clean_name(self) -> str: def clean_name(self) -> str:
"""The campaign name without the game name prefix. """Return the campaign name without the game name prefix.
Examples: Examples:
"Ravendawn - July 2" -> "July 2" "Ravendawn - July 2" -> "July 2"
@ -1302,7 +1298,7 @@ class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods]
@property @property
def single_reward_benefit(self) -> DropBenefit | None: def single_reward_benefit(self) -> DropBenefit | None:
"""The only unique reward benefit for this campaign, if it has one.""" """Return the only unique reward benefit for this campaign, if it has one."""
benefits: list[DropBenefit] = [] benefits: list[DropBenefit] = []
seen_benefit_keys: set[int | str] = set() seen_benefit_keys: set[int | str] = set()
@ -1321,7 +1317,7 @@ class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods]
@property @property
def single_reward_image_best_url(self) -> str: def single_reward_image_best_url(self) -> str:
"""The best image URL for a campaign that has exactly one reward.""" """Return the best image URL for a campaign that has exactly one reward."""
benefit: DropBenefit | None = self.single_reward_benefit benefit: DropBenefit | None = self.single_reward_benefit
if not benefit: if not benefit:
return "" return ""
@ -1329,12 +1325,12 @@ class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods]
@property @property
def meta_image_url(self) -> str: def meta_image_url(self) -> str:
"""The preferred campaign image URL for SEO metadata.""" """Return the preferred campaign image URL for SEO metadata."""
return self.single_reward_image_best_url or self.image_best_url return self.single_reward_image_best_url or self.image_best_url
@property @property
def image_best_url(self) -> str: def image_best_url(self) -> str:
"""The best URL for the campaign image. """Return the best URL for the campaign image.
Priority: Priority:
1. Local cached image file 1. Local cached image file
@ -1361,7 +1357,7 @@ class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods]
@property @property
def listing_image_url(self) -> str: def listing_image_url(self) -> str:
"""A campaign image URL optimized for list views. """Return a campaign image URL optimized for list views.
This intentionally avoids traversing drops/benefits to prevent N+1 queries This intentionally avoids traversing drops/benefits to prevent N+1 queries
in list pages that render many campaigns. in list pages that render many campaigns.
@ -1375,7 +1371,7 @@ class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods]
@property @property
def dashboard_image_url(self) -> str: def dashboard_image_url(self) -> str:
"""Dashboard-safe campaign or single-reward image URL.""" """Return dashboard-safe campaign or single-reward image URL."""
benefit: DropBenefit | None = self.single_reward_benefit benefit: DropBenefit | None = self.single_reward_benefit
if benefit and benefit.image_asset_url: if benefit and benefit.image_asset_url:
return benefit.image_asset_url return benefit.image_asset_url
@ -1383,7 +1379,7 @@ class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods]
@property @property
def duration_iso(self) -> str: def duration_iso(self) -> str:
"""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 This is used for the <time> element's datetime attribute to provide
machine-readable duration. If start_at or end_at is missing, returns machine-readable duration. If start_at or end_at is missing, returns
@ -1421,7 +1417,7 @@ class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods]
@property @property
def sorted_benefits(self) -> list[DropBenefit]: def sorted_benefits(self) -> list[DropBenefit]:
"""A sorted list of benefits for the campaign.""" """Return a sorted list of benefits for the campaign."""
benefits: list[DropBenefit] = [] benefits: list[DropBenefit] = []
for drop in self.time_based_drops.all(): # pyright: ignore[reportAttributeAccessIssue] for drop in self.time_based_drops.all(): # pyright: ignore[reportAttributeAccessIssue]
benefits.extend(drop.benefits.all()) # pyright: ignore[reportAttributeAccessIssue] benefits.extend(drop.benefits.all()) # pyright: ignore[reportAttributeAccessIssue]
@ -1570,7 +1566,7 @@ class DropBenefit(auto_prefetch.Model):
@property @property
def image_best_url(self) -> str: def image_best_url(self) -> str:
"""The best URL for the benefit image (local first).""" """Return the best URL for the benefit image (local first)."""
try: try:
if self.image_file: if self.image_file:
file_name: str = getattr(self.image_file, "name", "") file_name: str = getattr(self.image_file, "name", "")
@ -1917,7 +1913,7 @@ class RewardCampaign(auto_prefetch.Model):
@property @property
def image_best_url(self) -> str: def image_best_url(self) -> str:
"""The best URL for the reward campaign image (local first).""" """Return the best URL for the reward campaign image (local first)."""
try: try:
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
@ -2022,14 +2018,14 @@ class ChatBadge(auto_prefetch.Model):
verbose_name="Description", verbose_name="Description",
) )
click_action = models.TextField( # ruff:ignore[django-nullable-model-string-field] click_action = models.TextField( # noqa: DJ001
help_text="The action to take when clicking on the badge (e.g., 'visit_url').", help_text="The action to take when clicking on the badge (e.g., 'visit_url').",
verbose_name="Click Action", verbose_name="Click Action",
blank=True, blank=True,
null=True, null=True,
) )
click_url = models.URLField( # ruff:ignore[django-nullable-model-string-field] click_url = models.URLField( # noqa: DJ001
help_text="The URL to navigate to when clicking on the badge.", help_text="The URL to navigate to when clicking on the badge.",
verbose_name="Click URL", verbose_name="Click URL",
max_length=500, max_length=500,

View file

@ -8,11 +8,11 @@ from django.db.models import Count
logger = logging.getLogger("ttvdrops.signals") logger = logging.getLogger("ttvdrops.signals")
def _dispatch(task_fn: Any, pk: int) -> None: # ruff:ignore[any-type] def _dispatch(task_fn: Any, pk: int) -> None: # noqa: ANN401
"""Dispatch a Celery task, logging rather than raising when the broker is unavailable.""" """Dispatch a Celery task, logging rather than raising when the broker is unavailable."""
try: try:
task_fn.delay(pk) task_fn.delay(pk)
except Exception: # ruff:ignore[blind-except] except Exception: # noqa: BLE001
logger.debug( logger.debug(
"Could not dispatch %s(%d) — broker may be unavailable.", "Could not dispatch %s(%d) — broker may be unavailable.",
task_fn.name, task_fn.name,
@ -20,57 +20,49 @@ def _dispatch(task_fn: Any, pk: int) -> None: # ruff:ignore[any-type]
) )
def on_game_saved(sender: Any, instance: Any, created: bool, **kwargs: Any) -> None: # ruff:ignore[any-type, boolean-type-hint-positional-argument] def on_game_saved(sender: Any, instance: Any, created: bool, **kwargs: Any) -> None: # noqa: ANN401, FBT001
"""Dispatch a box-art download task when a new Game is created.""" """Dispatch a box-art download task when a new Game is created."""
if created: if created:
from twitch.tasks import ( # ruff:ignore[import-outside-top-level] from twitch.tasks import download_game_image # noqa: PLC0415
download_game_image,
)
_dispatch(download_game_image, instance.pk) _dispatch(download_game_image, instance.pk)
def on_drop_campaign_saved( def on_drop_campaign_saved(
sender: Any, # ruff:ignore[any-type] sender: Any, # noqa: ANN401
instance: Any, # ruff:ignore[any-type] instance: Any, # noqa: ANN401
created: bool, # ruff:ignore[boolean-type-hint-positional-argument] created: bool, # noqa: FBT001
**kwargs: Any, # ruff:ignore[any-type] **kwargs: Any, # noqa: ANN401
) -> None: ) -> None:
"""Dispatch an image download task when a new DropCampaign is created.""" """Dispatch an image download task when a new DropCampaign is created."""
if created: if created:
from twitch.tasks import ( # ruff:ignore[import-outside-top-level] from twitch.tasks import download_campaign_image # noqa: PLC0415
download_campaign_image,
)
_dispatch(download_campaign_image, instance.pk) _dispatch(download_campaign_image, instance.pk)
def on_drop_benefit_saved( def on_drop_benefit_saved(
sender: Any, # ruff:ignore[any-type] sender: Any, # noqa: ANN401
instance: Any, # ruff:ignore[any-type] instance: Any, # noqa: ANN401
created: bool, # ruff:ignore[boolean-type-hint-positional-argument] created: bool, # noqa: FBT001
**kwargs: Any, # ruff:ignore[any-type] **kwargs: Any, # noqa: ANN401
) -> None: ) -> None:
"""Dispatch an image download task when a new DropBenefit is created.""" """Dispatch an image download task when a new DropBenefit is created."""
if created: if created:
from twitch.tasks import ( # ruff:ignore[import-outside-top-level] from twitch.tasks import download_benefit_image # noqa: PLC0415
download_benefit_image,
)
_dispatch(download_benefit_image, instance.pk) _dispatch(download_benefit_image, instance.pk)
def on_reward_campaign_saved( def on_reward_campaign_saved(
sender: Any, # ruff:ignore[any-type] sender: Any, # noqa: ANN401
instance: Any, # ruff:ignore[any-type] instance: Any, # noqa: ANN401
created: bool, # ruff:ignore[boolean-type-hint-positional-argument] created: bool, # noqa: FBT001
**kwargs: Any, # ruff:ignore[any-type] **kwargs: Any, # noqa: ANN401
) -> None: ) -> None:
"""Dispatch an image download task when a new RewardCampaign is created.""" """Dispatch an image download task when a new RewardCampaign is created."""
if created: if created:
from twitch.tasks import ( # ruff:ignore[import-outside-top-level] from twitch.tasks import download_reward_campaign_image # noqa: PLC0415
download_reward_campaign_image,
)
_dispatch(download_reward_campaign_image, instance.pk) _dispatch(download_reward_campaign_image, instance.pk)
@ -80,8 +72,8 @@ def _refresh_allowed_campaign_counts(channel_ids: set[int]) -> None:
if not channel_ids: if not channel_ids:
return return
from twitch.models import Channel # ruff:ignore[import-outside-top-level] from twitch.models import Channel # noqa: PLC0415
from twitch.models import DropCampaign # ruff:ignore[import-outside-top-level] from twitch.models import DropCampaign # noqa: PLC0415
through_model: type[Channel] = DropCampaign.allow_channels.through through_model: type[Channel] = DropCampaign.allow_channels.through
counts_by_channel: dict[int, int] = { counts_by_channel: dict[int, int] = {
@ -104,19 +96,19 @@ def _refresh_allowed_campaign_counts(channel_ids: set[int]) -> None:
Channel.objects.bulk_update(channels, ["allowed_campaign_count"]) Channel.objects.bulk_update(channels, ["allowed_campaign_count"])
def on_drop_campaign_allow_channels_changed( # ruff:ignore[too-many-arguments, too-many-positional-arguments] def on_drop_campaign_allow_channels_changed( # noqa: PLR0913, PLR0917
sender: Any, # ruff:ignore[any-type] sender: Any, # noqa: ANN401
instance: Any, # ruff:ignore[any-type] instance: Any, # noqa: ANN401
action: str, action: str,
reverse: bool, # ruff:ignore[boolean-type-hint-positional-argument] reverse: bool, # noqa: FBT001
model: Any, # ruff:ignore[any-type] model: Any, # noqa: ANN401
pk_set: set[int] | None, pk_set: set[int] | None,
**kwargs: Any, # ruff:ignore[any-type] **kwargs: Any, # noqa: ANN401
) -> None: ) -> None:
"""Keep Channel.allowed_campaign_count in sync for allow_channels M2M changes.""" """Keep Channel.allowed_campaign_count in sync for allow_channels M2M changes."""
if action == "pre_clear" and not reverse: if action == "pre_clear" and not reverse:
# post_clear does not expose removed channel IDs; snapshot before clearing. # post_clear does not expose removed channel IDs; snapshot before clearing.
instance._pre_clear_channel_ids = set( # pyright: ignore[reportAttributeAccessIssue] # ruff:ignore[private-member-access] instance._pre_clear_channel_ids = set( # pyright: ignore[reportAttributeAccessIssue] # noqa: SLF001
instance.allow_channels.values_list("pk", flat=True), # pyright: ignore[reportAttributeAccessIssue] instance.allow_channels.values_list("pk", flat=True), # pyright: ignore[reportAttributeAccessIssue]
) )
return return

View file

@ -21,7 +21,7 @@ logger: logging.Logger = logging.getLogger("ttvdrops.tasks")
@shared_task(bind=True, queue="imports", max_retries=3, default_retry_delay=60) @shared_task(bind=True, queue="imports", max_retries=3, default_retry_delay=60)
def scan_pending_twitch_files(self) -> None: # ruff:ignore[missing-type-function-argument] def scan_pending_twitch_files(self) -> None: # noqa: ANN001
"""Scan TTVDROPS_PENDING_DIR for JSON files and dispatch an import task for each.""" """Scan TTVDROPS_PENDING_DIR for JSON files and dispatch an import task for each."""
pending_dir: str = os.getenv("TTVDROPS_PENDING_DIR", "") pending_dir: str = os.getenv("TTVDROPS_PENDING_DIR", "")
if not pending_dir: if not pending_dir:
@ -44,9 +44,9 @@ def scan_pending_twitch_files(self) -> None: # ruff:ignore[missing-type-functio
@shared_task(bind=True, queue="imports", max_retries=3, default_retry_delay=60) @shared_task(bind=True, queue="imports", max_retries=3, default_retry_delay=60)
def import_twitch_file(self, file_path: str) -> None: # ruff:ignore[missing-type-function-argument] def import_twitch_file(self, file_path: str) -> None: # noqa: ANN001
"""Import a single Twitch JSON drop file via BetterImportDrops logic.""" """Import a single Twitch JSON drop file via BetterImportDrops logic."""
from twitch.management.commands.better_import_drops import Command as Importer # ruff:ignore[unsorted-imports, import-outside-top-level] from twitch.management.commands.better_import_drops import Command as Importer # noqa: I001, PLC0415
path = Path(file_path) path = Path(file_path)
if not path.is_file(): if not path.is_file():
@ -103,7 +103,7 @@ def _convert_to_modern_formats(source: Path) -> None:
try: try:
img = _open_and_prepare_image(source) img = _open_and_prepare_image(source)
except Exception: # ruff:ignore[blind-except] except Exception: # noqa: BLE001
logger.debug("Format conversion failed for %s.", source) logger.debug("Format conversion failed for %s.", source)
return return
@ -116,7 +116,7 @@ def _open_and_prepare_image(source: Path) -> Image:
Returns: Returns:
An RGB-mode PIL Image ready for saving in modern formats. An RGB-mode PIL Image ready for saving in modern formats.
""" """
from PIL import Image # ruff:ignore[import-outside-top-level] from PIL import Image # noqa: PLC0415
with Image.open(source) as raw: with Image.open(source) as raw:
if raw.mode in {"RGBA", "LA"} or ( if raw.mode in {"RGBA", "LA"} or (
@ -140,7 +140,7 @@ def _save_modern_formats(img: Image, source: Path) -> None:
out: Path = source.with_suffix(ext) out: Path = source.with_suffix(ext)
try: try:
img.save(out, fmt, quality=80) img.save(out, fmt, quality=80)
except Exception: # ruff:ignore[blind-except] except Exception: # noqa: BLE001
logger.debug("Could not convert %s to %s.", source, fmt) logger.debug("Could not convert %s to %s.", source, fmt)
@ -150,11 +150,11 @@ def _save_modern_formats(img: Image, source: Path) -> None:
@shared_task(bind=True, queue="image-downloads", max_retries=3, default_retry_delay=300) @shared_task(bind=True, queue="image-downloads", max_retries=3, default_retry_delay=300)
def download_game_image(self, game_pk: int) -> None: # ruff:ignore[missing-type-function-argument] def download_game_image(self, game_pk: int) -> None: # noqa: ANN001
"""Download and cache the box art image for a single Game.""" """Download and cache the box art image for a single Game."""
from twitch.models import Game # ruff:ignore[import-outside-top-level, unsorted-imports] from twitch.models import Game # noqa: PLC0415
from twitch.utils import is_twitch_box_art_url # ruff:ignore[import-outside-top-level] from twitch.utils import is_twitch_box_art_url # noqa: PLC0415
from twitch.utils import normalize_twitch_box_art_url # ruff:ignore[import-outside-top-level] from twitch.utils import normalize_twitch_box_art_url # noqa: PLC0415
try: try:
game = Game.objects.get(pk=game_pk) game = Game.objects.get(pk=game_pk)
@ -172,9 +172,9 @@ def download_game_image(self, game_pk: int) -> None: # ruff:ignore[missing-type
@shared_task(bind=True, queue="image-downloads", max_retries=3, default_retry_delay=300) @shared_task(bind=True, queue="image-downloads", max_retries=3, default_retry_delay=300)
def download_campaign_image(self, campaign_pk: int) -> None: # ruff:ignore[missing-type-function-argument] def download_campaign_image(self, campaign_pk: int) -> None: # noqa: ANN001
"""Download and cache the image for a single DropCampaign.""" """Download and cache the image for a single DropCampaign."""
from twitch.models import DropCampaign # ruff:ignore[import-outside-top-level] from twitch.models import DropCampaign # noqa: PLC0415
try: try:
campaign = DropCampaign.objects.get(pk=campaign_pk) campaign = DropCampaign.objects.get(pk=campaign_pk)
@ -191,9 +191,9 @@ def download_campaign_image(self, campaign_pk: int) -> None: # ruff:ignore[miss
@shared_task(bind=True, queue="image-downloads", max_retries=3, default_retry_delay=300) @shared_task(bind=True, queue="image-downloads", max_retries=3, default_retry_delay=300)
def download_benefit_image(self, benefit_pk: int) -> None: # ruff:ignore[missing-type-function-argument] def download_benefit_image(self, benefit_pk: int) -> None: # noqa: ANN001
"""Download and cache the image for a single DropBenefit.""" """Download and cache the image for a single DropBenefit."""
from twitch.models import DropBenefit # ruff:ignore[import-outside-top-level] from twitch.models import DropBenefit # noqa: PLC0415
try: try:
benefit = DropBenefit.objects.get(pk=benefit_pk) benefit = DropBenefit.objects.get(pk=benefit_pk)
@ -214,9 +214,9 @@ def download_benefit_image(self, benefit_pk: int) -> None: # ruff:ignore[missin
@shared_task(bind=True, queue="image-downloads", max_retries=3, default_retry_delay=300) @shared_task(bind=True, queue="image-downloads", max_retries=3, default_retry_delay=300)
def download_reward_campaign_image(self, reward_pk: int) -> None: # ruff:ignore[missing-type-function-argument] def download_reward_campaign_image(self, reward_pk: int) -> None: # noqa: ANN001
"""Download and cache the image for a single RewardCampaign.""" """Download and cache the image for a single RewardCampaign."""
from twitch.models import RewardCampaign # ruff:ignore[import-outside-top-level] from twitch.models import RewardCampaign # noqa: PLC0415
try: try:
reward = RewardCampaign.objects.get(pk=reward_pk) reward = RewardCampaign.objects.get(pk=reward_pk)
@ -245,7 +245,7 @@ def download_all_images() -> None:
@shared_task(bind=True, queue="api-fetches", max_retries=3, default_retry_delay=120) @shared_task(bind=True, queue="api-fetches", max_retries=3, default_retry_delay=120)
def import_chat_badges(self) -> None: # ruff:ignore[missing-type-function-argument] def import_chat_badges(self) -> None: # noqa: ANN001
"""Fetch and upsert Twitch global chat badges via the Helix API.""" """Fetch and upsert Twitch global chat badges via the Helix API."""
try: try:
call_command("import_chat_badges") call_command("import_chat_badges")

View file

@ -46,7 +46,7 @@ def get_format_url(image_url: str, fmt: str) -> str:
@register.simple_tag @register.simple_tag
def picture( # ruff:ignore[too-many-arguments, too-many-positional-arguments] def picture( # noqa: PLR0913, PLR0917
src: str, src: str,
alt: str = "", alt: str = "",
width: int | None = None, width: int | None = None,

View file

@ -179,7 +179,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_campaign_list(self) -> None: def test_v1_campaign_list(self) -> None:
"""Return active campaigns from the v1 list endpoint.""" """Return active campaigns from the v1 list endpoint."""
response = self.client.get("/api/v1/twitch/campaigns/?status=active") response = self.client.get("/twitch/api/v1/campaigns/?status=active")
assert response.status_code == 200 assert response.status_code == 200
assert "Content-Disposition" not in response assert "Content-Disposition" not in response
@ -193,7 +193,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_campaign_list_filters_by_game(self) -> None: def test_v1_campaign_list_filters_by_game(self) -> None:
"""Filter campaigns by game twitch_id.""" """Filter campaigns by game twitch_id."""
response = self.client.get( response = self.client.get(
f"/api/v1/twitch/campaigns/?game={self.game.twitch_id}", f"/twitch/api/v1/campaigns/?game={self.game.twitch_id}",
) )
assert response.status_code == 200 assert response.status_code == 200
@ -203,7 +203,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_campaign_list_game_filter_with_no_matches(self) -> None: def test_v1_campaign_list_game_filter_with_no_matches(self) -> None:
"""Return empty list when game filter matches no campaigns.""" """Return empty list when game filter matches no campaigns."""
response = self.client.get("/api/v1/twitch/campaigns/?game=nonexistent") response = self.client.get("/twitch/api/v1/campaigns/?game=nonexistent")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -223,7 +223,7 @@ class TwitchApiV1TestCase(TestCase):
is_fully_imported=True, is_fully_imported=True,
) )
response = self.client.get("/api/v1/twitch/campaigns/?status=upcoming") response = self.client.get("/twitch/api/v1/campaigns/?status=upcoming")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -243,7 +243,7 @@ class TwitchApiV1TestCase(TestCase):
is_fully_imported=True, is_fully_imported=True,
) )
response = self.client.get("/api/v1/twitch/campaigns/?status=expired") response = self.client.get("/twitch/api/v1/campaigns/?status=expired")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -252,7 +252,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_campaign_list_default_page_size(self) -> None: def test_v1_campaign_list_default_page_size(self) -> None:
"""Return all campaigns when no page_size is specified.""" """Return all campaigns when no page_size is specified."""
response = self.client.get("/api/v1/twitch/campaigns/") response = self.client.get("/twitch/api/v1/campaigns/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -261,7 +261,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_campaign_list_page_size_param(self) -> None: def test_v1_campaign_list_page_size_param(self) -> None:
"""Respect custom page_size parameter.""" """Respect custom page_size parameter."""
response = self.client.get("/api/v1/twitch/campaigns/?page_size=1") response = self.client.get("/twitch/api/v1/campaigns/?page_size=1")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -270,7 +270,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_campaign_list_summary_fields(self) -> None: def test_v1_campaign_list_summary_fields(self) -> None:
"""Return correct field shape for campaign summary items.""" """Return correct field shape for campaign summary items."""
response = self.client.get("/api/v1/twitch/campaigns/") response = self.client.get("/twitch/api/v1/campaigns/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -292,7 +292,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_campaign_detail(self) -> None: def test_v1_campaign_detail(self) -> None:
"""Return nested campaign detail data from the v1 endpoint.""" """Return nested campaign detail data from the v1 endpoint."""
response = self.client.get("/api/v1/twitch/campaigns/campaign123/") response = self.client.get("/twitch/api/v1/campaigns/campaign123/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -322,7 +322,7 @@ class TwitchApiV1TestCase(TestCase):
update_fields=["box_art_file", "box_art_width", "box_art_height"], update_fields=["box_art_file", "box_art_width", "box_art_height"],
) )
response = self.client.get("/api/v1/twitch/campaigns/campaign123/") response = self.client.get("/twitch/api/v1/campaigns/campaign123/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -337,7 +337,7 @@ class TwitchApiV1TestCase(TestCase):
one extra query per benefit. one extra query per benefit.
""" """
with CaptureQueriesContext(connection) as capture: with CaptureQueriesContext(connection) as capture:
response = self.client.get("/api/v1/twitch/campaigns/campaign123/") response = self.client.get("/twitch/api/v1/campaigns/campaign123/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -361,26 +361,26 @@ class TwitchApiV1TestCase(TestCase):
"""Exercise all v1 routes with enough rows to catch deferred loads.""" """Exercise all v1 routes with enough rows to catch deferred loads."""
self._create_secondary_api_fixture() self._create_secondary_api_fixture()
list_urls: list[tuple[str, int]] = [ list_urls: list[tuple[str, int]] = [
("/api/v1/twitch/campaigns/?page_size=50", 2), ("/twitch/api/v1/campaigns/?page_size=50", 2),
("/api/v1/twitch/games/?page_size=50", 2), ("/twitch/api/v1/games/?page_size=50", 2),
("/api/v1/twitch/organizations/?page_size=50", 2), ("/twitch/api/v1/organizations/?page_size=50", 2),
("/api/v1/twitch/channels/?page_size=50", 2), ("/twitch/api/v1/channels/?page_size=50", 2),
("/api/v1/twitch/reward-campaigns/?page_size=50", 2), ("/twitch/api/v1/reward-campaigns/?page_size=50", 2),
("/api/v1/twitch/badges/?page_size=50", 2), ("/twitch/api/v1/badges/?page_size=50", 2),
] ]
detail_urls = [ detail_urls = [
"/api/v1/twitch/campaigns/campaign123/", "/twitch/api/v1/campaigns/campaign123/",
"/api/v1/twitch/campaigns/campaign456/", "/twitch/api/v1/campaigns/campaign456/",
"/api/v1/twitch/games/game123/", "/twitch/api/v1/games/game123/",
"/api/v1/twitch/games/game456/", "/twitch/api/v1/games/game456/",
"/api/v1/twitch/organizations/org123/", "/twitch/api/v1/organizations/org123/",
"/api/v1/twitch/organizations/org456/", "/twitch/api/v1/organizations/org456/",
"/api/v1/twitch/channels/channel123/", "/twitch/api/v1/channels/channel123/",
"/api/v1/twitch/channels/channel456/", "/twitch/api/v1/channels/channel456/",
"/api/v1/twitch/reward-campaigns/reward123/", "/twitch/api/v1/reward-campaigns/reward123/",
"/api/v1/twitch/reward-campaigns/reward456/", "/twitch/api/v1/reward-campaigns/reward456/",
"/api/v1/twitch/badges/test-badge-set/", "/twitch/api/v1/badges/test-badge-set/",
"/api/v1/twitch/badges/second-badge-set/", "/twitch/api/v1/badges/second-badge-set/",
] ]
for url, expected_total in list_urls: for url, expected_total in list_urls:
@ -395,21 +395,21 @@ class TwitchApiV1TestCase(TestCase):
assert response.status_code == 200, url assert response.status_code == 200, url
assert response.json() assert response.json()
schema_response = self.client.get(reverse("api-v1:openapi-json")) schema_response = self.client.get(reverse("twitch:twitch-api-v1:openapi-json"))
assert schema_response.status_code == 200 assert schema_response.status_code == 200
assert schema_response.json() assert schema_response.json()
docs_response = self.client.get(reverse("api-v1:openapi-view")) docs_response = self.client.get(reverse("twitch:twitch-api-v1:openapi-view"))
assert docs_response.status_code == 200 assert docs_response.status_code == 200
def test_v1_collection_endpoints(self) -> None: def test_v1_collection_endpoints(self) -> None:
"""Return v1 list responses for all Twitch API collections.""" """Return v1 list responses for all Twitch API collections."""
checks = [ checks = [
("/api/v1/twitch/games/", "game123"), ("/twitch/api/v1/games/", "game123"),
("/api/v1/twitch/organizations/", "org123"), ("/twitch/api/v1/organizations/", "org123"),
("/api/v1/twitch/channels/", "channel123"), ("/twitch/api/v1/channels/", "channel123"),
("/api/v1/twitch/reward-campaigns/", "reward123"), ("/twitch/api/v1/reward-campaigns/", "reward123"),
("/api/v1/twitch/badges/", "test-badge-set"), ("/twitch/api/v1/badges/", "test-badge-set"),
] ]
for url, expected_id in checks: for url, expected_id in checks:
@ -422,14 +422,14 @@ class TwitchApiV1TestCase(TestCase):
) )
assert actual_id == expected_id assert actual_id == expected_id
games_response = self.client.get("/api/v1/twitch/games/") games_response = self.client.get("/twitch/api/v1/games/")
games_data = games_response.json() games_data = games_response.json()
assert games_data["items"][0]["campaign_count"] == 1 assert games_data["items"][0]["campaign_count"] == 1
assert games_data["items"][0]["active_campaign_count"] == 1 assert games_data["items"][0]["active_campaign_count"] == 1
def test_v1_organization_detail_includes_games_and_campaigns(self) -> None: def test_v1_organization_detail_includes_games_and_campaigns(self) -> None:
"""Return concrete game counts and detailed organization campaigns.""" """Return concrete game counts and detailed organization campaigns."""
response = self.client.get("/api/v1/twitch/organizations/org123/") response = self.client.get("/twitch/api/v1/organizations/org123/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -447,8 +447,8 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_game_and_channel_detail_include_campaign_data(self) -> None: def test_v1_game_and_channel_detail_include_campaign_data(self) -> None:
"""Return campaign API fields on game and channel detail responses.""" """Return campaign API fields on game and channel detail responses."""
checks = [ checks = [
"/api/v1/twitch/games/game123/", "/twitch/api/v1/games/game123/",
"/api/v1/twitch/channels/channel123/", "/twitch/api/v1/channels/channel123/",
] ]
for url in checks: for url in checks:
@ -463,16 +463,16 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_detail_not_found(self) -> None: def test_v1_detail_not_found(self) -> None:
"""Return 404 for missing v1 campaign detail records.""" """Return 404 for missing v1 campaign detail records."""
response = self.client.get("/api/v1/twitch/campaigns/missing/") response = self.client.get("/twitch/api/v1/campaigns/missing/")
assert response.status_code == 404 assert response.status_code == 404
def test_v1_docs_endpoint(self) -> None: def test_v1_docs_endpoint(self) -> None:
"""Render the combined TTVDrops API documentation page.""" """Render the versioned Twitch API documentation page."""
response = self.client.get("/api/v1/docs/") response = self.client.get("/twitch/api/v1/docs")
assert response.status_code == 200 assert response.status_code == 200
assert reverse("api-v1:openapi-json") in response.content.decode() assert reverse("twitch:twitch-api-v1:openapi-json") in response.content.decode()
def test_v1_docs_links_render_on_twitch_pages(self) -> None: def test_v1_docs_links_render_on_twitch_pages(self) -> None:
"""Expose API docs in nav and resource API links in feed link groups.""" """Expose API docs in nav and resource API links in feed link groups."""
@ -480,27 +480,27 @@ class TwitchApiV1TestCase(TestCase):
( (
reverse("core:docs_rss"), reverse("core:docs_rss"),
"API Docs", "API Docs",
"/api/v1/docs/", "/twitch/api/v1/docs",
reverse("api-v1:openapi-view"), reverse("twitch:twitch-api-v1:openapi-view"),
), ),
( (
reverse("twitch:dashboard"), reverse("twitch:dashboard"),
"API Docs", "API Docs",
"[api]", "[api]",
reverse("api-v1:twitch-api-v1_list_campaigns"), reverse("twitch:twitch-api-v1:list_campaigns"),
), ),
( (
reverse("twitch:campaign_list"), reverse("twitch:campaign_list"),
"API Docs", "API Docs",
"[api]", "[api]",
reverse("api-v1:twitch-api-v1_list_campaigns"), reverse("twitch:twitch-api-v1:list_campaigns"),
), ),
( (
reverse("twitch:campaign_detail", args=[self.campaign.twitch_id]), reverse("twitch:campaign_detail", args=[self.campaign.twitch_id]),
"API Docs", "API Docs",
"[api]", "[api]",
reverse( reverse(
"api-v1:twitch-api-v1_get_campaign", "twitch:twitch-api-v1:get_campaign",
args=[self.campaign.twitch_id], args=[self.campaign.twitch_id],
), ),
), ),
@ -508,25 +508,25 @@ class TwitchApiV1TestCase(TestCase):
reverse("twitch:game_detail", args=[self.game.twitch_id]), reverse("twitch:game_detail", args=[self.game.twitch_id]),
"API Docs", "API Docs",
"[api]", "[api]",
reverse("api-v1:twitch-api-v1_get_game", args=[self.game.twitch_id]), reverse("twitch:twitch-api-v1:get_game", args=[self.game.twitch_id]),
), ),
( (
reverse("twitch:games_grid"), reverse("twitch:games_grid"),
"API Docs", "API Docs",
"[api]", "[api]",
reverse("api-v1:twitch-api-v1_list_games"), reverse("twitch:twitch-api-v1:list_games"),
), ),
( (
reverse("twitch:org_list"), reverse("twitch:org_list"),
"API Docs", "API Docs",
"[api]", "[api]",
reverse("api-v1:twitch-api-v1_list_organizations"), reverse("twitch:twitch-api-v1:list_organizations"),
), ),
( (
reverse("twitch:reward_campaign_list"), reverse("twitch:reward_campaign_list"),
"API Docs", "API Docs",
"[api]", "[api]",
reverse("api-v1:twitch-api-v1_list_reward_campaigns"), reverse("twitch:twitch-api-v1:list_reward_campaigns"),
), ),
( (
reverse( reverse(
@ -536,7 +536,7 @@ class TwitchApiV1TestCase(TestCase):
"API Docs", "API Docs",
"[api]", "[api]",
reverse( reverse(
"api-v1:twitch-api-v1_get_reward_campaign", "twitch:twitch-api-v1:get_reward_campaign",
args=[self.reward_campaign.twitch_id], args=[self.reward_campaign.twitch_id],
), ),
), ),
@ -546,7 +546,7 @@ class TwitchApiV1TestCase(TestCase):
response = self.client.get(url) response = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
assert reverse("api-v1:openapi-view") in content assert reverse("twitch:twitch-api-v1:openapi-view") in content
assert api_href in content assert api_href in content
assert nav_text in content assert nav_text in content
assert feed_text in content assert feed_text in content
@ -560,7 +560,7 @@ class TwitchApiV1TestCase(TestCase):
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
campaign_api_url = reverse( campaign_api_url = reverse(
"api-v1:twitch-api-v1_get_campaign", "twitch:twitch-api-v1:get_campaign",
args=[self.campaign.twitch_id], args=[self.campaign.twitch_id],
) )
assert f'href="{campaign_api_url}"' in content assert f'href="{campaign_api_url}"' in content
@ -570,7 +570,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_game_list_fields(self) -> None: def test_v1_game_list_fields(self) -> None:
"""Return correct field shape for game list items.""" """Return correct field shape for game list items."""
response = self.client.get("/api/v1/twitch/games/") response = self.client.get("/twitch/api/v1/games/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -590,7 +590,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_game_list_pagination(self) -> None: def test_v1_game_list_pagination(self) -> None:
"""Paginate game list results.""" """Paginate game list results."""
response = self.client.get("/api/v1/twitch/games/?page_size=1") response = self.client.get("/twitch/api/v1/games/?page_size=1")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -599,7 +599,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_game_detail_campaigns_and_owners(self) -> None: def test_v1_game_detail_campaigns_and_owners(self) -> None:
"""Return campaigns and organizations in game detail.""" """Return campaigns and organizations in game detail."""
response = self.client.get("/api/v1/twitch/games/game123/") response = self.client.get("/twitch/api/v1/games/game123/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -609,7 +609,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_game_detail_not_found(self) -> None: def test_v1_game_detail_not_found(self) -> None:
"""Return 404 for missing game.""" """Return 404 for missing game."""
response = self.client.get("/api/v1/twitch/games/nonexistent/") response = self.client.get("/twitch/api/v1/games/nonexistent/")
assert response.status_code == 404 assert response.status_code == 404
@ -617,7 +617,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_organization_list_fields(self) -> None: def test_v1_organization_list_fields(self) -> None:
"""Return correct field shape for organization list items.""" """Return correct field shape for organization list items."""
response = self.client.get("/api/v1/twitch/organizations/") response = self.client.get("/twitch/api/v1/organizations/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -630,7 +630,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_organization_detail_not_found(self) -> None: def test_v1_organization_detail_not_found(self) -> None:
"""Return 404 for missing organization.""" """Return 404 for missing organization."""
response = self.client.get("/api/v1/twitch/organizations/nonexistent/") response = self.client.get("/twitch/api/v1/organizations/nonexistent/")
assert response.status_code == 404 assert response.status_code == 404
@ -638,7 +638,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_channel_list_search(self) -> None: def test_v1_channel_list_search(self) -> None:
"""Filter channels by search query.""" """Filter channels by search query."""
response = self.client.get("/api/v1/twitch/channels/?search=testchannel") response = self.client.get("/twitch/api/v1/channels/?search=testchannel")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -647,7 +647,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_channel_list_search_no_match(self) -> None: def test_v1_channel_list_search_no_match(self) -> None:
"""Return empty list when search matches no channels.""" """Return empty list when search matches no channels."""
response = self.client.get("/api/v1/twitch/channels/?search=zzzzz") response = self.client.get("/twitch/api/v1/channels/?search=zzzzz")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -656,7 +656,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_channel_list_fields(self) -> None: def test_v1_channel_list_fields(self) -> None:
"""Return correct field shape for channel list items.""" """Return correct field shape for channel list items."""
response = self.client.get("/api/v1/twitch/channels/") response = self.client.get("/twitch/api/v1/channels/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -670,7 +670,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_channel_detail_campaigns(self) -> None: def test_v1_channel_detail_campaigns(self) -> None:
"""Return campaign summaries in channel detail.""" """Return campaign summaries in channel detail."""
response = self.client.get("/api/v1/twitch/channels/channel123/") response = self.client.get("/twitch/api/v1/channels/channel123/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -680,7 +680,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_channel_detail_not_found(self) -> None: def test_v1_channel_detail_not_found(self) -> None:
"""Return 404 for missing channel.""" """Return 404 for missing channel."""
response = self.client.get("/api/v1/twitch/channels/nonexistent/") response = self.client.get("/twitch/api/v1/channels/nonexistent/")
assert response.status_code == 404 assert response.status_code == 404
@ -689,7 +689,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_reward_campaign_list_filters_by_game(self) -> None: def test_v1_reward_campaign_list_filters_by_game(self) -> None:
"""Filter reward campaigns by game twitch_id.""" """Filter reward campaigns by game twitch_id."""
response = self.client.get( response = self.client.get(
f"/api/v1/twitch/reward-campaigns/?game={self.game.twitch_id}", f"/twitch/api/v1/reward-campaigns/?game={self.game.twitch_id}",
) )
assert response.status_code == 200 assert response.status_code == 200
@ -699,7 +699,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_reward_campaign_list_status_active(self) -> None: def test_v1_reward_campaign_list_status_active(self) -> None:
"""Filter reward campaigns by status=active.""" """Filter reward campaigns by status=active."""
response = self.client.get("/api/v1/twitch/reward-campaigns/?status=active") response = self.client.get("/twitch/api/v1/reward-campaigns/?status=active")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -719,7 +719,7 @@ class TwitchApiV1TestCase(TestCase):
summary="Expired summary", summary="Expired summary",
) )
response = self.client.get("/api/v1/twitch/reward-campaigns/?status=expired") response = self.client.get("/twitch/api/v1/reward-campaigns/?status=expired")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -728,7 +728,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_reward_campaign_list_fields(self) -> None: def test_v1_reward_campaign_list_fields(self) -> None:
"""Return correct field shape for reward campaign list items.""" """Return correct field shape for reward campaign list items."""
response = self.client.get("/api/v1/twitch/reward-campaigns/") response = self.client.get("/twitch/api/v1/reward-campaigns/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -748,7 +748,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_reward_campaign_detail_fields(self) -> None: def test_v1_reward_campaign_detail_fields(self) -> None:
"""Return correct field shape for reward campaign detail.""" """Return correct field shape for reward campaign detail."""
response = self.client.get("/api/v1/twitch/reward-campaigns/reward123/") response = self.client.get("/twitch/api/v1/reward-campaigns/reward123/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -774,7 +774,7 @@ class TwitchApiV1TestCase(TestCase):
is_sitewide=True, is_sitewide=True,
) )
response = self.client.get("/api/v1/twitch/reward-campaigns/no_game_reward/") response = self.client.get("/twitch/api/v1/reward-campaigns/no_game_reward/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -783,7 +783,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_reward_campaign_detail_not_found(self) -> None: def test_v1_reward_campaign_detail_not_found(self) -> None:
"""Return 404 for missing reward campaign.""" """Return 404 for missing reward campaign."""
response = self.client.get("/api/v1/twitch/reward-campaigns/nonexistent/") response = self.client.get("/twitch/api/v1/reward-campaigns/nonexistent/")
assert response.status_code == 404 assert response.status_code == 404
@ -791,7 +791,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_badge_list_fields(self) -> None: def test_v1_badge_list_fields(self) -> None:
"""Return correct field shape for badge set list items.""" """Return correct field shape for badge set list items."""
response = self.client.get("/api/v1/twitch/badges/") response = self.client.get("/twitch/api/v1/badges/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -804,7 +804,7 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_badge_detail_fields(self) -> None: def test_v1_badge_detail_fields(self) -> None:
"""Return correct field shape for badge set detail.""" """Return correct field shape for badge set detail."""
response = self.client.get("/api/v1/twitch/badges/test-badge-set/") response = self.client.get("/twitch/api/v1/badges/test-badge-set/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -822,6 +822,6 @@ class TwitchApiV1TestCase(TestCase):
def test_v1_badge_detail_not_found(self) -> None: def test_v1_badge_detail_not_found(self) -> None:
"""Return 404 for missing badge set.""" """Return 404 for missing badge set."""
response = self.client.get("/api/v1/twitch/badges/nonexistent/") response = self.client.get("/twitch/api/v1/badges/nonexistent/")
assert response.status_code == 404 assert response.status_code == 404

View file

@ -1080,7 +1080,7 @@ def test_campaign_feed_queries_bounded(
_build_campaign(game, i) _build_campaign(game, i)
url: str = reverse("core:campaign_feed") url: str = reverse("core: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. # ruff:ignore[missing-todo-link] # 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)
@ -1331,7 +1331,7 @@ def test_docs_rss_queries_bounded(
url: str = reverse("core:docs_rss") url: str = reverse("core: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. # ruff:ignore[missing-todo-link] # 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)

View file

@ -15,7 +15,7 @@ if TYPE_CHECKING:
@pytest.mark.django_db(transaction=True) @pytest.mark.django_db(transaction=True)
def test_0021_backfills_allowed_campaign_count() -> None: # ruff:ignore[too-many-locals] def test_0021_backfills_allowed_campaign_count() -> None: # noqa: PLR0914
"""Migration 0021 should backfill cached allowed campaign counts.""" """Migration 0021 should backfill cached allowed campaign counts."""
migrate_from: list[tuple[str, str]] = [ migrate_from: list[tuple[str, str]] = [
("twitch", "0020_rewardcampaign_tw_reward_ends_starts_idx"), ("twitch", "0020_rewardcampaign_tw_reward_ends_starts_idx"),

View file

@ -2555,7 +2555,7 @@ class TestRewardCampaignViews:
game.owners.add(org) game.owners.add(org)
return game return game
def _create_reward_campaign( # ruff:ignore[too-many-arguments] def _create_reward_campaign( # noqa: PLR0913
self, self,
twitch_id: str, twitch_id: str,
*, *,
@ -2618,7 +2618,7 @@ class TestRewardCampaignViews:
) )
assert reverse("twitch:game_detail", args=[game.twitch_id]) in content assert reverse("twitch:game_detail", args=[game.twitch_id]) in content
assert reverse("core:reward_campaign_feed") in content assert reverse("core:reward_campaign_feed") in content
assert reverse("api-v1:twitch-api-v1_list_reward_campaigns") in content assert reverse("twitch:twitch-api-v1:list_reward_campaigns") in content
assert response.context["reward_campaigns"].paginator.count == 2 assert response.context["reward_campaigns"].paginator.count == 2
def test_reward_campaign_list_filters_status_and_game( def test_reward_campaign_list_filters_status_and_game(
@ -2691,7 +2691,7 @@ class TestRewardCampaignViews:
assert reverse("twitch:game_detail", args=[game.twitch_id]) in content assert reverse("twitch:game_detail", args=[game.twitch_id]) in content
assert ( assert (
reverse( reverse(
"api-v1:twitch-api-v1_get_reward_campaign", "twitch:twitch-api-v1:get_reward_campaign",
args=[reward.twitch_id], args=[reward.twitch_id],
) )
in content in content

View file

@ -1,9 +1,9 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.urls import path from django.urls import path
from django.views.generic.base import RedirectView
from twitch import views from twitch import views
from twitch.api import api as twitch_api_v1
if TYPE_CHECKING: if TYPE_CHECKING:
from django.urls.resolvers import URLPattern from django.urls.resolvers import URLPattern
@ -13,32 +13,10 @@ app_name = "twitch"
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
# /twitch/api/v1/
path("api/v1/", twitch_api_v1.urls),
# /twitch/ # /twitch/
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
# Redirect old standalone Twitch v1 API URLs to the new combined API
# These must come before the catch-all below.
path(
"api/v1/docs/",
RedirectView.as_view(
pattern_name="api-v1:openapi-view",
permanent=True,
),
),
path(
"api/v1/openapi.json",
RedirectView.as_view(
pattern_name="api-v1:openapi-json",
permanent=True,
),
),
path(
"api/v1/<path:rest>",
RedirectView.as_view(
url="/api/v1/twitch/%(rest)s",
permanent=True,
query_string=True,
),
),
# /twitch/badges/ # /twitch/badges/
path("badges/", views.badge_list_view, name="badge_list"), path("badges/", views.badge_list_view, name="badge_list"),
# /twitch/badges/<set_id>/ # /twitch/badges/<set_id>/

View file

@ -196,7 +196,7 @@ def _build_breadcrumb_schema(items: list[dict[str, str | int]]) -> dict[str, Any
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. # ruff:ignore[missing-todo-link] # 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):
@ -384,7 +384,7 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
# MARK: /campaigns/ # MARK: /campaigns/
def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # ruff:ignore[too-many-locals] def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914
"""Function-based view for drop campaigns list. """Function-based view for drop campaigns list.
Args: Args:
@ -483,7 +483,7 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # ruff:ignor
# MARK: /campaigns/<twitch_id>/ # MARK: /campaigns/<twitch_id>/
def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # ruff:ignore[too-many-locals] def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914
"""Function-based view for a drop campaign detail. """Function-based view for a drop campaign detail.
Args: Args:
@ -536,7 +536,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
reverse("twitch:campaign_detail", args=[campaign.twitch_id]), 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. # ruff:ignore[missing-todo-link] # 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_event: dict[str, Any] = { campaign_event: dict[str, Any] = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Event", "@type": "Event",
@ -590,7 +590,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
campaign_schema: dict[str, Any] = campaign_event campaign_schema: dict[str, Any] = campaign_event
# Breadcrumb schema for navigation # Breadcrumb schema for navigation
# TODO(TheLovinator): We should have a game.get_display_name() method that encapsulates the logic of choosing between display_name, name, and twitch_id. # ruff:ignore[missing-todo-link] # 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 = ( game_name: str = (
campaign.game.display_name or campaign.game.name or campaign.game.twitch_id campaign.game.display_name or campaign.game.name or campaign.game.twitch_id
) )
@ -702,7 +702,7 @@ class GameDetailView(DetailView):
"""Return game queryset optimized for the game detail page.""" """Return game queryset optimized for the game detail page."""
return Game.for_detail_view() return Game.for_detail_view()
def get_context_data(self, **kwargs) -> dict[str, Any]: # ruff:ignore[too-many-locals] def get_context_data(self, **kwargs) -> dict[str, Any]: # noqa: PLR0914
"""Add additional context data. """Add additional context data.
Args: Args:
@ -849,7 +849,7 @@ def dashboard(request: HttpRequest) -> HttpResponse:
dashboard_data: dict[str, Any] = DropCampaign.dashboard_context(now) dashboard_data: dict[str, Any] = DropCampaign.dashboard_context(now)
# 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? # ruff:ignore[missing-todo-link] # 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",
@ -1184,7 +1184,7 @@ class ChannelDetailView(DetailView):
channel: Channel = get_object_or_404(queryset, twitch_id=twitch_id) channel: Channel = get_object_or_404(queryset, twitch_id=twitch_id)
return channel return channel
def get_context_data(self, **kwargs) -> dict[str, Any]: # ruff:ignore[too-many-locals] def get_context_data(self, **kwargs) -> dict[str, Any]: # noqa: PLR0914
"""Add additional context data. """Add additional context data.
Args: Args: