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] 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 |
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]
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 @@
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//