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_json", {}, 200),
# Kick endpoints
("kick:campaign_api_list", {}, 200),
("api-v1:kick-api-v1_list_campaigns", {}, 200),
(
"api-v1:kick-api-v1_get_campaign",
{"kick_id": self.kick_campaign.kick_id},
200,
),
("api-v1:kick-api-v1_list_games", {}, 200),
(
"api-v1:kick-api-v1_get_game",
{"kick_id": self.kick_category.kick_id},
200,
),
("api-v1:kick-api-v1_list_organizations", {}, 200),
(
"api-v1:kick-api-v1_get_organization",
{"kick_id": self.kick_org.kick_id},
200,
),
("api-v1:kick-api-v1_stats", {}, 200),
("kick:dashboard", {}, 200),
("kick:campaign_list", {}, 200),
("kick:campaign_detail", {"kick_id": self.kick_campaign.kick_id}, 200),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -203,6 +203,7 @@
<a href="{% url 'core:dataset_backups' %}">Dataset</a> |
<a href="https://github.com/sponsors/TheLovinator1">Donate</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">
<input type="search"
name="q"
@ -220,7 +221,6 @@
<a href="{% url 'twitch:channel_list' %}">Channels</a> |
<a href="{% url 'twitch:badge_list' %}">Badges</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>
<br />
<strong>Kick</strong>
@ -234,10 +234,7 @@
<a href="{% url 'chzzk:campaign_list' %}">Campaigns</a>
<br />
<strong>Other sites</strong>
<a href="#">Steam</a> |
<a href="{% url 'youtube:index' %}">YouTube</a> |
<a href="#">TikTok</a> |
<a href="#">Discord</a>
</nav>
<hr />
{% if messages %}

View file

@ -47,7 +47,7 @@
title="Atom feed for Twitch campaigns">[atom]</a>
<a href="{% url 'core:campaign_feed_discord' %}"
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>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div>

View file

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

View file

@ -127,6 +127,38 @@
<p>None ✅</p>
{% endif %}
</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>
<h2>Duplicate Campaign Names Per Game ({{ duplicate_name_campaigns|length }})</h2>
{% if duplicate_name_campaigns %}

View file

@ -18,7 +18,7 @@
</p>
<p>
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>
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 %}"
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 '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>
{% endif %}
</div>

View file

@ -33,7 +33,7 @@
title="Atom feed for all campaigns">[atom]</a>
<a href="{% url 'core:campaign_feed_discord' %}"
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>
<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 %}"

View file

@ -36,7 +36,7 @@
title="Atom feed for campaigns">[atom]</a>
<a href="{% url 'core:campaign_feed_discord' %}"
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>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div>

View file

@ -91,7 +91,7 @@
title="Discord feed for {{ game.display_name }} rewards">[discord]</a>
</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>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
</div>

View file

@ -32,7 +32,7 @@
title="Atom feed for all games">[atom]</a>
<a href="{% url 'core:game_feed_discord' %}"
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>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:export_games_csv' %}"

View file

@ -30,7 +30,7 @@
title="Atom feed for all games">[atom]</a>
<a href="{% url 'core:game_feed_discord' %}"
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>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:export_games_csv' %}"

View file

@ -15,7 +15,7 @@
title="Atom feed for all organizations">[atom]</a>
<a href="{% url 'core:organization_feed_discord' %}"
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>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
<a href="{% url 'twitch:export_organizations_csv' %}"

View file

@ -123,7 +123,7 @@
title="Atom feed for all reward campaigns">[atom]</a>
<a href="{% url 'core:reward_campaign_feed_discord' %}"
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>
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
{% if reward_campaign.external_url %}

View file

@ -30,7 +30,7 @@
<div>
<div>
<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>
</div>
<div>

View file

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

View file

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

View file

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

View file

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

View file

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