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

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

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/",