From 3d46cb5ec9c69ea3b6d6ec4e1a618296caf8a698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dinh?= Date: Wed, 15 Jul 2026 04:51:32 +0700 Subject: [PATCH 1/4] Add public Kick campaign JSON API --- config/tests/test_site_endpoint_smoke.py | 1 + kick/api.py | 213 +++++++++++ kick/tests/test_api.py | 449 +++++++++++++++++++++++ kick/urls.py | 7 + templates/core/dataset_backups.html | 49 ++- 5 files changed, 716 insertions(+), 3 deletions(-) create mode 100644 kick/api.py create mode 100644 kick/tests/test_api.py diff --git a/config/tests/test_site_endpoint_smoke.py b/config/tests/test_site_endpoint_smoke.py index 8814395..80be912 100644 --- a/config/tests/test_site_endpoint_smoke.py +++ b/config/tests/test_site_endpoint_smoke.py @@ -276,6 +276,7 @@ class SiteEndpointSmokeTest(TestCase): ("twitch:export_organizations_csv", {}, 200), ("twitch:export_organizations_json", {}, 200), # Kick endpoints + ("kick:campaign_api_list", {}, 200), ("kick:dashboard", {}, 200), ("kick:campaign_list", {}, 200), ("kick:campaign_detail", {"kick_id": self.kick_campaign.kick_id}, 200), diff --git a/kick/api.py b/kick/api.py new file mode 100644 index 0000000..3063926 --- /dev/null +++ b/kick/api.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from datetime import UTC +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal + +from django.http import JsonResponse +from django.utils import timezone +from django.views.decorators.http import require_GET + +from kick.models import KickDropCampaign + +if TYPE_CHECKING: + from datetime import datetime + + from django.db.models import QuerySet + from django.http import HttpRequest + + from kick.models import KickCategory + from kick.models import KickChannel + from kick.models import KickOrganization + from kick.models import KickReward + from kick.models import KickUser + +CampaignStatus = Literal["active", "upcoming", "expired", "unknown"] +VALID_STATUS_FILTERS: frozenset[str] = frozenset({"active", "upcoming", "expired"}) +DEFAULT_PAGE_SIZE = 100 +MAX_PAGE_SIZE = 500 + + +def _datetime_to_json(value: datetime | None) -> str | None: + if value is None: + return None + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _campaign_status(campaign: KickDropCampaign, now: 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 _serialize_category(category: KickCategory | None) -> dict[str, Any] | None: + if category is None: + return None + return { + "kick_id": category.kick_id, + "name": category.name, + "slug": category.slug, + "image_url": category.image_url, + } + + +def _serialize_organization( + organization: KickOrganization | None, +) -> dict[str, Any] | None: + if organization is None: + return None + return { + "kick_id": organization.kick_id, + "name": organization.name, + "logo_url": organization.logo_url, + "url": organization.url, + } + + +def _serialize_user(user: KickUser | None) -> dict[str, Any] | None: + if user is None: + return None + return { + "kick_id": user.kick_id, + "username": user.username, + "profile_picture": user.profile_picture, + } + + +def _serialize_channel(channel: KickChannel) -> dict[str, Any]: + return { + "kick_id": channel.kick_id, + "slug": channel.slug, + "url": channel.channel_url, + "user": _serialize_user(channel.user), + } + + +def _serialize_reward(reward: KickReward) -> dict[str, Any]: + return { + "kick_id": reward.kick_id, + "name": reward.name, + "image_url": reward.full_image_url, + "required_minutes_watched": reward.required_units, + } + + +def _serialize_campaign( + campaign: KickDropCampaign, + now: datetime, +) -> dict[str, Any]: + return { + "kick_id": campaign.kick_id, + "name": campaign.name, + "status": _campaign_status(campaign, now), + "image_url": campaign.image_url, + "connect_url": campaign.connect_url, + "url": campaign.url, + "starts_at": _datetime_to_json(campaign.starts_at), + "ends_at": _datetime_to_json(campaign.ends_at), + "category": _serialize_category(campaign.category), + "organization": _serialize_organization(campaign.organization), + "channels": [ + _serialize_channel(channel) for channel in campaign.channels.all() + ], + "rewards": [_serialize_reward(reward) for reward in campaign.rewards.all()], + "is_fully_imported": campaign.is_fully_imported, + "added_at": _datetime_to_json(campaign.added_at), + "updated_at": _datetime_to_json(campaign.updated_at), + } + + +def _parse_integer_query( + request: HttpRequest, + name: str, + default: int, +) -> int | JsonResponse: + raw_value: str | None = request.GET.get(name) + if raw_value is None: + return default + try: + return int(raw_value) + except ValueError: + return JsonResponse( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["query", name], + "msg": "Input should be a valid integer", + }, + ], + }, + status=422, + ) + + +@require_GET +def campaign_list_api(request: HttpRequest) -> JsonResponse: + """Return a paginated JSON representation of fully imported Kick campaigns.""" + page = _parse_integer_query(request, "page", 1) + if isinstance(page, JsonResponse): + return page + page = max(page, 1) + + page_size = _parse_integer_query(request, "page_size", DEFAULT_PAGE_SIZE) + if isinstance(page_size, JsonResponse): + return page_size + page_size = min(max(page_size, 1), MAX_PAGE_SIZE) + + status_filter: str | None = request.GET.get("status") + if status_filter is not None and status_filter not in VALID_STATUS_FILTERS: + return JsonResponse( + { + "detail": [ + { + "type": "literal_error", + "loc": ["query", "status"], + "msg": "Input should be 'active', 'upcoming' or 'expired'", + }, + ], + }, + status=422, + ) + + now: 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") + ) + + game_filter: int | JsonResponse | None = None + if request.GET.get("game") is not None: + game_filter = _parse_integer_query(request, "game", 0) + if isinstance(game_filter, JsonResponse): + return game_filter + if game_filter is not None: + queryset = queryset.filter(category__kick_id=game_filter) + + if status_filter == "active": + queryset = queryset.filter(starts_at__lte=now, ends_at__gte=now) + elif status_filter == "upcoming": + queryset = queryset.filter(starts_at__gt=now, ends_at__isnull=False) + elif status_filter == "expired": + queryset = queryset.filter(starts_at__lte=now, ends_at__lt=now) + + total: int = queryset.count() + offset: int = (page - 1) * page_size + campaigns: list[KickDropCampaign] = ( + list(queryset[offset : offset + page_size]) if offset < total else [] + ) + + return JsonResponse({ + "total": total, + "page": page, + "page_size": page_size, + "items": [_serialize_campaign(campaign, now) for campaign in campaigns], + }) diff --git a/kick/tests/test_api.py b/kick/tests/test_api.py new file mode 100644 index 0000000..76d829d --- /dev/null +++ b/kick/tests/test_api.py @@ -0,0 +1,449 @@ +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("kick:campaign_api_list")) + + assert response.status_code == 200 + assert response["Content-Type"] == "application/json" + assert response.json() == { + "total": 1, + "page": 1, + "page_size": 100, + "items": [ + { + "kick_id": "campaign-api", + "name": "API Campaign", + "status": "active", + "image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png", + "connect_url": "https://example.com/connect", + "url": "https://example.com/campaign", + "starts_at": "2026-07-01T00:00:00Z", + "ends_at": "2026-08-01T00:00:00Z", + "category": { + "kick_id": 123, + "name": "API Game", + "slug": "api-game", + "image_url": "https://example.com/game.png", + }, + "organization": { + "kick_id": "org-api", + "name": "API Organization", + "logo_url": "https://example.com/org.png", + "url": "https://example.com/org", + }, + "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", + }, + }, + ], + "rewards": [ + { + "kick_id": "reward-api", + "name": "API Reward", + "image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png", + "required_minutes_watched": 30, + }, + ], + "is_fully_imported": True, + "added_at": self.campaign.added_at.isoformat().replace( + "+00:00", "Z" + ), + "updated_at": self.campaign.updated_at.isoformat().replace( + "+00:00", "Z" + ), + }, + ], + } + + 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("kick:campaign_api_list"), + {"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("kick:campaign_api_list"), + {"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("kick:campaign_api_list"), + {"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("kick:campaign_api_list"), + {"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("kick:campaign_api_list"), + {"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("kick:campaign_api_list"), + {"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("kick:campaign_api_list"), + {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("kick:campaign_api_list"), + {"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("kick:campaign_api_list"), + {"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("kick:campaign_api_list"), + {"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("kick:campaign_api_list")) + + 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("kick:campaign_api_list"), + {"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("kick:campaign_api_list")) + + 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("kick:campaign_api_list")) + + 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("kick:campaign_api_list")) + + 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("kick:campaign_api_list")) + 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 diff --git a/kick/urls.py b/kick/urls.py index 1cfa7f6..8ec4e4f 100644 --- a/kick/urls.py +++ b/kick/urls.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from django.urls import path +from kick import api from kick import views from kick.feeds import KickCampaignAtomFeed from kick.feeds import KickCampaignDiscordFeed @@ -23,6 +24,12 @@ if TYPE_CHECKING: app_name = "kick" urlpatterns: list[URLPattern | URLResolver] = [ + # /kick/api/v1/campaigns/ + path( + route="api/v1/campaigns/", + view=api.campaign_list_api, + name="campaign_api_list", + ), # /kick/ path( route="", diff --git a/templates/core/dataset_backups.html b/templates/core/dataset_backups.html index db560a2..2e6835f 100644 --- a/templates/core/dataset_backups.html +++ b/templates/core/dataset_backups.html @@ -9,9 +9,10 @@

About this dataset

This site tracks and publishes open Twitch and Kick drop campaign data.

- The exported datasets on this page are released under CC0 so you can reuse them freely. - The underlying source data is scraped from Twitch/Kick APIs and pages, so we do not control the - upstream content and cannot guarantee upstream accuracy or permanence. + Campaign metadata in the downloadable datasets and public JSON API is released under + CC0 so you can reuse it freely. The underlying source data is scraped from Twitch/Kick + APIs and pages, so we do not control the upstream content and cannot guarantee upstream accuracy or + permanence. Linked third-party images, logos, and trademarks may remain subject to separate rights.

Note that some drops has missing or incomplete data due to Twitch API limitations.

@@ -28,6 +29,48 @@ and describe what you need.

+
+

Kick JSON API

+

+ Fully imported Kick campaigns are also available through the public, paginated + Kick campaign JSON API. + Responses include campaign dates, game and organization metadata, rewards, and the complete + channel list stored for each campaign. +

+

+ Use page and page_size for pagination. Filter by Kick category ID with + game, or by the computed campaign state with + status=active, status=upcoming, or status=expired. + Page numbers and page sizes have a minimum of 1, and the maximum page size is 500. +

+
{
+  "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 }]
+  }]
+}
+

+ Timestamps use ISO 8601 in UTC and may be null when the source lacks a date. Campaigns with + incomplete dates have the computed status unknown and are not returned by a status filter. + Each item includes every stored channel and raw reward without RSS truncation or reward-name merging. + Consumers can stop pagination when items is empty or when + page * page_size >= total. +

+

+ An empty channels list means that no channel restriction data is stored. It does not, + by itself, guarantee that a campaign is available on every channel. +

