Move all the APIs under the same router

This commit is contained in:
Joakim Hellsén 2026-07-21 04:05:35 +02:00
commit c54ceeb7a8
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
27 changed files with 1289 additions and 351 deletions

19
config/api.py Normal file
View file

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

View file

@ -276,7 +276,25 @@ class SiteEndpointSmokeTest(TestCase):
("twitch:export_organizations_csv", {}, 200), ("twitch:export_organizations_csv", {}, 200),
("twitch:export_organizations_json", {}, 200), ("twitch:export_organizations_json", {}, 200),
# Kick endpoints # Kick endpoints
("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:dashboard", {}, 200),
("kick:campaign_list", {}, 200), ("kick:campaign_list", {}, 200),
("kick:campaign_detail", {"kick_id": self.kick_campaign.kick_id}, 200), ("kick:campaign_detail", {"kick_id": self.kick_campaign.kick_id}, 200),

View file

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

View file

@ -705,6 +705,28 @@ def debug_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914
miner_only_rewards: QuerySet[RewardCampaign] = RewardCampaign.objects.filter( miner_only_rewards: QuerySet[RewardCampaign] = RewardCampaign.objects.filter(
twitch_id__in=miner_only_reward_ids, twitch_id__in=miner_only_reward_ids,
).order_by("name")[:200] ).order_by("name")[:200]
# ── Unknown-status campaigns ────────────────────────────────────────
# Kick campaigns with incomplete dates
kick_unknown_campaigns: QuerySet[KickDropCampaign] = (
KickDropCampaign.objects
.filter(
Q(starts_at__isnull=True) | Q(ends_at__isnull=True),
is_fully_imported=True,
)
.select_related("category", "organization")
.order_by("name")
)
# Twitch reward campaigns with incomplete dates
twitch_reward_unknown_campaigns: QuerySet[RewardCampaign] = (
RewardCampaign.objects
.filter(
Q(starts_at__isnull=True) | Q(ends_at__isnull=True),
)
.select_related("game")
.order_by("name")
)
context: dict[str, Any] = { context: dict[str, Any] = {
"now": now, "now": now,
"games_without_owner": games_without_owner, "games_without_owner": games_without_owner,
@ -725,6 +747,9 @@ def debug_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914
"miner_only_rewards_count": len(miner_only_reward_ids), "miner_only_rewards_count": len(miner_only_reward_ids),
"sunkibot_only_rewards": sunkibot_only_rewards, "sunkibot_only_rewards": sunkibot_only_rewards,
"miner_only_rewards": miner_only_rewards, "miner_only_rewards": miner_only_rewards,
# Unknown-status campaigns
"kick_unknown_campaigns": kick_unknown_campaigns,
"twitch_reward_unknown_campaigns": twitch_reward_unknown_campaigns,
} }
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(

View file

@ -1,41 +1,219 @@
from __future__ import annotations 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 TYPE_CHECKING
from typing import Any
from typing import Literal 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.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 KickDropCampaign
from kick.models import KickOrganization
from kick.models import KickUser
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest 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 KickReward
from kick.models import KickUser
CampaignStatus = Literal["active", "upcoming", "expired", "unknown"] 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 DEFAULT_PAGE_SIZE = 100
MAX_PAGE_SIZE = 500 MAX_PAGE_SIZE = 500
api = Router()
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: # 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 and campaign.ends_at:
if campaign.starts_at <= now <= campaign.ends_at: if campaign.starts_at <= now <= campaign.ends_at:
return "active" return "active"
@ -45,137 +223,172 @@ def _campaign_status(campaign: KickDropCampaign, now: datetime) -> CampaignStatu
return "unknown" 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: if category is None:
return None return None
return { return V1CategorySchema(
"kick_id": category.kick_id, kick_id=category.kick_id,
"name": category.name, name=category.name,
"slug": category.slug, slug=category.slug,
"image_url": category.image_url, image_url=category.image_url,
} )
def _serialize_organization( def _serialize_organization_summary(
organization: KickOrganization | None, organization: KickOrganization | None,
) -> dict[str, Any] | None: ) -> V1OrganizationSummarySchema | None:
if organization is None: if organization is None:
return None return None
return { return V1OrganizationSummarySchema(
"kick_id": organization.kick_id, kick_id=organization.kick_id,
"name": organization.name, name=organization.name,
"logo_url": organization.logo_url, logo_url=organization.logo_url,
"url": organization.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: if user is None:
return None return None
return { return V1UserSchema(
"kick_id": user.kick_id, kick_id=user.kick_id,
"username": user.username, username=user.username,
"profile_picture": user.profile_picture, profile_picture=user.profile_picture,
} )
def _serialize_channel(channel: KickChannel) -> dict[str, Any]: def _serialize_channel(channel: KickChannel) -> V1ChannelSchema:
return { return V1ChannelSchema(
"kick_id": channel.kick_id, kick_id=channel.kick_id,
"slug": channel.slug, slug=channel.slug,
"url": channel.channel_url, url=channel.channel_url,
"user": _serialize_user(channel.user), user=_serialize_user(channel.user),
} )
def _serialize_reward(reward: KickReward) -> dict[str, Any]: def _serialize_reward(reward: KickReward) -> V1RewardSchema:
return { return V1RewardSchema(
"kick_id": reward.kick_id, kick_id=reward.kick_id,
"name": reward.name, name=reward.name,
"image_url": reward.full_image_url, image_url=reward.full_image_url,
"required_minutes_watched": reward.required_units, 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( def _serialize_campaign(
campaign: KickDropCampaign, campaign: KickDropCampaign,
now: datetime, now: datetime.datetime,
) -> dict[str, Any]: ) -> V1CampaignDetailSchema:
return { return V1CampaignDetailSchema(
"kick_id": campaign.kick_id, kick_id=campaign.kick_id,
"name": campaign.name, name=campaign.name,
"status": _campaign_status(campaign, now), status=_campaign_status(campaign, now),
"image_url": campaign.image_url, image_url=campaign.image_url,
"connect_url": campaign.connect_url, starts_at=campaign.starts_at,
"url": campaign.url, ends_at=campaign.ends_at,
"starts_at": _datetime_to_json(campaign.starts_at), category=_serialize_category(campaign.category),
"ends_at": _datetime_to_json(campaign.ends_at), organization=_serialize_organization_summary(campaign.organization),
"category": _serialize_category(campaign.category), reward_count=campaign.rewards.count(), # pyright: ignore[reportAttributeAccessIssue]
"organization": _serialize_organization(campaign.organization), is_fully_imported=campaign.is_fully_imported,
"channels": [ connect_url=campaign.connect_url,
_serialize_channel(channel) for channel in campaign.channels.all() 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()], added_at=campaign.added_at,
"is_fully_imported": campaign.is_fully_imported, updated_at=campaign.updated_at,
"added_at": _datetime_to_json(campaign.added_at), )
"updated_at": _datetime_to_json(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, request: HttpRequest,
name: str, page: int = 1,
default: int, page_size: int = DEFAULT_PAGE_SIZE,
) -> int | JsonResponse: status: CampaignStatusFilter | None = None,
raw_value: str | None = request.GET.get(name) game: int | None = None,
if raw_value is None: organization: str | None = None,
return default search: str | None = None,
try: ) -> V1CampaignListSchema:
return int(raw_value) """Return paginated Kick drop campaigns."""
except ValueError: now: datetime.datetime = timezone.now()
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] = ( queryset: QuerySet[KickDropCampaign] = (
KickDropCampaign.objects KickDropCampaign.objects
.filter(is_fully_imported=True) .filter(is_fully_imported=True)
@ -184,30 +397,214 @@ def campaign_list_api(request: HttpRequest) -> JsonResponse:
.order_by("-starts_at", "kick_id") .order_by("-starts_at", "kick_id")
) )
game_filter: int | JsonResponse | None = None if game is not None:
if request.GET.get("game") is not None: queryset = queryset.filter(category__kick_id=game)
game_filter = _parse_integer_query(request, "game", 0) if organization is not None:
if isinstance(game_filter, JsonResponse): queryset = queryset.filter(organization__kick_id=organization)
return game_filter if search is not None:
if game_filter is not None: queryset = queryset.filter(name__icontains=search)
queryset = queryset.filter(category__kick_id=game_filter)
if status_filter == "active": queryset = _apply_status_filter(queryset, status, now)
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() items, total, current_page, current_page_size = _paginate(
offset: int = (page - 1) * page_size queryset,
campaigns: list[KickDropCampaign] = ( page=page,
list(queryset[offset : offset + page_size]) if offset < total else [] page_size=page_size,
) )
return JsonResponse({ return V1CampaignListSchema(
"total": total, total=total,
"page": page, page=current_page,
"page_size": page_size, page_size=current_page_size,
"items": [_serialize_campaign(campaign, now) for campaign in campaigns], items=[_serialize_campaign(campaign, now) for campaign in items],
}) )
@api.get("/campaigns/{kick_id}/", response=V1CampaignDetailSchema)
def get_campaign(
request: HttpRequest,
kick_id: str,
) -> V1CampaignDetailSchema:
"""Return a single Kick drop campaign.
Raises:
Http404: If the campaign does not exist or is not fully imported.
"""
try:
campaign: KickDropCampaign = (
KickDropCampaign.objects
.filter(is_fully_imported=True)
.select_related("category", "organization")
.prefetch_related("channels__user", "rewards")
.get(kick_id=kick_id)
)
except KickDropCampaign.DoesNotExist as exc:
msg = "Campaign not found"
raise Http404(msg) from exc
return _serialize_campaign(campaign, timezone.now())
@api.get("/games/", response=V1CategoryListSchema)
def list_games(
request: HttpRequest,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
search: str | None = None,
) -> V1CategoryListSchema:
"""Return paginated Kick categories (games)."""
now: datetime.datetime = timezone.now()
queryset: QuerySet[KickCategory] = KickCategory.objects.annotate(
campaign_count=models.Count(
"campaigns",
filter=models.Q(campaigns__is_fully_imported=True),
),
active_campaign_count=models.Count(
"campaigns",
filter=models.Q(
campaigns__is_fully_imported=True,
campaigns__starts_at__lte=now,
campaigns__ends_at__gte=now,
),
),
).order_by("name")
if search is not None:
queryset = queryset.filter(name__icontains=search)
items, total, current_page, current_page_size = _paginate(
queryset,
page=page,
page_size=page_size,
)
return V1CategoryListSchema(
total=total,
page=current_page,
page_size=current_page_size,
items=[
V1CategoryListItemSchema(
kick_id=cat.kick_id,
name=cat.name,
slug=cat.slug,
image_url=cat.image_url,
campaign_count=cat.campaign_count, # pyright: ignore[reportAttributeAccessIssue]
active_campaign_count=cat.active_campaign_count, # pyright: ignore[reportAttributeAccessIssue]
)
for cat in items
],
)
@api.get("/games/{kick_id}/", response=V1CategoryDetailSchema)
def get_game(
request: HttpRequest,
kick_id: int,
) -> V1CategoryDetailSchema:
"""Return a single Kick category (game) with its campaigns."""
category: KickCategory = get_object_or_404(KickCategory, kick_id=kick_id)
now: datetime.datetime = timezone.now()
campaigns: list[KickDropCampaign] = list(
category.campaigns # pyright: ignore[reportAttributeAccessIssue]
.filter(is_fully_imported=True)
.select_related("organization")
.prefetch_related("rewards")
.order_by("-starts_at"),
)
return V1CategoryDetailSchema(
kick_id=category.kick_id,
name=category.name,
slug=category.slug,
image_url=category.image_url,
campaign_count=len(campaigns),
active_campaign_count=sum(
1 for c in campaigns if _campaign_status(c, now) == "active"
),
campaigns=[_serialize_campaign_summary(c, now) for c in campaigns],
)
@api.get("/organizations/", response=V1OrganizationListSchema)
def list_organizations(
request: HttpRequest,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
search: str | None = None,
) -> V1OrganizationListSchema:
"""Return paginated Kick organizations."""
now: datetime.datetime = timezone.now()
queryset: QuerySet[KickOrganization] = KickOrganization.objects.annotate(
campaign_count=models.Count(
"campaigns",
filter=models.Q(campaigns__is_fully_imported=True),
),
active_campaign_count=models.Count(
"campaigns",
filter=models.Q(
campaigns__is_fully_imported=True,
campaigns__starts_at__lte=now,
campaigns__ends_at__gte=now,
),
),
).order_by("name")
if search is not None:
queryset = queryset.filter(name__icontains=search)
items, total, current_page, current_page_size = _paginate(
queryset,
page=page,
page_size=page_size,
)
return V1OrganizationListSchema(
total=total,
page=current_page,
page_size=current_page_size,
items=[
V1OrganizationListItemSchema(
kick_id=org.kick_id,
name=org.name,
logo_url=org.logo_url,
url=org.url,
campaign_count=org.campaign_count, # pyright: ignore[reportAttributeAccessIssue]
active_campaign_count=org.active_campaign_count, # pyright: ignore[reportAttributeAccessIssue]
)
for org in items
],
)
@api.get("/organizations/{kick_id}/", response=V1OrganizationDetailSchema)
def get_organization(
request: HttpRequest,
kick_id: str,
) -> V1OrganizationDetailSchema:
"""Return a single Kick organization with its campaigns."""
organization: KickOrganization = get_object_or_404(
KickOrganization,
kick_id=kick_id,
)
now: datetime.datetime = timezone.now()
campaigns: list[KickDropCampaign] = list(
organization.campaigns # pyright: ignore[reportAttributeAccessIssue]
.filter(is_fully_imported=True)
.select_related("category")
.prefetch_related("rewards")
.order_by("-starts_at"),
)
return V1OrganizationDetailSchema(
kick_id=organization.kick_id,
name=organization.name,
logo_url=organization.logo_url,
url=organization.url,
campaign_count=len(campaigns),
active_campaign_count=sum(
1 for c in campaigns if _campaign_status(c, now) == "active"
),
campaigns=[_serialize_campaign_summary(c, now) for c in campaigns],
)

View file

@ -84,66 +84,63 @@ class KickCampaignApiTest(TestCase):
"kick.api.timezone.now", "kick.api.timezone.now",
return_value=datetime(2026, 7, 15, tzinfo=UTC), 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.status_code == 200
assert response["Content-Type"] == "application/json" assert response["Content-Type"].startswith("application/json")
assert response.json() == { data = response.json()
"total": 1, assert data["total"] == 1
"page": 1, assert data["page"] == 1
"page_size": 100, assert data["page_size"] == 100
"items": [
{ item = data["items"][0]
"kick_id": "campaign-api", assert item["kick_id"] == "campaign-api"
"name": "API Campaign", assert item["name"] == "API Campaign"
"status": "active", assert item["status"] == "active"
"image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png", assert (
"connect_url": "https://example.com/connect", item["image_url"]
"url": "https://example.com/campaign", == "https://ext.cdn.kick.com/drops/reward-image/reward.png"
"starts_at": "2026-07-01T00:00:00Z", )
"ends_at": "2026-08-01T00:00:00Z", assert item["connect_url"] == "https://example.com/connect"
"category": { assert item["url"] == "https://example.com/campaign"
"kick_id": 123, assert item["starts_at"] == "2026-07-01T00:00:00Z"
"name": "API Game", assert item["ends_at"] == "2026-08-01T00:00:00Z"
"slug": "api-game", assert item["category"] == {
"image_url": "https://example.com/game.png", "kick_id": 123,
}, "name": "API Game",
"organization": { "slug": "api-game",
"kick_id": "org-api", "image_url": "https://example.com/game.png",
"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 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: def test_filters_campaigns_by_game(self) -> None:
"""A Kick category ID can select a downstream import subset.""" """A Kick category ID can select a downstream import subset."""
@ -162,7 +159,7 @@ class KickCampaignApiTest(TestCase):
) )
response = self.client.get( response = self.client.get(
reverse("kick:campaign_api_list"), reverse("api-v1:kick-api-v1_list_campaigns"),
{"game": self.category.kick_id}, {"game": self.category.kick_id},
) )
@ -204,12 +201,12 @@ class KickCampaignApiTest(TestCase):
for status, expected_id in expected_ids.items(): for status, expected_id in expected_ids.items():
with self.subTest(status=status): with self.subTest(status=status):
response = self.client.get( response = self.client.get(
reverse("kick:campaign_api_list"), reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": status}, {"status": status},
) )
assert response.status_code == 200 assert response.status_code == 200
assert [item["kick_id"] for item in response.json()["items"]] == [ 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: def test_status_filters_exclude_campaigns_with_partial_dates(self) -> None:
@ -240,7 +237,7 @@ class KickCampaignApiTest(TestCase):
for status in ("upcoming", "expired"): for status in ("upcoming", "expired"):
with self.subTest(status=status): with self.subTest(status=status):
response = self.client.get( response = self.client.get(
reverse("kick:campaign_api_list"), reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": status}, {"status": status},
) )
assert all( assert all(
@ -261,7 +258,7 @@ class KickCampaignApiTest(TestCase):
) )
response = self.client.get( response = self.client.get(
reverse("kick:campaign_api_list"), reverse("api-v1:kick-api-v1_list_campaigns"),
{"page": 2, "page_size": 1}, {"page": 2, "page_size": 1},
) )
@ -277,7 +274,7 @@ class KickCampaignApiTest(TestCase):
def test_clamps_page_size(self) -> None: def test_clamps_page_size(self) -> None:
"""A caller cannot request an unbounded response page.""" """A caller cannot request an unbounded response page."""
response = self.client.get( response = self.client.get(
reverse("kick:campaign_api_list"), reverse("api-v1:kick-api-v1_list_campaigns"),
{"page_size": 1000}, {"page_size": 1000},
) )
@ -287,7 +284,7 @@ class KickCampaignApiTest(TestCase):
def test_clamps_pagination_minimums(self) -> None: def test_clamps_pagination_minimums(self) -> None:
"""Zero and negative pagination values use the first one-item page.""" """Zero and negative pagination values use the first one-item page."""
response = self.client.get( response = self.client.get(
reverse("kick:campaign_api_list"), reverse("api-v1:kick-api-v1_list_campaigns"),
{"page": 0, "page_size": -1}, {"page": 0, "page_size": -1},
) )
@ -300,7 +297,7 @@ class KickCampaignApiTest(TestCase):
for field in ("page", "page_size"): for field in ("page", "page_size"):
with self.subTest(field=field): with self.subTest(field=field):
response = self.client.get( response = self.client.get(
reverse("kick:campaign_api_list"), reverse("api-v1:kick-api-v1_list_campaigns"),
{field: "not-an-integer"}, {field: "not-an-integer"},
) )
assert response.status_code == 422 assert response.status_code == 422
@ -311,7 +308,7 @@ class KickCampaignApiTest(TestCase):
page = 10**100 page = 10**100
response = self.client.get( response = self.client.get(
reverse("kick:campaign_api_list"), reverse("api-v1:kick-api-v1_list_campaigns"),
{"page": page}, {"page": page},
) )
@ -322,7 +319,7 @@ class KickCampaignApiTest(TestCase):
def test_rejects_invalid_status(self) -> None: def test_rejects_invalid_status(self) -> None:
"""Unknown status values return a client error instead of an empty result.""" """Unknown status values return a client error instead of an empty result."""
response = self.client.get( response = self.client.get(
reverse("kick:campaign_api_list"), reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": "running"}, {"status": "running"},
) )
@ -332,7 +329,7 @@ class KickCampaignApiTest(TestCase):
def test_rejects_empty_status(self) -> None: def test_rejects_empty_status(self) -> None:
"""An explicitly empty status is invalid rather than an omitted filter.""" """An explicitly empty status is invalid rather than an omitted filter."""
response = self.client.get( response = self.client.get(
reverse("kick:campaign_api_list"), reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": ""}, {"status": ""},
) )
@ -340,14 +337,14 @@ class KickCampaignApiTest(TestCase):
def test_rejects_non_get_methods(self) -> None: def test_rejects_non_get_methods(self) -> None:
"""The campaign feed is a read-only endpoint.""" """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 assert response.status_code == 405
def test_rejects_invalid_game_id(self) -> None: def test_rejects_invalid_game_id(self) -> None:
"""A non-numeric Kick category ID returns a validation response.""" """A non-numeric Kick category ID returns a validation response."""
response = self.client.get( response = self.client.get(
reverse("kick:campaign_api_list"), reverse("api-v1:kick-api-v1_list_campaigns"),
{"game": "not-an-id"}, {"game": "not-an-id"},
) )
@ -363,7 +360,7 @@ class KickCampaignApiTest(TestCase):
) )
self.campaign.channels.add(channel) 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"] channels = response.json()["items"][0]["channels"]
assert len(channels) == 7 assert len(channels) == 7
@ -383,7 +380,7 @@ class KickCampaignApiTest(TestCase):
campaign=self.campaign, 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"] rewards = response.json()["items"][0]["rewards"]
assert [reward["name"] for reward in rewards] == [ assert [reward["name"] for reward in rewards] == [
@ -404,7 +401,7 @@ class KickCampaignApiTest(TestCase):
is_fully_imported=True, 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 = next(
item item
@ -418,7 +415,7 @@ class KickCampaignApiTest(TestCase):
def select_count() -> int: def select_count() -> int:
with CaptureQueriesContext(connection) as queries: 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 assert response.status_code == 200
return sum( return sum(
query["sql"].lstrip().upper().startswith("SELECT") query["sql"].lstrip().upper().startswith("SELECT")
@ -447,3 +444,367 @@ class KickCampaignApiTest(TestCase):
scaled = select_count() scaled = select_count()
assert scaled == baseline assert scaled == baseline
class KickCampaignDetailApiTest(TestCase):
"""Tests for the campaign detail endpoint."""
@classmethod
def setUpTestData(cls) -> None:
cls.organization = KickOrganization.objects.create(
kick_id="org-detail",
name="Detail Org",
)
cls.category = KickCategory.objects.create(
kick_id=200,
name="Detail Game",
slug="detail-game",
)
cls.campaign = KickDropCampaign.objects.create(
kick_id="campaign-detail",
name="Detail Campaign",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=cls.organization,
category=cls.category,
rule_id=1,
rule_name="Watch to earn",
is_fully_imported=True,
)
cls.user = KickUser.objects.create(
kick_id=457,
username="detail-streamer",
profile_picture="https://example.com/detail.png",
)
cls.channel = KickChannel.objects.create(
kick_id=790,
slug="detail-streamer",
user=cls.user,
)
cls.campaign.channels.add(cls.channel)
cls.reward = KickReward.objects.create(
kick_id="reward-detail",
name="Detail Reward",
required_units=45,
campaign=cls.campaign,
category=cls.category,
organization=cls.organization,
)
def test_detail_returns_campaign(self) -> None:
"""A fully imported campaign returns its full serialized payload."""
with mock.patch(
"kick.api.timezone.now",
return_value=datetime(2026, 7, 15, tzinfo=UTC),
):
response = self.client.get(
reverse(
"api-v1:kick-api-v1_get_campaign",
args=[self.campaign.kick_id],
),
)
assert response.status_code == 200
data = response.json()
assert data["kick_id"] == "campaign-detail"
assert data["status"] == "active"
assert data["name"] == "Detail Campaign"
assert len(data["channels"]) == 1
assert len(data["rewards"]) == 1
def test_detail_not_found(self) -> None:
"""A non-existent campaign ID returns 404."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_campaign", args=["nonexistent-id"]),
)
assert response.status_code == 404
def test_detail_not_fully_imported(self) -> None:
"""A campaign that is not fully imported returns 404."""
KickDropCampaign.objects.create(
kick_id="not-imported-detail",
name="Not Imported",
organization=self.organization,
category=self.category,
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
is_fully_imported=False,
)
response = self.client.get(
reverse("api-v1:kick-api-v1_get_campaign", args=["not-imported-detail"]),
)
assert response.status_code == 404
class KickCampaignSearchFilterTest(TestCase):
"""Tests for the search and organization filter on the campaign list."""
@classmethod
def setUpTestData(cls) -> None:
cls.org_a = KickOrganization.objects.create(
kick_id="org-a",
name="Studio Alpha",
)
cls.org_b = KickOrganization.objects.create(kick_id="org-b", name="Beta Corp")
cls.cat = KickCategory.objects.create(kick_id=300, name="Test Game")
cls.camp_alpha = KickDropCampaign.objects.create(
kick_id="alpha-camp",
name="Alpha Drop Event",
starts_at=datetime(2026, 6, 1, tzinfo=UTC),
ends_at=datetime(2026, 7, 1, tzinfo=UTC),
organization=cls.org_a,
category=cls.cat,
is_fully_imported=True,
)
cls.camp_beta = KickDropCampaign.objects.create(
kick_id="beta-camp",
name="Beta Giveaway",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=cls.org_b,
category=cls.cat,
is_fully_imported=True,
)
cls.camp_alpha2 = KickDropCampaign.objects.create(
kick_id="alpha-camp-2",
name="Alpha Second Wave",
starts_at=datetime(2026, 8, 1, tzinfo=UTC),
ends_at=datetime(2026, 9, 1, tzinfo=UTC),
organization=cls.org_a,
category=cls.cat,
is_fully_imported=True,
)
def test_filters_by_organization(self) -> None:
"""An organization kick_id returns only that org's campaigns."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"organization": "org-a"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert sorted(ids) == ["alpha-camp", "alpha-camp-2"]
def test_organization_filter_with_no_matches(self) -> None:
"""An org with no campaigns returns an empty list."""
KickOrganization.objects.create(kick_id="org-empty", name="Empty Org")
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"organization": "org-empty"},
)
assert response.status_code == 200
assert response.json()["items"] == []
def test_searches_by_name(self) -> None:
"""A search term filters campaigns by case-insensitive name match."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"search": "alpha"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert sorted(ids) == ["alpha-camp", "alpha-camp-2"]
def test_search_with_no_matches(self) -> None:
"""A search term with no matches returns an empty list."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"search": "zzzzz"},
)
assert response.status_code == 200
assert response.json()["items"] == []
def test_search_case_insensitive(self) -> None:
"""Search is case-insensitive."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"search": "ALPHA"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert sorted(ids) == ["alpha-camp", "alpha-camp-2"]
def test_organization_filter_with_search(self) -> None:
"""Organization filter and search can be combined."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"organization": "org-a", "search": "Second"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert ids == ["alpha-camp-2"]
class KickOrganizationApiTest(TestCase):
"""Tests for the organization API endpoints."""
@classmethod
def setUpTestData(cls) -> None:
cls.org_a = KickOrganization.objects.create(
kick_id="org-list-a",
name="Alpha Studios",
logo_url="https://example.com/alpha.png",
url="https://example.com/alpha",
)
cls.org_b = KickOrganization.objects.create(
kick_id="org-list-b",
name="Beta Games",
logo_url="https://example.com/beta.png",
url="https://example.com/beta",
)
cat = KickCategory.objects.create(kick_id=400, name="Game")
for org in (cls.org_a, cls.org_b):
KickDropCampaign.objects.create(
kick_id=f"camp-{org.kick_id}",
name=f"{org.name} Campaign",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=org,
category=cat,
is_fully_imported=True,
)
def test_list_returns_organizations(self) -> None:
"""Organization list returns all orgs with campaign counts."""
response = self.client.get(reverse("api-v1:kick-api-v1_list_organizations"))
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
names = [item["name"] for item in data["items"]]
assert names == ["Alpha Studios", "Beta Games"]
assert all(item["campaign_count"] == 1 for item in data["items"])
def test_detail_returns_organization_with_campaigns(self) -> None:
"""Organization detail returns org metadata and its campaigns."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_organization", args=["org-list-a"]),
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Alpha Studios"
assert data["campaign_count"] == 1
assert len(data["campaigns"]) == 1
assert data["campaigns"][0]["name"] == "Alpha Studios Campaign"
def test_detail_not_found(self) -> None:
"""A non-existent org returns 404."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_organization", args=["nonexistent"]),
)
assert response.status_code == 404
def test_list_pagination(self) -> None:
"""Organization list respects pagination."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_organizations"),
{"page": 1, "page_size": 1},
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["total"] == 2
def test_list_search(self) -> None:
"""Organization list supports search by name."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_organizations"),
{"search": "alpha"},
)
assert response.status_code == 200
names = [item["name"] for item in response.json()["items"]]
assert names == ["Alpha Studios"]
class KickCategoryApiTest(TestCase):
"""Tests for the category (game) API endpoints."""
@classmethod
def setUpTestData(cls) -> None:
cls.cat_a = KickCategory.objects.create(
kick_id=500,
name="Action Game",
slug="action-game",
image_url="https://example.com/action.png",
)
cls.cat_b = KickCategory.objects.create(
kick_id=501,
name="Racing Game",
slug="racing-game",
image_url="https://example.com/racing.png",
)
org = KickOrganization.objects.create(
kick_id="cat-org",
name="Category Org",
)
for cat in (cls.cat_a, cls.cat_b):
KickDropCampaign.objects.create(
kick_id=f"camp-{cat.kick_id}",
name=f"{cat.name} Campaign",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=org,
category=cat,
is_fully_imported=True,
)
def test_list_returns_categories(self) -> None:
"""Category list returns all categories with campaign counts."""
response = self.client.get(reverse("api-v1:kick-api-v1_list_games"))
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
names = [item["name"] for item in data["items"]]
assert names == ["Action Game", "Racing Game"]
assert all(item["campaign_count"] == 1 for item in data["items"])
def test_detail_returns_category_with_campaigns(self) -> None:
"""Category detail returns metadata and its campaigns."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_game", args=[500]),
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Action Game"
assert data["campaign_count"] == 1
assert len(data["campaigns"]) == 1
assert data["campaigns"][0]["name"] == "Action Game Campaign"
def test_detail_not_found(self) -> None:
"""A non-existent category returns 404."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_game", args=[99999]),
)
assert response.status_code == 404
def test_list_search(self) -> None:
"""Category list supports search by name."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_games"),
{"search": "action"},
)
assert response.status_code == 200
names = [item["name"] for item in response.json()["items"]]
assert names == ["Action Game"]
class KickStatsApiTest(TestCase):
"""Tests for the stats endpoint."""
@classmethod
def setUpTestData(cls) -> None:
KickOrganization.objects.create(kick_id="stats-org", name="Stats Org")
KickCategory.objects.create(kick_id=600, name="Stats Game")
KickChannel.objects.create(kick_id=800, slug="stats-channel")
def test_stats_returns_aggregate_counts(self) -> None:
"""Stats endpoint returns counts for all entity types."""
response = self.client.get(reverse("api-v1:kick-api-v1_stats"))
assert response.status_code == 200
data = response.json()
assert data["total_campaigns"] == 0
assert data["total_organizations"] == 1
assert data["total_categories"] == 1
assert data["total_channels"] == 1
for key in ("active", "upcoming", "expired", "partial_dates"):
assert key in data

View file

@ -1,8 +1,8 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.urls import path from django.urls import path
from django.views.generic.base import RedirectView
from kick import api
from kick import views from kick import views
from kick.feeds import KickCampaignAtomFeed from kick.feeds import KickCampaignAtomFeed
from kick.feeds import KickCampaignDiscordFeed from kick.feeds import KickCampaignDiscordFeed
@ -24,18 +24,20 @@ if TYPE_CHECKING:
app_name = "kick" app_name = "kick"
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
# /kick/api/v1/campaigns/
path(
route="api/v1/campaigns/",
view=api.campaign_list_api,
name="campaign_api_list",
),
# /kick/ # /kick/
path( path(
route="", route="",
view=views.dashboard, view=views.dashboard,
name="dashboard", name="dashboard",
), ),
# Redirect old Kick v1 campaign API to the new combined API
path(
route="api/v1/campaigns/",
view=RedirectView.as_view(
pattern_name="api-v1:kick-api-v1_list_campaigns",
permanent=True,
),
),
# /kick/campaigns/ # /kick/campaigns/
path( path(
route="campaigns/", route="campaigns/",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -801,6 +801,10 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904
queryset = queryset.filter(start_at__gt=now) queryset = queryset.filter(start_at__gt=now)
elif status == "expired": elif status == "expired":
queryset = queryset.filter(end_at__lt=now) queryset = queryset.filter(end_at__lt=now)
elif status == "unknown":
queryset = queryset.filter(
Q(start_at__isnull=True) | Q(end_at__isnull=True),
)
return queryset return queryset
@classmethod @classmethod

View file

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

View file

@ -2618,7 +2618,7 @@ class TestRewardCampaignViews:
) )
assert reverse("twitch:game_detail", args=[game.twitch_id]) in content assert reverse("twitch:game_detail", args=[game.twitch_id]) in content
assert reverse("core:reward_campaign_feed") in content assert reverse("core:reward_campaign_feed") in content
assert reverse("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 assert response.context["reward_campaigns"].paginator.count == 2
def test_reward_campaign_list_filters_status_and_game( def test_reward_campaign_list_filters_status_and_game(
@ -2691,7 +2691,7 @@ class TestRewardCampaignViews:
assert reverse("twitch:game_detail", args=[game.twitch_id]) in content assert reverse("twitch:game_detail", args=[game.twitch_id]) in content
assert ( assert (
reverse( reverse(
"twitch:twitch-api-v1:get_reward_campaign", "api-v1:twitch-api-v1_get_reward_campaign",
args=[reward.twitch_id], args=[reward.twitch_id],
) )
in content in content

View file

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