+
{% if datasets %} From c54ceeb7a8055025ee05608cf534d31df7b047e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Tue, 21 Jul 2026 04:05:35 +0200 Subject: [PATCH 2/4] Move all the APIs under the same router --- config/api.py | 19 + config/tests/test_site_endpoint_smoke.py | 20 +- config/urls.py | 5 + core/views.py | 25 + kick/api.py | 693 +++++++++++++++---- kick/tests/test_api.py | 509 ++++++++++++-- kick/urls.py | 16 +- pyproject.toml | 4 + templates/base.html | 5 +- templates/core/dashboard.html | 2 +- templates/core/dataset_backups.html | 83 ++- templates/core/debug.html | 32 + templates/core/docs_rss.html | 2 +- templates/twitch/campaign_detail.html | 2 +- templates/twitch/campaign_list.html | 2 +- templates/twitch/dashboard.html | 2 +- templates/twitch/game_detail.html | 2 +- templates/twitch/games_grid.html | 2 +- templates/twitch/games_list.html | 2 +- templates/twitch/org_list.html | 2 +- templates/twitch/reward_campaign_detail.html | 2 +- templates/twitch/reward_campaign_list.html | 2 +- twitch/api.py | 15 +- twitch/models.py | 4 + twitch/tests/test_api.py | 154 ++--- twitch/tests/test_views.py | 4 +- twitch/urls.py | 28 +- 27 files changed, 1288 insertions(+), 350 deletions(-) create mode 100644 config/api.py diff --git a/config/api.py b/config/api.py new file mode 100644 index 0000000..1d96550 --- /dev/null +++ b/config/api.py @@ -0,0 +1,19 @@ +"""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] diff --git a/config/tests/test_site_endpoint_smoke.py b/config/tests/test_site_endpoint_smoke.py index 80be912..2cc7b54 100644 --- a/config/tests/test_site_endpoint_smoke.py +++ b/config/tests/test_site_endpoint_smoke.py @@ -276,7 +276,25 @@ class SiteEndpointSmokeTest(TestCase): ("twitch:export_organizations_csv", {}, 200), ("twitch:export_organizations_json", {}, 200), # Kick endpoints - ("kick:campaign_api_list", {}, 200), + ("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:campaign_list", {}, 200), ("kick:campaign_detail", {"kick_id": self.kick_campaign.kick_id}, 200), diff --git a/config/urls.py b/config/urls.py index f4d56ad..be4ce90 100644 --- a/config/urls.py +++ b/config/urls.py @@ -43,6 +43,11 @@ urlpatterns: list[URLPattern | URLResolver] = [ view=core_views.sitemap_youtube_view, name="sitemap-youtube", ), + # API v1 + path( + "api/v1/", + include("config.api", namespace="api-v1"), + ), # Core app path(route="", view=include("core.urls", namespace="core")), # Twitch app diff --git a/core/views.py b/core/views.py index 6b20b55..963ebe3 100644 --- a/core/views.py +++ b/core/views.py @@ -705,6 +705,28 @@ def debug_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914 miner_only_rewards: QuerySet[RewardCampaign] = RewardCampaign.objects.filter( twitch_id__in=miner_only_reward_ids, ).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] = { "now": now, "games_without_owner": games_without_owner, @@ -725,6 +747,9 @@ def debug_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914 "miner_only_rewards_count": len(miner_only_reward_ids), "sunkibot_only_rewards": sunkibot_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( diff --git a/kick/api.py b/kick/api.py index 3063926..50b0065 100644 --- a/kick/api.py +++ b/kick/api.py @@ -1,41 +1,219 @@ from __future__ import annotations -from datetime import UTC +import datetime # ruff: ignore[typing-only-standard-library-import] from typing import TYPE_CHECKING -from typing import Any from typing import Literal -from django.http import JsonResponse +from django.db import models +from django.http import Http404 +from django.shortcuts import get_object_or_404 from django.utils import timezone -from django.views.decorators.http import require_GET +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 datetime import datetime - from django.db.models import QuerySet from django.http import HttpRequest - from kick.models import KickCategory - from kick.models import KickChannel - from kick.models import KickOrganization from kick.models import KickReward - from kick.models import KickUser CampaignStatus = Literal["active", "upcoming", "expired", "unknown"] -VALID_STATUS_FILTERS: frozenset[str] = frozenset({"active", "upcoming", "expired"}) +CampaignStatusFilter = Literal["active", "upcoming", "expired", "unknown"] +VALID_STATUS_FILTERS: frozenset[str] = frozenset({ + "active", + "upcoming", + "expired", + "unknown", +}) DEFAULT_PAGE_SIZE = 100 MAX_PAGE_SIZE = 500 - -def _datetime_to_json(value: datetime | None) -> str | None: - if value is None: - return None - return value.astimezone(UTC).isoformat().replace("+00:00", "Z") +api = Router() -def _campaign_status(campaign: KickDropCampaign, now: datetime) -> CampaignStatus: +# 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" @@ -45,137 +223,172 @@ def _campaign_status(campaign: KickDropCampaign, now: datetime) -> CampaignStatu return "unknown" -def _serialize_category(category: KickCategory | None) -> dict[str, Any] | None: +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 { - "kick_id": category.kick_id, - "name": category.name, - "slug": category.slug, - "image_url": category.image_url, - } + return V1CategorySchema( + kick_id=category.kick_id, + name=category.name, + slug=category.slug, + image_url=category.image_url, + ) -def _serialize_organization( +def _serialize_organization_summary( organization: KickOrganization | None, -) -> dict[str, Any] | None: +) -> V1OrganizationSummarySchema | None: if organization is None: return None - return { - "kick_id": organization.kick_id, - "name": organization.name, - "logo_url": organization.logo_url, - "url": organization.url, - } + return V1OrganizationSummarySchema( + kick_id=organization.kick_id, + name=organization.name, + logo_url=organization.logo_url, + url=organization.url, + ) -def _serialize_user(user: KickUser | None) -> dict[str, Any] | None: +def _serialize_user(user: KickUser | None) -> V1UserSchema | None: if user is None: return None - return { - "kick_id": user.kick_id, - "username": user.username, - "profile_picture": user.profile_picture, - } + return V1UserSchema( + kick_id=user.kick_id, + username=user.username, + profile_picture=user.profile_picture, + ) -def _serialize_channel(channel: KickChannel) -> dict[str, Any]: - return { - "kick_id": channel.kick_id, - "slug": channel.slug, - "url": channel.channel_url, - "user": _serialize_user(channel.user), - } +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) -> dict[str, Any]: - return { - "kick_id": reward.kick_id, - "name": reward.name, - "image_url": reward.full_image_url, - "required_minutes_watched": reward.required_units, - } +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, -) -> dict[str, Any]: - return { - "kick_id": campaign.kick_id, - "name": campaign.name, - "status": _campaign_status(campaign, now), - "image_url": campaign.image_url, - "connect_url": campaign.connect_url, - "url": campaign.url, - "starts_at": _datetime_to_json(campaign.starts_at), - "ends_at": _datetime_to_json(campaign.ends_at), - "category": _serialize_category(campaign.category), - "organization": _serialize_organization(campaign.organization), - "channels": [ - _serialize_channel(channel) for channel in campaign.channels.all() + 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] ], - "rewards": [_serialize_reward(reward) for reward in campaign.rewards.all()], - "is_fully_imported": campaign.is_fully_imported, - "added_at": _datetime_to_json(campaign.added_at), - "updated_at": _datetime_to_json(campaign.updated_at), - } + added_at=campaign.added_at, + updated_at=campaign.updated_at, + ) -def _parse_integer_query( +# 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, - name: str, - default: int, -) -> int | JsonResponse: - raw_value: str | None = request.GET.get(name) - if raw_value is None: - return default - try: - return int(raw_value) - except ValueError: - return JsonResponse( - { - "detail": [ - { - "type": "int_parsing", - "loc": ["query", name], - "msg": "Input should be a valid integer", - }, - ], - }, - status=422, - ) - - -@require_GET -def campaign_list_api(request: HttpRequest) -> JsonResponse: - """Return a paginated JSON representation of fully imported Kick campaigns.""" - page = _parse_integer_query(request, "page", 1) - if isinstance(page, JsonResponse): - return page - page = max(page, 1) - - page_size = _parse_integer_query(request, "page_size", DEFAULT_PAGE_SIZE) - if isinstance(page_size, JsonResponse): - return page_size - page_size = min(max(page_size, 1), MAX_PAGE_SIZE) - - status_filter: str | None = request.GET.get("status") - if status_filter is not None and status_filter not in VALID_STATUS_FILTERS: - return JsonResponse( - { - "detail": [ - { - "type": "literal_error", - "loc": ["query", "status"], - "msg": "Input should be 'active', 'upcoming' or 'expired'", - }, - ], - }, - status=422, - ) - - now: datetime = timezone.now() + 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) @@ -184,30 +397,214 @@ def campaign_list_api(request: HttpRequest) -> JsonResponse: .order_by("-starts_at", "kick_id") ) - game_filter: int | JsonResponse | None = None - if request.GET.get("game") is not None: - game_filter = _parse_integer_query(request, "game", 0) - if isinstance(game_filter, JsonResponse): - return game_filter - if game_filter is not None: - queryset = queryset.filter(category__kick_id=game_filter) + 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) - if status_filter == "active": - queryset = queryset.filter(starts_at__lte=now, ends_at__gte=now) - elif status_filter == "upcoming": - queryset = queryset.filter(starts_at__gt=now, ends_at__isnull=False) - elif status_filter == "expired": - queryset = queryset.filter(starts_at__lte=now, ends_at__lt=now) + queryset = _apply_status_filter(queryset, status, now) - total: int = queryset.count() - offset: int = (page - 1) * page_size - campaigns: list[KickDropCampaign] = ( - list(queryset[offset : offset + page_size]) if offset < total else [] + items, total, current_page, current_page_size = _paginate( + queryset, + page=page, + page_size=page_size, ) - return JsonResponse({ - "total": total, - "page": page, - "page_size": page_size, - "items": [_serialize_campaign(campaign, now) for campaign in campaigns], - }) + 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], + ) diff --git a/kick/tests/test_api.py b/kick/tests/test_api.py index 76d829d..ee63ae2 100644 --- a/kick/tests/test_api.py +++ b/kick/tests/test_api.py @@ -84,66 +84,63 @@ class KickCampaignApiTest(TestCase): "kick.api.timezone.now", return_value=datetime(2026, 7, 15, tzinfo=UTC), ): - response = self.client.get(reverse("kick:campaign_api_list")) + response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns")) assert response.status_code == 200 - assert response["Content-Type"] == "application/json" - assert response.json() == { - "total": 1, - "page": 1, - "page_size": 100, - "items": [ - { - "kick_id": "campaign-api", - "name": "API Campaign", - "status": "active", - "image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png", - "connect_url": "https://example.com/connect", - "url": "https://example.com/campaign", - "starts_at": "2026-07-01T00:00:00Z", - "ends_at": "2026-08-01T00:00:00Z", - "category": { - "kick_id": 123, - "name": "API Game", - "slug": "api-game", - "image_url": "https://example.com/game.png", - }, - "organization": { - "kick_id": "org-api", - "name": "API Organization", - "logo_url": "https://example.com/org.png", - "url": "https://example.com/org", - }, - "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", - }, - }, - ], - "rewards": [ - { - "kick_id": "reward-api", - "name": "API Reward", - "image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png", - "required_minutes_watched": 30, - }, - ], - "is_fully_imported": True, - "added_at": self.campaign.added_at.isoformat().replace( - "+00:00", "Z" - ), - "updated_at": self.campaign.updated_at.isoformat().replace( - "+00:00", "Z" - ), - }, - ], + 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.""" @@ -162,7 +159,7 @@ class KickCampaignApiTest(TestCase): ) response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"game": self.category.kick_id}, ) @@ -204,12 +201,12 @@ class KickCampaignApiTest(TestCase): for status, expected_id in expected_ids.items(): with self.subTest(status=status): response = self.client.get( - reverse("kick:campaign_api_list"), + 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 + expected_id, ] def test_status_filters_exclude_campaigns_with_partial_dates(self) -> None: @@ -240,7 +237,7 @@ class KickCampaignApiTest(TestCase): for status in ("upcoming", "expired"): with self.subTest(status=status): response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"status": status}, ) assert all( @@ -261,7 +258,7 @@ class KickCampaignApiTest(TestCase): ) response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"page": 2, "page_size": 1}, ) @@ -277,7 +274,7 @@ class KickCampaignApiTest(TestCase): def test_clamps_page_size(self) -> None: """A caller cannot request an unbounded response page.""" response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"page_size": 1000}, ) @@ -287,7 +284,7 @@ class KickCampaignApiTest(TestCase): def test_clamps_pagination_minimums(self) -> None: """Zero and negative pagination values use the first one-item page.""" response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"page": 0, "page_size": -1}, ) @@ -300,7 +297,7 @@ class KickCampaignApiTest(TestCase): for field in ("page", "page_size"): with self.subTest(field=field): response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {field: "not-an-integer"}, ) assert response.status_code == 422 @@ -311,7 +308,7 @@ class KickCampaignApiTest(TestCase): page = 10**100 response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"page": page}, ) @@ -322,7 +319,7 @@ class KickCampaignApiTest(TestCase): def test_rejects_invalid_status(self) -> None: """Unknown status values return a client error instead of an empty result.""" response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"status": "running"}, ) @@ -332,7 +329,7 @@ class KickCampaignApiTest(TestCase): def test_rejects_empty_status(self) -> None: """An explicitly empty status is invalid rather than an omitted filter.""" response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"status": ""}, ) @@ -340,14 +337,14 @@ class KickCampaignApiTest(TestCase): def test_rejects_non_get_methods(self) -> None: """The campaign feed is a read-only endpoint.""" - response = self.client.post(reverse("kick:campaign_api_list")) + 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("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"game": "not-an-id"}, ) @@ -363,7 +360,7 @@ class KickCampaignApiTest(TestCase): ) self.campaign.channels.add(channel) - response = self.client.get(reverse("kick:campaign_api_list")) + response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns")) channels = response.json()["items"][0]["channels"] assert len(channels) == 7 @@ -383,7 +380,7 @@ class KickCampaignApiTest(TestCase): campaign=self.campaign, ) - response = self.client.get(reverse("kick:campaign_api_list")) + 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] == [ @@ -404,7 +401,7 @@ class KickCampaignApiTest(TestCase): is_fully_imported=True, ) - response = self.client.get(reverse("kick:campaign_api_list")) + response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns")) item = next( item @@ -418,7 +415,7 @@ class KickCampaignApiTest(TestCase): def select_count() -> int: with CaptureQueriesContext(connection) as queries: - response = self.client.get(reverse("kick:campaign_api_list")) + 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") @@ -447,3 +444,367 @@ class KickCampaignApiTest(TestCase): 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 diff --git a/kick/urls.py b/kick/urls.py index 8ec4e4f..6481ae8 100644 --- a/kick/urls.py +++ b/kick/urls.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING from django.urls import path +from django.views.generic.base import RedirectView -from kick import api from kick import views from kick.feeds import KickCampaignAtomFeed from kick.feeds import KickCampaignDiscordFeed @@ -24,18 +24,20 @@ if TYPE_CHECKING: app_name = "kick" urlpatterns: list[URLPattern | URLResolver] = [ - # /kick/api/v1/campaigns/ - path( - route="api/v1/campaigns/", - view=api.campaign_list_api, - name="campaign_api_list", - ), # /kick/ path( route="", view=views.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/ path( route="campaigns/", diff --git a/pyproject.toml b/pyproject.toml index dcaf47e..edf95f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,7 @@ lint.ignore = [ [tool.ruff.lint.per-file-ignores] "**/tests/**" = [ "ARG", + "D102", "FBT", "PLR0904", "PLR2004", @@ -124,6 +125,9 @@ lint.ignore = [ "SLF001", ] "**/migrations/**" = ["RUF012"] +"**/api.py" = [ + "PLR0913", # Allow many arguments for API endpoint functions +] [tool.djlint] profile = "django" diff --git a/templates/base.html b/templates/base.html index 9fe04e5..12e5b7d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -203,6 +203,7 @@ Dataset | Donate | GitHub | + API Docs |
Channels | Badges | Emotes | - API Docs | Inventory
Kick @@ -234,10 +234,7 @@ Campaigns
Other sites - Steam | YouTube | - TikTok | - Discord
{% if messages %} diff --git a/templates/core/dashboard.html b/templates/core/dashboard.html index c19674e..1c95756 100644 --- a/templates/core/dashboard.html +++ b/templates/core/dashboard.html @@ -47,7 +47,7 @@ title="Atom feed for Twitch campaigns">[atom] [discord] - [api] [explain] diff --git a/templates/core/dataset_backups.html b/templates/core/dataset_backups.html index 2e6835f..a8a1433 100644 --- a/templates/core/dataset_backups.html +++ b/templates/core/dataset_backups.html @@ -7,40 +7,46 @@

Dataset Backups

About this dataset

-

This site tracks and publishes open Twitch and Kick drop campaign data.

+

This site tracks and publishes open drop campaign data from Twitch and Kick.

Campaign metadata in the downloadable datasets and public JSON API is released under - CC0 so you can reuse it freely. The underlying source data is scraped from Twitch/Kick - APIs and pages, so we do not control the upstream content and cannot guarantee upstream accuracy or + CC0 so you can reuse it freely. The underlying source data is scraped from Twitch and Kick + APIs and pages; we do not control the upstream content and cannot guarantee its accuracy or permanence. Linked third-party images, logos, and trademarks may remain subject to separate rights.

-

Note that some drops has missing or incomplete data due to Twitch API limitations.

+

Note that some drops have missing or incomplete data due to Twitch API limitations.

Reward campaign data is sourced from SunkwiBOT/twitch-drops-api. - Raw JSON exports are available in the repository: + Raw JSON exports are available in the same repository as drops.json and rewards.json.

- Need a special format for your workflow or research pipeline? - Contact me via GitHub issues + Kick data is fetched directly from the public + Kick campaigns API + on a recurring schedule. +

+

+ Need a custom format for your workflow or research pipeline? + Open a GitHub issue and describe what you need.

Kick JSON API

- Fully imported Kick campaigns are also available through the public, paginated - Kick campaign JSON API. - Responses include campaign dates, game and organization metadata, rewards, and the complete + Fully imported Kick campaigns are also available through the public paginated + Kick campaign JSON API. + Responses include campaign dates, game and organization metadata, rewards, and the full channel list stored for each campaign.

Use page and page_size for pagination. Filter by Kick category ID with game, or by the computed campaign state with - status=active, status=upcoming, or status=expired. + status=active, status=upcoming, status=expired, + or status=unknown. Page numbers and page sizes have a minimum of 1, and the maximum page size is 500.

{
@@ -60,15 +66,60 @@
   }]
 }

- Timestamps use ISO 8601 in UTC and may be null when the source lacks a date. Campaigns with - incomplete dates have the computed status unknown and are not returned by a status filter. + Timestamps use ISO 8601 in UTC and may be null when the source does not provide a date. Campaigns with + incomplete dates receive the computed status unknown 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 items is empty or when page * page_size >= total.

- An empty channels list means that no channel restriction data is stored. It does not, - by itself, guarantee that a campaign is available on every channel. + An empty channels list means no channel restriction data is stored. It does not + by itself guarantee that a campaign is available on every channel. +

+
+
+

Twitch JSON API

+

+ Fully imported Twitch campaigns are available through the public paginated + Twitch campaign JSON API. + Responses include campaign dates, game and organization metadata, allowed channels, drops, + and rewards for each campaign. +

+

+ Use page and page_size for pagination. Filter by Twitch game ID with + game, or by the computed campaign state with + status=active, status=upcoming, status=expired, + or status=unknown. + Page numbers and page sizes have a minimum of 1, and the maximum page size is 500. +

+
{
+  "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": ["..."] }]
+  }]
+}
+

+ Timestamps use ISO 8601 in UTC and may be null when the source does not provide a date. Campaigns with + incomplete dates receive the computed status unknown and are excluded from status-based filtering. +

+

+ Additional endpoints include + games, + organizations, + channels, + reward campaigns, and + chat badges + all public and paginated. Each list endpoint has a corresponding detail view by ID.

{% if datasets %} @@ -97,7 +148,7 @@ {% endfor %}
-

Found {{ dataset_count }} datasets.

+

{{ dataset_count }} dataset{% if dataset_count != 1 %}s{% endif %} available.

{% else %}

No dataset backups found.

{% endif %} diff --git a/templates/core/debug.html b/templates/core/debug.html index 32c41e3..c62e4b7 100644 --- a/templates/core/debug.html +++ b/templates/core/debug.html @@ -127,6 +127,38 @@

None ✅

{% endif %} +
+

Kick Campaigns With Unknown Status ({{ kick_unknown_campaigns|length }})

+ {% if kick_unknown_campaigns %} + + {% else %} +

None ✅

+ {% endif %} +
+
+

Twitch Reward Campaigns With Unknown Status ({{ twitch_reward_unknown_campaigns|length }})

+ {% if twitch_reward_unknown_campaigns %} + + {% else %} +

None ✅

+ {% endif %} +

Duplicate Campaign Names Per Game ({{ duplicate_name_campaigns|length }})

{% if duplicate_name_campaigns %} diff --git a/templates/core/docs_rss.html b/templates/core/docs_rss.html index 6d3d163..49e41e2 100644 --- a/templates/core/docs_rss.html +++ b/templates/core/docs_rss.html @@ -18,7 +18,7 @@

Twitch JSON API documentation is available at - /twitch/api/v1/docs. + /api/v1/docs/.

Twitch campaign feeds accept ?limit=50 to change item count and diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index 85ec252..94fa6eb 100644 --- a/templates/twitch/campaign_detail.html +++ b/templates/twitch/campaign_detail.html @@ -121,7 +121,7 @@ [discord] [explain] - [api] {% endif %} diff --git a/templates/twitch/campaign_list.html b/templates/twitch/campaign_list.html index 9fbe54d..b2ce75b 100644 --- a/templates/twitch/campaign_list.html +++ b/templates/twitch/campaign_list.html @@ -33,7 +33,7 @@ title="Atom feed for all campaigns">[atom] [discord] - [api] [explain] [atom] [discord] - [api] [explain] diff --git a/templates/twitch/game_detail.html b/templates/twitch/game_detail.html index fd52995..a5b7e56 100644 --- a/templates/twitch/game_detail.html +++ b/templates/twitch/game_detail.html @@ -91,7 +91,7 @@ title="Discord feed for {{ game.display_name }} rewards">[discord]

- [api] [explain]
diff --git a/templates/twitch/games_grid.html b/templates/twitch/games_grid.html index b85eb34..fd60bd8 100644 --- a/templates/twitch/games_grid.html +++ b/templates/twitch/games_grid.html @@ -32,7 +32,7 @@ title="Atom feed for all games">[atom] [discord] - [api] [explain] [atom] [discord] - [api] [explain] [atom] [discord] - [api] [explain] [atom] [discord] - [api] [explain] {% if reward_campaign.external_url %} diff --git a/templates/twitch/reward_campaign_list.html b/templates/twitch/reward_campaign_list.html index 24c1d11..b6e25bf 100644 --- a/templates/twitch/reward_campaign_list.html +++ b/templates/twitch/reward_campaign_list.html @@ -30,7 +30,7 @@
[explain] - [api]
diff --git a/twitch/api.py b/twitch/api.py index 96a409d..4e94366 100644 --- a/twitch/api.py +++ b/twitch/api.py @@ -9,7 +9,7 @@ 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 NinjaAPI +from ninja import Router from ninja import Schema from twitch.models import Channel @@ -28,7 +28,7 @@ if TYPE_CHECKING: from twitch.models import DropBenefit from twitch.models import TimeBasedDrop -V1StatusFilter = Literal["active", "upcoming", "expired"] +V1StatusFilter = Literal["active", "upcoming", "expired", "unknown"] DEFAULT_PAGE_SIZE = 100 MAX_PAGE_SIZE = 500 @@ -253,11 +253,7 @@ class V1ChatBadgeSetListSchema(V1PaginationSchema): items: list[V1ChatBadgeSetSchema] -api = NinjaAPI( - title="TTVDrops Twitch API", - version="1.0.0", - urls_namespace="twitch:twitch-api-v1", -) +api = Router() def _paginate[ModelT: models.Model]( @@ -305,6 +301,11 @@ def _apply_status_filter[ModelT: models.Model]( return queryset.filter(**{f"{start_field}__gt": now}) if status == "expired": 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 diff --git a/twitch/models.py b/twitch/models.py index 6aeb0f1..934d169 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -801,6 +801,10 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 queryset = queryset.filter(start_at__gt=now) elif status == "expired": 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 @classmethod diff --git a/twitch/tests/test_api.py b/twitch/tests/test_api.py index a753a34..b6b847b 100644 --- a/twitch/tests/test_api.py +++ b/twitch/tests/test_api.py @@ -179,7 +179,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_campaign_list(self) -> None: """Return active campaigns from the v1 list endpoint.""" - response = self.client.get("/twitch/api/v1/campaigns/?status=active") + response = self.client.get("/api/v1/twitch/campaigns/?status=active") assert response.status_code == 200 assert "Content-Disposition" not in response @@ -193,7 +193,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_campaign_list_filters_by_game(self) -> None: """Filter campaigns by game twitch_id.""" response = self.client.get( - f"/twitch/api/v1/campaigns/?game={self.game.twitch_id}", + f"/api/v1/twitch/campaigns/?game={self.game.twitch_id}", ) assert response.status_code == 200 @@ -203,7 +203,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_campaign_list_game_filter_with_no_matches(self) -> None: """Return empty list when game filter matches no campaigns.""" - response = self.client.get("/twitch/api/v1/campaigns/?game=nonexistent") + response = self.client.get("/api/v1/twitch/campaigns/?game=nonexistent") assert response.status_code == 200 data = response.json() @@ -223,7 +223,7 @@ class TwitchApiV1TestCase(TestCase): is_fully_imported=True, ) - response = self.client.get("/twitch/api/v1/campaigns/?status=upcoming") + response = self.client.get("/api/v1/twitch/campaigns/?status=upcoming") assert response.status_code == 200 data = response.json() @@ -243,7 +243,7 @@ class TwitchApiV1TestCase(TestCase): is_fully_imported=True, ) - response = self.client.get("/twitch/api/v1/campaigns/?status=expired") + response = self.client.get("/api/v1/twitch/campaigns/?status=expired") assert response.status_code == 200 data = response.json() @@ -252,7 +252,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_campaign_list_default_page_size(self) -> None: """Return all campaigns when no page_size is specified.""" - response = self.client.get("/twitch/api/v1/campaigns/") + response = self.client.get("/api/v1/twitch/campaigns/") assert response.status_code == 200 data = response.json() @@ -261,7 +261,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_campaign_list_page_size_param(self) -> None: """Respect custom page_size parameter.""" - response = self.client.get("/twitch/api/v1/campaigns/?page_size=1") + response = self.client.get("/api/v1/twitch/campaigns/?page_size=1") assert response.status_code == 200 data = response.json() @@ -270,7 +270,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_campaign_list_summary_fields(self) -> None: """Return correct field shape for campaign summary items.""" - response = self.client.get("/twitch/api/v1/campaigns/") + response = self.client.get("/api/v1/twitch/campaigns/") assert response.status_code == 200 data = response.json() @@ -292,7 +292,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_campaign_detail(self) -> None: """Return nested campaign detail data from the v1 endpoint.""" - response = self.client.get("/twitch/api/v1/campaigns/campaign123/") + response = self.client.get("/api/v1/twitch/campaigns/campaign123/") assert response.status_code == 200 data = response.json() @@ -322,7 +322,7 @@ class TwitchApiV1TestCase(TestCase): update_fields=["box_art_file", "box_art_width", "box_art_height"], ) - response = self.client.get("/twitch/api/v1/campaigns/campaign123/") + response = self.client.get("/api/v1/twitch/campaigns/campaign123/") assert response.status_code == 200 data = response.json() @@ -337,7 +337,7 @@ class TwitchApiV1TestCase(TestCase): one extra query per benefit. """ with CaptureQueriesContext(connection) as capture: - response = self.client.get("/twitch/api/v1/campaigns/campaign123/") + response = self.client.get("/api/v1/twitch/campaigns/campaign123/") assert response.status_code == 200 data = response.json() @@ -361,26 +361,26 @@ class TwitchApiV1TestCase(TestCase): """Exercise all v1 routes with enough rows to catch deferred loads.""" self._create_secondary_api_fixture() list_urls: list[tuple[str, int]] = [ - ("/twitch/api/v1/campaigns/?page_size=50", 2), - ("/twitch/api/v1/games/?page_size=50", 2), - ("/twitch/api/v1/organizations/?page_size=50", 2), - ("/twitch/api/v1/channels/?page_size=50", 2), - ("/twitch/api/v1/reward-campaigns/?page_size=50", 2), - ("/twitch/api/v1/badges/?page_size=50", 2), + ("/api/v1/twitch/campaigns/?page_size=50", 2), + ("/api/v1/twitch/games/?page_size=50", 2), + ("/api/v1/twitch/organizations/?page_size=50", 2), + ("/api/v1/twitch/channels/?page_size=50", 2), + ("/api/v1/twitch/reward-campaigns/?page_size=50", 2), + ("/api/v1/twitch/badges/?page_size=50", 2), ] detail_urls = [ - "/twitch/api/v1/campaigns/campaign123/", - "/twitch/api/v1/campaigns/campaign456/", - "/twitch/api/v1/games/game123/", - "/twitch/api/v1/games/game456/", - "/twitch/api/v1/organizations/org123/", - "/twitch/api/v1/organizations/org456/", - "/twitch/api/v1/channels/channel123/", - "/twitch/api/v1/channels/channel456/", - "/twitch/api/v1/reward-campaigns/reward123/", - "/twitch/api/v1/reward-campaigns/reward456/", - "/twitch/api/v1/badges/test-badge-set/", - "/twitch/api/v1/badges/second-badge-set/", + "/api/v1/twitch/campaigns/campaign123/", + "/api/v1/twitch/campaigns/campaign456/", + "/api/v1/twitch/games/game123/", + "/api/v1/twitch/games/game456/", + "/api/v1/twitch/organizations/org123/", + "/api/v1/twitch/organizations/org456/", + "/api/v1/twitch/channels/channel123/", + "/api/v1/twitch/channels/channel456/", + "/api/v1/twitch/reward-campaigns/reward123/", + "/api/v1/twitch/reward-campaigns/reward456/", + "/api/v1/twitch/badges/test-badge-set/", + "/api/v1/twitch/badges/second-badge-set/", ] for url, expected_total in list_urls: @@ -395,21 +395,21 @@ class TwitchApiV1TestCase(TestCase): assert response.status_code == 200, url assert response.json() - schema_response = self.client.get(reverse("twitch:twitch-api-v1:openapi-json")) + schema_response = self.client.get(reverse("api-v1:openapi-json")) assert schema_response.status_code == 200 assert schema_response.json() - docs_response = self.client.get(reverse("twitch:twitch-api-v1:openapi-view")) + docs_response = self.client.get(reverse("api-v1:openapi-view")) assert docs_response.status_code == 200 def test_v1_collection_endpoints(self) -> None: """Return v1 list responses for all Twitch API collections.""" checks = [ - ("/twitch/api/v1/games/", "game123"), - ("/twitch/api/v1/organizations/", "org123"), - ("/twitch/api/v1/channels/", "channel123"), - ("/twitch/api/v1/reward-campaigns/", "reward123"), - ("/twitch/api/v1/badges/", "test-badge-set"), + ("/api/v1/twitch/games/", "game123"), + ("/api/v1/twitch/organizations/", "org123"), + ("/api/v1/twitch/channels/", "channel123"), + ("/api/v1/twitch/reward-campaigns/", "reward123"), + ("/api/v1/twitch/badges/", "test-badge-set"), ] for url, expected_id in checks: @@ -422,14 +422,14 @@ class TwitchApiV1TestCase(TestCase): ) assert actual_id == expected_id - games_response = self.client.get("/twitch/api/v1/games/") + games_response = self.client.get("/api/v1/twitch/games/") games_data = games_response.json() assert games_data["items"][0]["campaign_count"] == 1 assert games_data["items"][0]["active_campaign_count"] == 1 def test_v1_organization_detail_includes_games_and_campaigns(self) -> None: """Return concrete game counts and detailed organization campaigns.""" - response = self.client.get("/twitch/api/v1/organizations/org123/") + response = self.client.get("/api/v1/twitch/organizations/org123/") assert response.status_code == 200 data = response.json() @@ -447,8 +447,8 @@ class TwitchApiV1TestCase(TestCase): def test_v1_game_and_channel_detail_include_campaign_data(self) -> None: """Return campaign API fields on game and channel detail responses.""" checks = [ - "/twitch/api/v1/games/game123/", - "/twitch/api/v1/channels/channel123/", + "/api/v1/twitch/games/game123/", + "/api/v1/twitch/channels/channel123/", ] for url in checks: @@ -463,16 +463,16 @@ class TwitchApiV1TestCase(TestCase): def test_v1_detail_not_found(self) -> None: """Return 404 for missing v1 campaign detail records.""" - response = self.client.get("/twitch/api/v1/campaigns/missing/") + response = self.client.get("/api/v1/twitch/campaigns/missing/") assert response.status_code == 404 def test_v1_docs_endpoint(self) -> None: - """Render the versioned Twitch API documentation page.""" - response = self.client.get("/twitch/api/v1/docs") + """Render the combined TTVDrops API documentation page.""" + response = self.client.get("/api/v1/docs/") assert response.status_code == 200 - assert reverse("twitch:twitch-api-v1:openapi-json") in response.content.decode() + assert reverse("api-v1:openapi-json") in response.content.decode() def test_v1_docs_links_render_on_twitch_pages(self) -> None: """Expose API docs in nav and resource API links in feed link groups.""" @@ -480,27 +480,27 @@ class TwitchApiV1TestCase(TestCase): ( reverse("core:docs_rss"), "API Docs", - "/twitch/api/v1/docs", - reverse("twitch:twitch-api-v1:openapi-view"), + "/api/v1/docs/", + reverse("api-v1:openapi-view"), ), ( reverse("twitch:dashboard"), "API Docs", "[api]", - reverse("twitch:twitch-api-v1:list_campaigns"), + reverse("api-v1:twitch-api-v1_list_campaigns"), ), ( reverse("twitch:campaign_list"), "API Docs", "[api]", - reverse("twitch:twitch-api-v1:list_campaigns"), + reverse("api-v1:twitch-api-v1_list_campaigns"), ), ( reverse("twitch:campaign_detail", args=[self.campaign.twitch_id]), "API Docs", "[api]", reverse( - "twitch:twitch-api-v1:get_campaign", + "api-v1:twitch-api-v1_get_campaign", args=[self.campaign.twitch_id], ), ), @@ -508,25 +508,25 @@ class TwitchApiV1TestCase(TestCase): reverse("twitch:game_detail", args=[self.game.twitch_id]), "API Docs", "[api]", - reverse("twitch:twitch-api-v1:get_game", args=[self.game.twitch_id]), + reverse("api-v1:twitch-api-v1_get_game", args=[self.game.twitch_id]), ), ( reverse("twitch:games_grid"), "API Docs", "[api]", - reverse("twitch:twitch-api-v1:list_games"), + reverse("api-v1:twitch-api-v1_list_games"), ), ( reverse("twitch:org_list"), "API Docs", "[api]", - reverse("twitch:twitch-api-v1:list_organizations"), + reverse("api-v1:twitch-api-v1_list_organizations"), ), ( reverse("twitch:reward_campaign_list"), "API Docs", "[api]", - reverse("twitch:twitch-api-v1:list_reward_campaigns"), + reverse("api-v1:twitch-api-v1_list_reward_campaigns"), ), ( reverse( @@ -536,7 +536,7 @@ class TwitchApiV1TestCase(TestCase): "API Docs", "[api]", reverse( - "twitch:twitch-api-v1:get_reward_campaign", + "api-v1:twitch-api-v1_get_reward_campaign", args=[self.reward_campaign.twitch_id], ), ), @@ -546,7 +546,7 @@ class TwitchApiV1TestCase(TestCase): response = self.client.get(url) assert response.status_code == 200 content = response.content.decode() - assert reverse("twitch:twitch-api-v1:openapi-view") in content + assert reverse("api-v1:openapi-view") in content assert api_href in content assert nav_text in content assert feed_text in content @@ -560,7 +560,7 @@ class TwitchApiV1TestCase(TestCase): assert response.status_code == 200 content = response.content.decode() campaign_api_url = reverse( - "twitch:twitch-api-v1:get_campaign", + "api-v1:twitch-api-v1_get_campaign", args=[self.campaign.twitch_id], ) assert f'href="{campaign_api_url}"' in content @@ -570,7 +570,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_game_list_fields(self) -> None: """Return correct field shape for game list items.""" - response = self.client.get("/twitch/api/v1/games/") + response = self.client.get("/api/v1/twitch/games/") assert response.status_code == 200 data = response.json() @@ -590,7 +590,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_game_list_pagination(self) -> None: """Paginate game list results.""" - response = self.client.get("/twitch/api/v1/games/?page_size=1") + response = self.client.get("/api/v1/twitch/games/?page_size=1") assert response.status_code == 200 data = response.json() @@ -599,7 +599,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_game_detail_campaigns_and_owners(self) -> None: """Return campaigns and organizations in game detail.""" - response = self.client.get("/twitch/api/v1/games/game123/") + response = self.client.get("/api/v1/twitch/games/game123/") assert response.status_code == 200 data = response.json() @@ -609,7 +609,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_game_detail_not_found(self) -> None: """Return 404 for missing game.""" - response = self.client.get("/twitch/api/v1/games/nonexistent/") + response = self.client.get("/api/v1/twitch/games/nonexistent/") assert response.status_code == 404 @@ -617,7 +617,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_organization_list_fields(self) -> None: """Return correct field shape for organization list items.""" - response = self.client.get("/twitch/api/v1/organizations/") + response = self.client.get("/api/v1/twitch/organizations/") assert response.status_code == 200 data = response.json() @@ -630,7 +630,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_organization_detail_not_found(self) -> None: """Return 404 for missing organization.""" - response = self.client.get("/twitch/api/v1/organizations/nonexistent/") + response = self.client.get("/api/v1/twitch/organizations/nonexistent/") assert response.status_code == 404 @@ -638,7 +638,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_channel_list_search(self) -> None: """Filter channels by search query.""" - response = self.client.get("/twitch/api/v1/channels/?search=testchannel") + response = self.client.get("/api/v1/twitch/channels/?search=testchannel") assert response.status_code == 200 data = response.json() @@ -647,7 +647,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_channel_list_search_no_match(self) -> None: """Return empty list when search matches no channels.""" - response = self.client.get("/twitch/api/v1/channels/?search=zzzzz") + response = self.client.get("/api/v1/twitch/channels/?search=zzzzz") assert response.status_code == 200 data = response.json() @@ -656,7 +656,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_channel_list_fields(self) -> None: """Return correct field shape for channel list items.""" - response = self.client.get("/twitch/api/v1/channels/") + response = self.client.get("/api/v1/twitch/channels/") assert response.status_code == 200 data = response.json() @@ -670,7 +670,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_channel_detail_campaigns(self) -> None: """Return campaign summaries in channel detail.""" - response = self.client.get("/twitch/api/v1/channels/channel123/") + response = self.client.get("/api/v1/twitch/channels/channel123/") assert response.status_code == 200 data = response.json() @@ -680,7 +680,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_channel_detail_not_found(self) -> None: """Return 404 for missing channel.""" - response = self.client.get("/twitch/api/v1/channels/nonexistent/") + response = self.client.get("/api/v1/twitch/channels/nonexistent/") assert response.status_code == 404 @@ -689,7 +689,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_reward_campaign_list_filters_by_game(self) -> None: """Filter reward campaigns by game twitch_id.""" response = self.client.get( - f"/twitch/api/v1/reward-campaigns/?game={self.game.twitch_id}", + f"/api/v1/twitch/reward-campaigns/?game={self.game.twitch_id}", ) assert response.status_code == 200 @@ -699,7 +699,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_reward_campaign_list_status_active(self) -> None: """Filter reward campaigns by status=active.""" - response = self.client.get("/twitch/api/v1/reward-campaigns/?status=active") + response = self.client.get("/api/v1/twitch/reward-campaigns/?status=active") assert response.status_code == 200 data = response.json() @@ -719,7 +719,7 @@ class TwitchApiV1TestCase(TestCase): summary="Expired summary", ) - response = self.client.get("/twitch/api/v1/reward-campaigns/?status=expired") + response = self.client.get("/api/v1/twitch/reward-campaigns/?status=expired") assert response.status_code == 200 data = response.json() @@ -728,7 +728,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_reward_campaign_list_fields(self) -> None: """Return correct field shape for reward campaign list items.""" - response = self.client.get("/twitch/api/v1/reward-campaigns/") + response = self.client.get("/api/v1/twitch/reward-campaigns/") assert response.status_code == 200 data = response.json() @@ -748,7 +748,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_reward_campaign_detail_fields(self) -> None: """Return correct field shape for reward campaign detail.""" - response = self.client.get("/twitch/api/v1/reward-campaigns/reward123/") + response = self.client.get("/api/v1/twitch/reward-campaigns/reward123/") assert response.status_code == 200 data = response.json() @@ -774,7 +774,7 @@ class TwitchApiV1TestCase(TestCase): is_sitewide=True, ) - response = self.client.get("/twitch/api/v1/reward-campaigns/no_game_reward/") + response = self.client.get("/api/v1/twitch/reward-campaigns/no_game_reward/") assert response.status_code == 200 data = response.json() @@ -783,7 +783,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_reward_campaign_detail_not_found(self) -> None: """Return 404 for missing reward campaign.""" - response = self.client.get("/twitch/api/v1/reward-campaigns/nonexistent/") + response = self.client.get("/api/v1/twitch/reward-campaigns/nonexistent/") assert response.status_code == 404 @@ -791,7 +791,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_badge_list_fields(self) -> None: """Return correct field shape for badge set list items.""" - response = self.client.get("/twitch/api/v1/badges/") + response = self.client.get("/api/v1/twitch/badges/") assert response.status_code == 200 data = response.json() @@ -804,7 +804,7 @@ class TwitchApiV1TestCase(TestCase): def test_v1_badge_detail_fields(self) -> None: """Return correct field shape for badge set detail.""" - response = self.client.get("/twitch/api/v1/badges/test-badge-set/") + response = self.client.get("/api/v1/twitch/badges/test-badge-set/") assert response.status_code == 200 data = response.json() @@ -822,6 +822,6 @@ class TwitchApiV1TestCase(TestCase): def test_v1_badge_detail_not_found(self) -> None: """Return 404 for missing badge set.""" - response = self.client.get("/twitch/api/v1/badges/nonexistent/") + response = self.client.get("/api/v1/twitch/badges/nonexistent/") assert response.status_code == 404 diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 7e15e41..2034481 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -2618,7 +2618,7 @@ class TestRewardCampaignViews: ) assert reverse("twitch:game_detail", args=[game.twitch_id]) in content assert reverse("core:reward_campaign_feed") in content - assert reverse("twitch:twitch-api-v1:list_reward_campaigns") in content + assert reverse("api-v1:twitch-api-v1_list_reward_campaigns") in content assert response.context["reward_campaigns"].paginator.count == 2 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:twitch-api-v1:get_reward_campaign", + "api-v1:twitch-api-v1_get_reward_campaign", args=[reward.twitch_id], ) in content diff --git a/twitch/urls.py b/twitch/urls.py index b114c9e..854ac66 100644 --- a/twitch/urls.py +++ b/twitch/urls.py @@ -1,9 +1,9 @@ from typing import TYPE_CHECKING from django.urls import path +from django.views.generic.base import RedirectView from twitch import views -from twitch.api import api as twitch_api_v1 if TYPE_CHECKING: from django.urls.resolvers import URLPattern @@ -13,10 +13,32 @@ app_name = "twitch" urlpatterns: list[URLPattern | URLResolver] = [ - # /twitch/api/v1/ - path("api/v1/", twitch_api_v1.urls), # /twitch/ 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/", + RedirectView.as_view( + url="/api/v1/twitch/%(rest)s", + permanent=True, + query_string=True, + ), + ), # /twitch/badges/ path("badges/", views.badge_list_view, name="badge_list"), # /twitch/badges// From 1424978854b50d8b8ab5fd1b7a1d36292524b521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Tue, 21 Jul 2026 04:12:13 +0200 Subject: [PATCH 3/4] Update Ruff and fix its errors --- .pre-commit-config.yaml | 4 +- .../commands/import_chzzk_campaign.py | 2 +- chzzk/tasks.py | 4 +- config/celery.py | 4 +- config/tests/test_celery.py | 2 +- conftest.py | 2 +- core/tasks.py | 2 +- core/tests/test_sitemaps.py | 4 +- core/utils.py | 6 +- core/views.py | 14 ++-- kick/feeds.py | 2 +- kick/management/commands/import_kick_drops.py | 4 +- kick/models.py | 14 ++-- kick/schemas.py | 2 +- kick/tasks.py | 2 +- kick/tests/test_kick.py | 2 +- manage.py | 2 +- tools/extract_historical_drops.py | 4 +- twitch/api.py | 2 +- twitch/apps.py | 24 +++---- twitch/feeds.py | 12 ++-- .../commands/backfill_image_dimensions.py | 2 +- twitch/management/commands/backup_db.py | 10 +-- .../commands/better_import_drops.py | 8 +-- .../commands/cleanup_unknown_organizations.py | 2 +- .../management/commands/download_box_art.py | 2 +- .../management/commands/import_chat_badges.py | 2 +- .../commands/import_twitch_drops_api.py | 38 +++++------ twitch/management/commands/watch_imports.py | 4 +- ...ename_operation_name_to_operation_names.py | 4 +- .../0016_mark_all_drops_fully_imported.py | 2 +- twitch/models.py | 44 ++++++------- twitch/signals.py | 64 +++++++++++-------- twitch/tasks.py | 34 +++++----- twitch/templatetags/image_tags.py | 2 +- twitch/tests/test_feeds.py | 4 +- twitch/tests/test_migrations.py | 2 +- twitch/tests/test_views.py | 2 +- twitch/views.py | 16 ++--- 39 files changed, 182 insertions(+), 174 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f53995d..00baaa9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,13 +21,13 @@ repos: - id: trailing-whitespace - repo: https://github.com/adamchainz/django-upgrade - rev: 1.30.0 + rev: 1.31.1 hooks: - id: django-upgrade args: [--target-version, "6.0"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.17 + rev: v0.15.22 hooks: - id: ruff-check args: ["--fix", "--exit-non-zero-on-fix"] diff --git a/chzzk/management/commands/import_chzzk_campaign.py b/chzzk/management/commands/import_chzzk_campaign.py index c8944ca..d39449a 100644 --- a/chzzk/management/commands/import_chzzk_campaign.py +++ b/chzzk/management/commands/import_chzzk_campaign.py @@ -144,7 +144,7 @@ class Command(BaseCommand): Args: campaign_no (int): The campaign number to import. """ - api_version: str = "v2" # TODO(TheLovinator): Add support for v1 API # noqa: TD003 + api_version: str = "v2" # TODO(TheLovinator): Add support for v1 API # ruff:ignore[missing-todo-link] url: str = f"https://api.chzzk.naver.com/service/{api_version}/drops/campaigns/{campaign_no}" resp: requests.Response = requests.get( url, diff --git a/chzzk/tasks.py b/chzzk/tasks.py index 55f929c..a2ef415 100644 --- a/chzzk/tasks.py +++ b/chzzk/tasks.py @@ -9,7 +9,7 @@ logger: logging.Logger = logging.getLogger("ttvdrops.tasks") @shared_task(bind=True, queue="imports", max_retries=3, default_retry_delay=60) -def import_chzzk_campaign_task(self, campaign_no: int) -> None: # noqa: ANN001 +def import_chzzk_campaign_task(self, campaign_no: int) -> None: # ruff:ignore[missing-type-function-argument] """Import a single Chzzk campaign by its campaign number.""" try: call_command("import_chzzk_campaign", str(campaign_no)) @@ -18,7 +18,7 @@ def import_chzzk_campaign_task(self, campaign_no: int) -> None: # noqa: ANN001 @shared_task(bind=True, queue="api-fetches", max_retries=3, default_retry_delay=120) -def discover_chzzk_campaigns(self) -> None: # noqa: ANN001 +def discover_chzzk_campaigns(self) -> None: # ruff:ignore[missing-type-function-argument] """Discover and import the latest Chzzk campaigns (equivalent to --latest flag).""" try: call_command("import_chzzk_campaign", latest=True) diff --git a/config/celery.py b/config/celery.py index 129ab98..503b47d 100644 --- a/config/celery.py +++ b/config/celery.py @@ -23,10 +23,10 @@ classes = [ ] for cls in classes: - setattr( # noqa: B010 + setattr( # ruff:ignore[set-attr-with-constant] cls, "__class_getitem__", - classmethod(lambda cls, *args, **kwargs: cls), # noqa: ARG005 + classmethod(lambda cls, *args, **kwargs: cls), # ruff:ignore[unused-lambda-argument] ) diff --git a/config/tests/test_celery.py b/config/tests/test_celery.py index edb0a3d..332f086 100644 --- a/config/tests/test_celery.py +++ b/config/tests/test_celery.py @@ -16,7 +16,7 @@ def celery_app() -> Generator[Celery, Any]: Yields: Celery: A Celery app instance configured for testing. """ - with patch("os.environ.setdefault") as mock_setenv: # noqa: F841 + with patch("os.environ.setdefault") as mock_setenv: # ruff:ignore[unused-variable] app = Celery("config") app.config_from_object("django.conf:settings", namespace="CELERY") yield app diff --git a/conftest.py b/conftest.py index e3b4683..883c39d 100644 --- a/conftest.py +++ b/conftest.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from collections.abc import Generator -@pytest.fixture(autouse=True) # noqa: RUF076 — intentional: project-wide N+1 detection with @pytest.mark.no_zeal escape hatch +@pytest.fixture(autouse=True) def use_zeal(request: pytest.FixtureRequest) -> Generator[None]: """Enable Zeal N+1 detection context for each pytest test. diff --git a/core/tasks.py b/core/tasks.py index cf7f6f0..c26f5ae 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -9,7 +9,7 @@ logger: logging.Logger = logging.getLogger("ttvdrops.tasks") @shared_task(bind=True, queue="default", max_retries=3, default_retry_delay=300) -def submit_indexnow_task(self) -> None: # noqa: ANN001 +def submit_indexnow_task(self) -> None: # ruff:ignore[missing-type-function-argument] """Submit all site URLs to the IndexNow search index.""" try: call_command("submit_indexnow") diff --git a/core/tests/test_sitemaps.py b/core/tests/test_sitemaps.py index 7821279..e6093fa 100644 --- a/core/tests/test_sitemaps.py +++ b/core/tests/test_sitemaps.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET # noqa: S405 +import xml.etree.ElementTree as ET # ruff:ignore[suspicious-xml-etree-import] from typing import TYPE_CHECKING from django.urls import reverse @@ -9,7 +9,7 @@ if TYPE_CHECKING: def _extract_locs(xml_bytes: bytes) -> list[str]: - root = ET.fromstring(xml_bytes) # noqa: S314 + root = ET.fromstring(xml_bytes) # ruff:ignore[suspicious-xml-element-tree-usage] ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"} return [el.text for el in root.findall(".//s:loc", ns) if el.text] diff --git a/core/utils.py b/core/utils.py index 2adc70f..863dfca 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,7 +1,7 @@ from typing import Any -from xml.etree.ElementTree import Element # noqa: S405 -from xml.etree.ElementTree import SubElement # noqa: S405 -from xml.etree.ElementTree import tostring # noqa: S405 +from xml.etree.ElementTree import Element # ruff:ignore[suspicious-xml-etree-import] +from xml.etree.ElementTree import SubElement # ruff:ignore[suspicious-xml-etree-import] +from xml.etree.ElementTree import tostring # ruff:ignore[suspicious-xml-etree-import] from django.conf import settings diff --git a/core/views.py b/core/views.py index 963ebe3..e80920e 100644 --- a/core/views.py +++ b/core/views.py @@ -53,7 +53,7 @@ MIN_SEARCH_RANK = 0.05 DEFAULT_SITE_DESCRIPTION = "Archive of Twitch drops, campaigns, rewards, and more." -def _build_seo_context( # noqa: PLR0913, PLR0917 +def _build_seo_context( # ruff:ignore[too-many-arguments, too-many-positional-arguments] page_title: str = "ttvdrops", page_description: str | None = None, page_url: str | None = None, @@ -91,7 +91,7 @@ def _build_seo_context( # noqa: PLR0913, PLR0917 if page_url and not page_url.startswith("http"): page_url = f"{settings.BASE_URL}{page_url}" - # TODO(TheLovinator): Instead of having so many parameters, # noqa: TD003 + # TODO(TheLovinator): Instead of having so many parameters, # ruff:ignore[missing-todo-link] # consider having a single "seo_info" parameter that # can contain all of these optional fields. This would make # it easier to extend in the future without changing the @@ -533,7 +533,7 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse: # MARK: /debug/ -def debug_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914 +def debug_view(request: HttpRequest) -> HttpResponse: # ruff:ignore[too-many-locals] """Debug view showing potentially broken or inconsistent data. Returns: @@ -772,8 +772,8 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse: Returns: HttpResponse: The rendered dataset backups page. """ - # TODO(TheLovinator): Instead of only using sql we should also support other formats like parquet, csv, or json. # noqa: TD003 - # TODO(TheLovinator): Upload to s3 instead. # noqa: TD003 + # 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): Upload to s3 instead. # ruff:ignore[missing-todo-link] # TODO(TheLovinator): https://developers.google.com/search/docs/appearance/structured-data/dataset#json-ld datasets_root: Path = settings.DATA_DIR / "datasets" search_dirs: list[Path] = [datasets_root] @@ -889,7 +889,7 @@ def dataset_backup_download_view( Raises: Http404: When the file is not found or is outside the data directory. """ - # TODO(TheLovinator): Use s3 instead of local disk. # noqa: TD003 + # TODO(TheLovinator): Use s3 instead of local disk. # ruff:ignore[missing-todo-link] datasets_root: Path = settings.DATA_DIR / "datasets" requested_path: Path = (datasets_root / relative_path).resolve() @@ -994,7 +994,7 @@ def search_view(request: HttpRequest) -> HttpResponse: total_results_count: int = sum(len(qs) for qs in results.values()) - # TODO(TheLovinator): Make the description more informative by including counts of each result type, e.g. "Found 5 games, 3 campaigns, and 10 drops for 'rust'." # noqa: TD003 + # 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] if query: page_title: str = f"Search Results for '{query}'"[:60] page_description: str = f"Found {total_results_count} results for '{query}'." diff --git a/kick/feeds.py b/kick/feeds.py index 36f55b9..d2d65fb 100644 --- a/kick/feeds.py +++ b/kick/feeds.py @@ -496,7 +496,7 @@ class KickCategoryCampaignFeed(TTVDropsBaseFeed): self._limit = None return super().__call__(request, *args, **kwargs) - def get_object(self, request: HttpRequest, kick_id: int) -> KickCategory: # noqa: ARG002 + def get_object(self, request: HttpRequest, kick_id: int) -> KickCategory: # ruff:ignore[unused-method-argument] """Return game object for this feed URL.""" return KickCategory.objects.get(kick_id=kick_id) diff --git a/kick/management/commands/import_kick_drops.py b/kick/management/commands/import_kick_drops.py index 4027227..308e202 100644 --- a/kick/management/commands/import_kick_drops.py +++ b/kick/management/commands/import_kick_drops.py @@ -130,7 +130,7 @@ class Command(BaseCommand): try: payload: dict = response.json() - except Exception as exc: # noqa: BLE001 + except Exception as exc: # ruff:ignore[blind-except] self.stderr.write(self.style.ERROR(f"Failed to parse JSON response: {exc}")) return @@ -166,7 +166,7 @@ class Command(BaseCommand): self.style.SUCCESS(f"Imported {imported}/{len(campaigns)} campaign(s)."), ) - def _import_campaign(self, data: KickDropCampaignSchema) -> None: # noqa: PLR0914, PLR0915 + def _import_campaign(self, data: KickDropCampaignSchema) -> None: # ruff:ignore[too-many-locals, too-many-statements] """Import a single campaign and all its related objects.""" # Organization org_data: KickOrganizationSchema = data.organization diff --git a/kick/models.py b/kick/models.py index 450434f..40a01c1 100644 --- a/kick/models.py +++ b/kick/models.py @@ -93,12 +93,12 @@ class KickCategory(auto_prefetch.Model): @property def get_absolute_url(self) -> str: - """Return the URL to the game detail page.""" + """The URL to the game detail page.""" return reverse("kick:game_detail", args=[self.kick_id]) @property def kick_url(self) -> str: - """Return the URL to the game page on Kick.""" + """The URL to the game page on Kick.""" return f"https://kick.com/category/{self.slug}" if self.slug else "" @@ -136,7 +136,7 @@ class KickUser(auto_prefetch.Model): @property def kick_profile_url(self) -> str: - """Return the Kick profile URL for this user.""" + """The Kick profile URL for this user.""" return f"https://kick.com/{self.username}" if self.username else "" @@ -183,7 +183,7 @@ class KickChannel(auto_prefetch.Model): @property def channel_url(self) -> str: - """Return the Kick channel URL.""" + """The Kick channel URL.""" return f"https://kick.com/{self.slug}" if self.slug else "" @@ -290,7 +290,7 @@ class KickDropCampaign(auto_prefetch.Model): @property def image_url(self) -> str: - """Return the image URL for the campaign.""" + """The image URL for the campaign.""" # Image from first drop rewards_prefetched: list[KickReward] | None = getattr( self, @@ -356,7 +356,7 @@ class KickDropCampaign(auto_prefetch.Model): @property def merged_rewards(self) -> list[KickReward]: - """Return rewards de-duplicated by normalized name. + """Rewards de-duplicated by normalized 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 def full_image_url(self) -> str: - """Return the absolute image URL for this reward. + """The absolute image URL for this reward. If the image_url is a relative path, prepend the Kick image base URL. """ diff --git a/kick/schemas.py b/kick/schemas.py index 2b26114..396dbb1 100644 --- a/kick/schemas.py +++ b/kick/schemas.py @@ -1,4 +1,4 @@ -from datetime import datetime # noqa: TC003 +from datetime import datetime # ruff:ignore[typing-only-standard-library-import] from pydantic import BaseModel from pydantic import Field diff --git a/kick/tasks.py b/kick/tasks.py index 9c50a9e..2eebd3b 100644 --- a/kick/tasks.py +++ b/kick/tasks.py @@ -9,7 +9,7 @@ logger = logging.getLogger("ttvdrops.tasks") @shared_task(bind=True, queue="api-fetches", max_retries=3, default_retry_delay=120) -def import_kick_drops(self) -> None: # noqa: ANN001 +def import_kick_drops(self) -> None: # ruff:ignore[missing-type-function-argument] """Fetch and upsert Kick drop campaigns from the public API.""" try: call_command("import_kick_drops") diff --git a/kick/tests/test_kick.py b/kick/tests/test_kick.py index 3f2925e..6d4153b 100644 --- a/kick/tests/test_kick.py +++ b/kick/tests/test_kick.py @@ -463,7 +463,7 @@ class KickDropCampaignMergedRewardsTest(TestCase): class ImportKickDropsCommandTest(TestCase): """Tests for the import_kick_drops management command.""" - def _run_command(self, json_payload: dict, **options: Any) -> tuple[str, str]: # noqa: ANN401 + def _run_command(self, json_payload: dict, **options: Any) -> tuple[str, str]: # ruff:ignore[any-type] mock_response = MagicMock() mock_response.json.return_value = json_payload diff --git a/manage.py b/manage.py index 1077d0a..43ab60a 100755 --- a/manage.py +++ b/manage.py @@ -13,7 +13,7 @@ def main() -> None: """ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") try: - from django.core.management import execute_from_command_line # noqa: PLC0415 + from django.core.management import execute_from_command_line # ruff:ignore[import-outside-top-level, unsorted-imports] except ImportError as exc: msg = ( "Couldn't import Django. Are you sure it's installed and " diff --git a/tools/extract_historical_drops.py b/tools/extract_historical_drops.py index 65db357..9af65a0 100755 --- a/tools/extract_historical_drops.py +++ b/tools/extract_historical_drops.py @@ -16,7 +16,7 @@ import argparse import hashlib import json import shutil -import subprocess # noqa: S404 +import subprocess # ruff:ignore[suspicious-subprocess-import] import tempfile from pathlib import Path from typing import Any @@ -60,7 +60,7 @@ def run_git(git_dir: str, *args: str, check: bool = True) -> str: if not git: msg = "Git executable not found in PATH." raise FileNotFoundError(msg) - result: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603 + result: subprocess.CompletedProcess[str] = subprocess.run( # ruff:ignore[subprocess-without-shell-equals-true] [git, "--git-dir", git_dir, *args], capture_output=True, text=True, diff --git a/twitch/api.py b/twitch/api.py index 4e94366..26af923 100644 --- a/twitch/api.py +++ b/twitch/api.py @@ -1,6 +1,6 @@ from __future__ import annotations -import datetime # noqa: TC003 +import datetime # ruff:ignore[typing-only-standard-library-import] from dataclasses import dataclass from typing import TYPE_CHECKING from typing import Literal diff --git a/twitch/apps.py b/twitch/apps.py index d15fdbd..67d0d6c 100644 --- a/twitch/apps.py +++ b/twitch/apps.py @@ -15,7 +15,7 @@ class TwitchConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "twitch" - def ready(self) -> None: # noqa: D102 + def ready(self) -> None: # ruff:ignore[undocumented-public-method] logger: logging.Logger = logging.getLogger("ttvdrops.apps") # 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 # when new Twitch records are created. - from django.db.models.signals import m2m_changed # noqa: I001, PLC0415 - from django.db.models.signals import post_save # noqa: PLC0415 + from django.db.models.signals import m2m_changed # ruff:ignore[unsorted-imports, import-outside-top-level] + from django.db.models.signals import post_save # ruff:ignore[import-outside-top-level] - from twitch.models import DropBenefit # noqa: PLC0415 - from twitch.models import DropCampaign # noqa: PLC0415 - from twitch.models import Game # noqa: PLC0415 - from twitch.models import RewardCampaign # noqa: PLC0415 - from twitch.signals import on_drop_benefit_saved # noqa: PLC0415 - from twitch.signals import on_drop_campaign_allow_channels_changed # noqa: PLC0415 - from twitch.signals import on_drop_campaign_saved # noqa: PLC0415 - from twitch.signals import on_game_saved # noqa: PLC0415 - from twitch.signals import on_reward_campaign_saved # noqa: PLC0415 + from twitch.models import DropBenefit # ruff:ignore[import-outside-top-level] + from twitch.models import DropCampaign # ruff:ignore[import-outside-top-level] + from twitch.models import Game # ruff:ignore[import-outside-top-level] + from twitch.models import RewardCampaign # ruff:ignore[import-outside-top-level] + from twitch.signals import on_drop_benefit_saved # ruff:ignore[import-outside-top-level] + from twitch.signals import on_drop_campaign_allow_channels_changed # ruff:ignore[import-outside-top-level] + from twitch.signals import on_drop_campaign_saved # ruff:ignore[import-outside-top-level] + from twitch.signals import on_game_saved # ruff:ignore[import-outside-top-level] + from twitch.signals import on_reward_campaign_saved # ruff:ignore[import-outside-top-level] post_save.connect(on_game_saved, sender=Game) post_save.connect(on_drop_campaign_saved, sender=DropCampaign) diff --git a/twitch/feeds.py b/twitch/feeds.py index b5c503c..3c9e904 100644 --- a/twitch/feeds.py +++ b/twitch/feeds.py @@ -22,7 +22,7 @@ from django.utils.html import format_html_join from django.utils.safestring import SafeText from core.base_url import build_absolute_uri -from core.base_url import get_current_site # noqa: F811 +from core.base_url import get_current_site # ruff:ignore[redefined-while-unused] from twitch.models import Channel from twitch.models import ChatBadge from twitch.models import DropCampaign @@ -185,9 +185,9 @@ class TTVDropsBaseFeed(Feed): Returns: SyndicationFeed: The feed generator instance with the correct site and URL context for absolute URL generation. """ - # TODO(TheLovinator): Refactor to avoid this mess. # noqa: TD003 + # TODO(TheLovinator): Refactor to avoid this mess. # ruff:ignore[missing-todo-link] try: - from django.contrib.sites import shortcuts as sites_shortcuts # noqa: I001, PLC0415 + from django.contrib.sites import shortcuts as sites_shortcuts # ruff:ignore[unsorted-imports, import-outside-top-level] except ImportError: 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", ""): - # TODO(TheLovinator): Improve detection of global emotes # noqa: TD003 + # TODO(TheLovinator): Improve detection of global emotes # ruff:ignore[missing-todo-link] parts.append( format_html( "{}", @@ -1177,7 +1177,7 @@ class GameCampaignFeed(TTVDropsBaseFeed): self._hide_paid = _query_bool(request, "hide_paid") return super().__call__(request, *args, **kwargs) - def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002 + def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # ruff:ignore[unused-method-argument] """Retrieve the Game instance for the given Twitch ID. Returns: @@ -1727,7 +1727,7 @@ class GameRewardCampaignFeed(TTVDropsBaseFeed): self._limit = None return super().__call__(request, *args, **kwargs) - def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002 + def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # ruff:ignore[unused-method-argument] """Retrieve the Game instance for the given Twitch ID. Returns: diff --git a/twitch/management/commands/backfill_image_dimensions.py b/twitch/management/commands/backfill_image_dimensions.py index 9d876b0..3a0b468 100644 --- a/twitch/management/commands/backfill_image_dimensions.py +++ b/twitch/management/commands/backfill_image_dimensions.py @@ -14,7 +14,7 @@ class Command(BaseCommand): help = "Backfill image dimensions for existing cached images" - def handle(self, *args, **options) -> None: # noqa: ARG002, PLR0915 + def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument, too-many-statements] """Execute the command.""" total_updated = 0 diff --git a/twitch/management/commands/backup_db.py b/twitch/management/commands/backup_db.py index 753e844..bcdbbd5 100644 --- a/twitch/management/commands/backup_db.py +++ b/twitch/management/commands/backup_db.py @@ -2,7 +2,7 @@ import io import json import os import shutil -import subprocess # noqa: S404 +import subprocess # ruff:ignore[suspicious-subprocess-import] from compression import zstd from datetime import datetime from pathlib import Path @@ -191,11 +191,11 @@ def _write_table_rows( connection: SQLite connection. table: Table name. """ - cursor = connection.execute(f'SELECT * FROM "{table}"') # noqa: S608 + cursor = connection.execute(f'SELECT * FROM "{table}"') # ruff:ignore[hardcoded-sql-expression] columns = [description[0] for description in cursor.description] for row in cursor.fetchall(): values = ", ".join(_sql_literal(row[idx]) for idx in range(len(columns))) - handle.write(f'INSERT INTO "{table}" VALUES ({values});\n') # noqa: S608 + handle.write(f'INSERT INTO "{table}" VALUES ({values});\n') # ruff:ignore[hardcoded-sql-expression] def _write_indexes( @@ -268,7 +268,7 @@ def _write_postgres_dump(output_path: Path, tables: list[str]) -> None: for table in tables: cmd.extend(["-t", f"public.{table}"]) - process = subprocess.Popen( # noqa: S603 + process = subprocess.Popen( # ruff:ignore[subprocess-without-shell-equals-true] cmd, stdout=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]] = {} with django_connection.cursor() as cursor: for table in tables: - cursor.execute(f'SELECT * FROM "{table}"') # noqa: S608 + cursor.execute(f'SELECT * FROM "{table}"') # ruff:ignore[hardcoded-sql-expression] columns: list[str] = [col[0] for col in cursor.description] rows = cursor.fetchall() data[table] = [dict(zip(columns, row, strict=False)) for row in rows] diff --git a/twitch/management/commands/better_import_drops.py b/twitch/management/commands/better_import_drops.py index 81acda0..03ed611 100644 --- a/twitch/management/commands/better_import_drops.py +++ b/twitch/management/commands/better_import_drops.py @@ -403,7 +403,7 @@ def repair_partially_broken_json(raw_text: str) -> str: wrapped: str = f"[{raw_text}]" wrapped_data = json.loads(wrapped) # Validate that all items look like GraphQL responses - if isinstance(wrapped_data, list) and wrapped_data: # noqa: SIM102 + if isinstance(wrapped_data, list) and wrapped_data: # ruff:ignore[collapsible-if] # Check if all items have "data" or "extensions" (GraphQL response structure) if all( 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]] = [] for line in lines: - line: str = line.strip() # noqa: PLW2901 + line: str = line.strip() # ruff:ignore[redefined-loop-name] if line and line.startswith("{"): try: fixed_line: str = json_repair.repair_json(line, logging=False) @@ -749,7 +749,7 @@ class Command(BaseCommand): return channel_obj - def process_responses( # noqa: PLR0915 + def process_responses( # ruff:ignore[too-many-statements] self, responses: list[dict[str, Any]], file_path: Path, @@ -1180,7 +1180,7 @@ class Command(BaseCommand): msg: str = f"Path does not exist: {input_path}" raise CommandError(msg) - def handle(self, *args, **options) -> None: # noqa: ARG002 + def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument] """Main entry point for the command.""" colorama_init(autoreset=True) diff --git a/twitch/management/commands/cleanup_unknown_organizations.py b/twitch/management/commands/cleanup_unknown_organizations.py index 3f07263..e6ec0a9 100644 --- a/twitch/management/commands/cleanup_unknown_organizations.py +++ b/twitch/management/commands/cleanup_unknown_organizations.py @@ -49,7 +49,7 @@ class Command(BaseCommand): ), ) - def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002 + def handle(self, *args: Any, **options: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Execute the command to detach the organization and optionally re-import data. Args: diff --git a/twitch/management/commands/download_box_art.py b/twitch/management/commands/download_box_art.py index 3d1b513..2a32cb2 100644 --- a/twitch/management/commands/download_box_art.py +++ b/twitch/management/commands/download_box_art.py @@ -39,7 +39,7 @@ class Command(BaseCommand): help="Re-download even if a local box art file already exists.", ) - def handle( # noqa: PLR0914, PLR0915 + def handle( # ruff:ignore[too-many-locals, too-many-statements] self, *_args: str, **options: str | bool | int | None, diff --git a/twitch/management/commands/import_chat_badges.py b/twitch/management/commands/import_chat_badges.py index 43906be..2e0d463 100644 --- a/twitch/management/commands/import_chat_badges.py +++ b/twitch/management/commands/import_chat_badges.py @@ -50,7 +50,7 @@ class Command(BaseCommand): help="Twitch Access Token (optional - will be obtained automatically if not provided)", ) - def handle(self, *args, **options) -> None: # noqa: ARG002 + def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument] """Main entry point for the command. Raises: diff --git a/twitch/management/commands/import_twitch_drops_api.py b/twitch/management/commands/import_twitch_drops_api.py index f8119ec..8791b18 100644 --- a/twitch/management/commands/import_twitch_drops_api.py +++ b/twitch/management/commands/import_twitch_drops_api.py @@ -27,7 +27,7 @@ import hashlib import json import os import pathlib -import subprocess # noqa: S404 +import subprocess # ruff:ignore[suspicious-subprocess-import] import tempfile from typing import TYPE_CHECKING from typing import Any @@ -194,7 +194,7 @@ class Command(BaseCommand): ), ) - def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002, PLR0914, PLR0915 + def handle(self, *args: Any, **options: Any) -> None: # ruff:ignore[any-type, unused-method-argument, too-many-locals, too-many-statements] """Main entry point for the command. Raises: @@ -301,7 +301,7 @@ class Command(BaseCommand): crash_on_error=crash_on_error, skip_existing=skip_existing, ) - except Exception as exc: # noqa: BLE001 + except Exception as exc: # ruff:ignore[blind-except] msg = f"Failed to import drops.json: {exc}" self.stderr.write(self.style.ERROR(msg)) errors.append(msg) @@ -315,7 +315,7 @@ class Command(BaseCommand): crash_on_error=crash_on_error, skip_existing=skip_existing, ) - except Exception as exc: # noqa: BLE001 + except Exception as exc: # ruff:ignore[blind-except] msg = f"Failed to import rewards.json: {exc}" self.stderr.write(self.style.ERROR(msg)) errors.append(msg) @@ -353,7 +353,7 @@ class Command(BaseCommand): # Historical import via git clone # ------------------------------------------------------------------ - def _process_historical( # noqa: PLR0913 + def _process_historical( # ruff:ignore[too-many-arguments] self, source: str, *, @@ -423,7 +423,7 @@ class Command(BaseCommand): max_commits=max_commits, skip_existing=skip_existing, ) - except Exception as exc: # noqa: BLE001 + except Exception as exc: # ruff:ignore[blind-except] errors.append(str(exc)) finally: if own_clone: @@ -431,7 +431,7 @@ class Command(BaseCommand): return drops_count, rewards_count, errors - def _import_historical_file( # noqa: PLR0913 + def _import_historical_file( # ruff:ignore[too-many-arguments] self, git_dir: str, file_path: str, @@ -581,7 +581,7 @@ class Command(BaseCommand): Stdout of the git command. """ cmd = ["git", "--git-dir", git_dir, *args] - result = subprocess.run(cmd, capture_output=True, text=True, check=check) # noqa: S603 + result = subprocess.run(cmd, capture_output=True, text=True, check=check) # ruff:ignore[subprocess-without-shell-equals-true] return result.stdout def _rmtree(self, path: str) -> None: @@ -659,7 +659,7 @@ class Command(BaseCommand): ) @staticmethod - def _strip_typename(data: Any) -> Any: # noqa: ANN401 + def _strip_typename(data: Any) -> Any: # ruff:ignore[any-type] """Recursively remove ``__typename`` keys from parsed JSON data. 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 data - def _generate_report( # noqa: PLR0914, PLR0915 + def _generate_report( # ruff:ignore[too-many-locals, too-many-statements] self, drops_url: str, rewards_url: str, @@ -707,7 +707,7 @@ class Command(BaseCommand): Raises: ValidationError: If any item fails schema validation and crash_on_error is True. """ - from collections import Counter # noqa: PLC0415 + from collections import Counter # ruff:ignore[import-outside-top-level] self.stdout.write("=" * 60) self.stdout.write(self.style.SUCCESS("COMPARISON REPORT")) @@ -719,14 +719,14 @@ class Command(BaseCommand): # Track differences for field-level comparison sample_limit = 5 - for label, raw_url, model_cls, id_field in [ # noqa: PLR1702 + for label, raw_url, model_cls, id_field in [ # ruff:ignore[too-many-nested-blocks] ("drops", drops_url, DropCampaign, "twitch_id"), ("rewards", rewards_url, RewardCampaign, "twitch_id"), ]: self.stdout.write(f"\n--- {label.upper()} ---") try: raw_data = self._fetch_json(raw_url, f"{label}.json") - except Exception as exc: # noqa: BLE001 + except Exception as exc: # ruff:ignore[blind-except] self.stderr.write( self.style.ERROR(f" Failed to fetch {label}.json: {exc}"), ) @@ -894,7 +894,7 @@ class Command(BaseCommand): self.stdout.write("\n" + "=" * 60) - def _parse_and_import_drops( # noqa: PLR0913 + def _parse_and_import_drops( # ruff:ignore[too-many-arguments] self, raw_data: list[dict[str, Any]], source: str, @@ -976,7 +976,7 @@ class Command(BaseCommand): return total_campaigns - def _process_sunkwibot_reward( # noqa: PLR0915 + def _process_sunkwibot_reward( # ruff:ignore[too-many-statements] self, reward: SunkwiBotRewardSchema, game_obj: Game, @@ -1133,7 +1133,7 @@ class Command(BaseCommand): self, tbd_schema: SunkwiBotTimeBasedDropSchema, campaign_obj: DropCampaign, - source: str, # noqa: ARG002 + source: str, # ruff:ignore[unused-method-argument] *, verbose: bool, ) -> None: @@ -1335,7 +1335,7 @@ class Command(BaseCommand): skip_existing=skip_existing, ) - def _parse_and_import_rewards( # noqa: PLR0913 + def _parse_and_import_rewards( # ruff:ignore[too-many-arguments] self, raw_data: list[dict[str, Any]], source: str, @@ -1563,12 +1563,12 @@ class Command(BaseCommand): self._org_cache[twitch_id] = org_obj return org_obj - def _get_or_create_game( # noqa: PLR0913 + def _get_or_create_game( # ruff:ignore[too-many-arguments] self, twitch_id: str, display_name: str, box_art_url: str, - source: str, # noqa: ARG002 + source: str, # ruff:ignore[unused-method-argument] *, verbose: bool, org_obj: Organization | None = None, diff --git a/twitch/management/commands/watch_imports.py b/twitch/management/commands/watch_imports.py index d1f6ca3..96e8549 100644 --- a/twitch/management/commands/watch_imports.py +++ b/twitch/management/commands/watch_imports.py @@ -37,7 +37,7 @@ class Command(BaseCommand): help="Path to directory to watch for JSON files", ) - def handle(self, *args, **options) -> None: # noqa: ARG002 + def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument] """Main entry point for the watch command. Args: @@ -87,7 +87,7 @@ class Command(BaseCommand): importer_command: An instance of the BetterImportDropsCommand to handle the import logic. watch_path: The directory path to watch for JSON files. """ - # TODO(TheLovinator): Implement actual file watching using watchdog or similar library. # noqa: TD003 + # TODO(TheLovinator): Implement actual file watching using watchdog or similar library. # ruff:ignore[missing-todo-link] json_files: list[Path] = [ f for f in watch_path.iterdir() if f.suffix == ".json" and f.is_file() ] diff --git a/twitch/migrations/0007_rename_operation_name_to_operation_names.py b/twitch/migrations/0007_rename_operation_name_to_operation_names.py index 63190e1..bbe6ac2 100644 --- a/twitch/migrations/0007_rename_operation_name_to_operation_names.py +++ b/twitch/migrations/0007_rename_operation_name_to_operation_names.py @@ -5,7 +5,7 @@ from django.db import migrations from django.db import models -def migrate_operation_name_to_list(apps, schema_editor) -> None: # noqa: ANN001 +def migrate_operation_name_to_list(apps, schema_editor) -> None: # ruff:ignore[missing-type-function-argument] """Convert operation_name string values to operation_names list.""" DropCampaign = apps.get_model("twitch", "DropCampaign") for campaign in DropCampaign.objects.all(): @@ -14,7 +14,7 @@ def migrate_operation_name_to_list(apps, schema_editor) -> None: # noqa: ANN001 campaign.save(update_fields=["operation_names"]) -def reverse_operation_names_to_string(apps, schema_editor) -> None: # noqa: ANN001 +def reverse_operation_names_to_string(apps, schema_editor) -> None: # ruff:ignore[missing-type-function-argument] """Convert operation_names list back to operation_name string (first item only).""" DropCampaign = apps.get_model("twitch", "DropCampaign") for campaign in DropCampaign.objects.all(): diff --git a/twitch/migrations/0016_mark_all_drops_fully_imported.py b/twitch/migrations/0016_mark_all_drops_fully_imported.py index 54b4003..5a9ef00 100644 --- a/twitch/migrations/0016_mark_all_drops_fully_imported.py +++ b/twitch/migrations/0016_mark_all_drops_fully_imported.py @@ -1,7 +1,7 @@ from django.db import migrations -def mark_all_drops_fully_imported(apps, schema_editor) -> None: # noqa: ANN001 +def mark_all_drops_fully_imported(apps, schema_editor) -> None: # ruff:ignore[missing-type-function-argument] """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. diff --git a/twitch/models.py b/twitch/models.py index 934d169..7ff20f7 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -137,7 +137,7 @@ class Game(auto_prefetch.Model): default="", ) - box_art = models.URLField( # noqa: DJ001 + box_art = models.URLField( # ruff:ignore[django-nullable-model-string-field] verbose_name="Box art URL", max_length=500, blank=True, @@ -231,12 +231,12 @@ class Game(auto_prefetch.Model): @property def organizations(self) -> models.QuerySet[Organization]: - """Return orgs that own games with campaigns for this game.""" + """Orgs that own games with campaigns for this game.""" return Organization.objects.filter(games__drop_campaigns__game=self).distinct() @property def get_game_name(self) -> str: - """Return the best available name for the game.""" + """The best available name for the game.""" if self.display_name: return self.display_name if self.name: @@ -247,8 +247,8 @@ class Game(auto_prefetch.Model): @property def twitch_directory_url(self) -> str: - """Return Twitch directory URL with drops filter when slug exists.""" - # TODO(TheLovinator): If no slug, get from Twitch API or IGDB? # noqa: TD003 + """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] if self.slug: return f"https://www.twitch.tv/directory/category/{self.slug}?filter=drops" @@ -256,7 +256,7 @@ class Game(auto_prefetch.Model): @property def box_art_best_url(self) -> str: - """Return the best available URL for the game's box art (local first).""" + """The best available URL for the game's box art (local first).""" try: if self.box_art_file and getattr(self.box_art_file, "url", None): return self.box_art_file.url @@ -271,7 +271,7 @@ class Game(auto_prefetch.Model): @property def dashboard_box_art_url(self) -> str: - """Return dashboard-safe box art URL without touching deferred image fields.""" + """Dashboard-safe box art URL without touching deferred image fields.""" return normalize_twitch_box_art_url(self.box_art or "") @classmethod @@ -570,7 +570,7 @@ class Channel(auto_prefetch.Model): @property def preferred_name(self) -> str: - """Return display name fallback used by channel-facing pages.""" + """Display name fallback used by channel-facing pages.""" return self.display_name or self.name or self.twitch_id def detail_description(self, total_campaigns: int) -> str: @@ -580,7 +580,7 @@ class Channel(auto_prefetch.Model): # MARK: DropCampaign -class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 +class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods] """Represents a Twitch drop campaign.""" twitch_id = models.TextField( @@ -1270,7 +1270,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def clean_name(self) -> str: - """Return the campaign name without the game name prefix. + """The campaign name without the game name prefix. Examples: "Ravendawn - July 2" -> "July 2" @@ -1302,7 +1302,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def single_reward_benefit(self) -> DropBenefit | None: - """Return the only unique reward benefit for this campaign, if it has one.""" + """The only unique reward benefit for this campaign, if it has one.""" benefits: list[DropBenefit] = [] seen_benefit_keys: set[int | str] = set() @@ -1321,7 +1321,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def single_reward_image_best_url(self) -> str: - """Return the best image URL for a campaign that has exactly one reward.""" + """The best image URL for a campaign that has exactly one reward.""" benefit: DropBenefit | None = self.single_reward_benefit if not benefit: return "" @@ -1329,12 +1329,12 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def meta_image_url(self) -> str: - """Return the preferred campaign image URL for SEO metadata.""" + """The preferred campaign image URL for SEO metadata.""" return self.single_reward_image_best_url or self.image_best_url @property def image_best_url(self) -> str: - """Return the best URL for the campaign image. + """The best URL for the campaign image. Priority: 1. Local cached image file @@ -1361,7 +1361,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def listing_image_url(self) -> str: - """Return a campaign image URL optimized for list views. + """A campaign image URL optimized for list views. This intentionally avoids traversing drops/benefits to prevent N+1 queries in list pages that render many campaigns. @@ -1375,7 +1375,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def dashboard_image_url(self) -> str: - """Return dashboard-safe campaign or single-reward image URL.""" + """Dashboard-safe campaign or single-reward image URL.""" benefit: DropBenefit | None = self.single_reward_benefit if benefit and benefit.image_asset_url: return benefit.image_asset_url @@ -1383,7 +1383,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def duration_iso(self) -> str: - """Return the campaign duration in ISO 8601 format (e.g., 'P3DT4H30M'). + """The campaign duration in ISO 8601 format (e.g., 'P3DT4H30M'). This is used for the