Add public Kick campaign JSON API

This commit is contained in:
Tomáš Dinh 2026-07-15 04:51:32 +07:00 committed by Joakim Hellsén
commit 3d46cb5ec9
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
5 changed files with 716 additions and 3 deletions

View file

@ -276,6 +276,7 @@ class SiteEndpointSmokeTest(TestCase):
("twitch:export_organizations_csv", {}, 200), ("twitch:export_organizations_csv", {}, 200),
("twitch:export_organizations_json", {}, 200), ("twitch:export_organizations_json", {}, 200),
# Kick endpoints # Kick endpoints
("kick:campaign_api_list", {}, 200),
("kick:dashboard", {}, 200), ("kick:dashboard", {}, 200),
("kick:campaign_list", {}, 200), ("kick:campaign_list", {}, 200),
("kick:campaign_detail", {"kick_id": self.kick_campaign.kick_id}, 200), ("kick:campaign_detail", {"kick_id": self.kick_campaign.kick_id}, 200),

213
kick/api.py Normal file
View file

@ -0,0 +1,213 @@
from __future__ import annotations
from datetime import UTC
from typing import TYPE_CHECKING
from typing import Any
from typing import Literal
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET
from kick.models import KickDropCampaign
if TYPE_CHECKING:
from datetime import datetime
from django.db.models import QuerySet
from django.http import HttpRequest
from kick.models import KickCategory
from kick.models import KickChannel
from kick.models import KickOrganization
from kick.models import KickReward
from kick.models import KickUser
CampaignStatus = Literal["active", "upcoming", "expired", "unknown"]
VALID_STATUS_FILTERS: frozenset[str] = frozenset({"active", "upcoming", "expired"})
DEFAULT_PAGE_SIZE = 100
MAX_PAGE_SIZE = 500
def _datetime_to_json(value: datetime | None) -> str | None:
if value is None:
return None
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
def _campaign_status(campaign: KickDropCampaign, now: datetime) -> CampaignStatus:
if campaign.starts_at and campaign.ends_at:
if campaign.starts_at <= now <= campaign.ends_at:
return "active"
if campaign.starts_at > now:
return "upcoming"
return "expired"
return "unknown"
def _serialize_category(category: KickCategory | None) -> dict[str, Any] | None:
if category is None:
return None
return {
"kick_id": category.kick_id,
"name": category.name,
"slug": category.slug,
"image_url": category.image_url,
}
def _serialize_organization(
organization: KickOrganization | None,
) -> dict[str, Any] | None:
if organization is None:
return None
return {
"kick_id": organization.kick_id,
"name": organization.name,
"logo_url": organization.logo_url,
"url": organization.url,
}
def _serialize_user(user: KickUser | None) -> dict[str, Any] | None:
if user is None:
return None
return {
"kick_id": user.kick_id,
"username": user.username,
"profile_picture": user.profile_picture,
}
def _serialize_channel(channel: KickChannel) -> dict[str, Any]:
return {
"kick_id": channel.kick_id,
"slug": channel.slug,
"url": channel.channel_url,
"user": _serialize_user(channel.user),
}
def _serialize_reward(reward: KickReward) -> dict[str, Any]:
return {
"kick_id": reward.kick_id,
"name": reward.name,
"image_url": reward.full_image_url,
"required_minutes_watched": reward.required_units,
}
def _serialize_campaign(
campaign: KickDropCampaign,
now: datetime,
) -> dict[str, Any]:
return {
"kick_id": campaign.kick_id,
"name": campaign.name,
"status": _campaign_status(campaign, now),
"image_url": campaign.image_url,
"connect_url": campaign.connect_url,
"url": campaign.url,
"starts_at": _datetime_to_json(campaign.starts_at),
"ends_at": _datetime_to_json(campaign.ends_at),
"category": _serialize_category(campaign.category),
"organization": _serialize_organization(campaign.organization),
"channels": [
_serialize_channel(channel) for channel in campaign.channels.all()
],
"rewards": [_serialize_reward(reward) for reward in campaign.rewards.all()],
"is_fully_imported": campaign.is_fully_imported,
"added_at": _datetime_to_json(campaign.added_at),
"updated_at": _datetime_to_json(campaign.updated_at),
}
def _parse_integer_query(
request: HttpRequest,
name: str,
default: int,
) -> int | JsonResponse:
raw_value: str | None = request.GET.get(name)
if raw_value is None:
return default
try:
return int(raw_value)
except ValueError:
return JsonResponse(
{
"detail": [
{
"type": "int_parsing",
"loc": ["query", name],
"msg": "Input should be a valid integer",
},
],
},
status=422,
)
@require_GET
def campaign_list_api(request: HttpRequest) -> JsonResponse:
"""Return a paginated JSON representation of fully imported Kick campaigns."""
page = _parse_integer_query(request, "page", 1)
if isinstance(page, JsonResponse):
return page
page = max(page, 1)
page_size = _parse_integer_query(request, "page_size", DEFAULT_PAGE_SIZE)
if isinstance(page_size, JsonResponse):
return page_size
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
status_filter: str | None = request.GET.get("status")
if status_filter is not None and status_filter not in VALID_STATUS_FILTERS:
return JsonResponse(
{
"detail": [
{
"type": "literal_error",
"loc": ["query", "status"],
"msg": "Input should be 'active', 'upcoming' or 'expired'",
},
],
},
status=422,
)
now: datetime = timezone.now()
queryset: QuerySet[KickDropCampaign] = (
KickDropCampaign.objects
.filter(is_fully_imported=True)
.select_related("category", "organization")
.prefetch_related("channels__user", "rewards")
.order_by("-starts_at", "kick_id")
)
game_filter: int | JsonResponse | None = None
if request.GET.get("game") is not None:
game_filter = _parse_integer_query(request, "game", 0)
if isinstance(game_filter, JsonResponse):
return game_filter
if game_filter is not None:
queryset = queryset.filter(category__kick_id=game_filter)
if status_filter == "active":
queryset = queryset.filter(starts_at__lte=now, ends_at__gte=now)
elif status_filter == "upcoming":
queryset = queryset.filter(starts_at__gt=now, ends_at__isnull=False)
elif status_filter == "expired":
queryset = queryset.filter(starts_at__lte=now, ends_at__lt=now)
total: int = queryset.count()
offset: int = (page - 1) * page_size
campaigns: list[KickDropCampaign] = (
list(queryset[offset : offset + page_size]) if offset < total else []
)
return JsonResponse({
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_campaign(campaign, now) for campaign in campaigns],
})

449
kick/tests/test_api.py Normal file
View file

@ -0,0 +1,449 @@
from __future__ import annotations
from datetime import UTC
from datetime import datetime
from unittest import mock
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from kick.models import KickCategory
from kick.models import KickChannel
from kick.models import KickDropCampaign
from kick.models import KickOrganization
from kick.models import KickReward
from kick.models import KickUser
class KickCampaignApiTest(TestCase):
"""Tests for the public Kick campaign JSON API."""
@classmethod
def setUpTestData(cls) -> None:
"""Create a fully populated campaign for API contract tests."""
cls.organization = KickOrganization.objects.create(
kick_id="org-api",
name="API Organization",
logo_url="https://example.com/org.png",
url="https://example.com/org",
)
cls.category = KickCategory.objects.create(
kick_id=123,
name="API Game",
slug="api-game",
image_url="https://example.com/game.png",
)
cls.campaign = KickDropCampaign.objects.create(
kick_id="campaign-api",
name="API Campaign",
status="expired",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
connect_url="https://example.com/connect",
url="https://example.com/campaign",
organization=cls.organization,
category=cls.category,
rule_id=1,
rule_name="Watch to earn",
is_fully_imported=True,
)
KickDropCampaign.objects.create(
kick_id="campaign-not-imported",
name="Campaign Not Imported",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=cls.organization,
category=cls.category,
)
user = KickUser.objects.create(
kick_id=456,
username="api-streamer",
profile_picture="https://example.com/avatar.png",
)
channel = KickChannel.objects.create(
kick_id=789,
slug="api-streamer",
user=user,
)
cls.campaign.channels.add(channel)
KickReward.objects.create(
kick_id="reward-api",
name="API Reward",
image_url="drops/reward-image/reward.png",
required_units=30,
campaign=cls.campaign,
category=cls.category,
organization=cls.organization,
)
def test_list_returns_paginated_campaigns_with_nested_data(self) -> None:
"""A campaign includes the fields needed by downstream importers."""
with mock.patch(
"kick.api.timezone.now",
return_value=datetime(2026, 7, 15, tzinfo=UTC),
):
response = self.client.get(reverse("kick:campaign_api_list"))
assert response.status_code == 200
assert response["Content-Type"] == "application/json"
assert response.json() == {
"total": 1,
"page": 1,
"page_size": 100,
"items": [
{
"kick_id": "campaign-api",
"name": "API Campaign",
"status": "active",
"image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png",
"connect_url": "https://example.com/connect",
"url": "https://example.com/campaign",
"starts_at": "2026-07-01T00:00:00Z",
"ends_at": "2026-08-01T00:00:00Z",
"category": {
"kick_id": 123,
"name": "API Game",
"slug": "api-game",
"image_url": "https://example.com/game.png",
},
"organization": {
"kick_id": "org-api",
"name": "API Organization",
"logo_url": "https://example.com/org.png",
"url": "https://example.com/org",
},
"channels": [
{
"kick_id": 789,
"slug": "api-streamer",
"url": "https://kick.com/api-streamer",
"user": {
"kick_id": 456,
"username": "api-streamer",
"profile_picture": "https://example.com/avatar.png",
},
},
],
"rewards": [
{
"kick_id": "reward-api",
"name": "API Reward",
"image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png",
"required_minutes_watched": 30,
},
],
"is_fully_imported": True,
"added_at": self.campaign.added_at.isoformat().replace(
"+00:00", "Z"
),
"updated_at": self.campaign.updated_at.isoformat().replace(
"+00:00", "Z"
),
},
],
}
def test_filters_campaigns_by_game(self) -> None:
"""A Kick category ID can select a downstream import subset."""
other_category = KickCategory.objects.create(
kick_id=124,
name="Other Game",
)
KickDropCampaign.objects.create(
kick_id="other-campaign",
name="Other Campaign",
starts_at=datetime(2026, 8, 2, tzinfo=UTC),
ends_at=datetime(2026, 9, 1, tzinfo=UTC),
organization=self.organization,
category=other_category,
is_fully_imported=True,
)
response = self.client.get(
reverse("kick:campaign_api_list"),
{"game": self.category.kick_id},
)
assert response.status_code == 200
assert [item["kick_id"] for item in response.json()["items"]] == [
self.campaign.kick_id,
]
def test_filters_campaigns_by_computed_status(self) -> None:
"""All supported status filters are computed from campaign dates."""
KickDropCampaign.objects.create(
kick_id="upcoming-campaign",
name="Upcoming Campaign",
starts_at=datetime(2026, 8, 2, tzinfo=UTC),
ends_at=datetime(2026, 9, 1, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
KickDropCampaign.objects.create(
kick_id="expired-campaign",
name="Expired Campaign",
starts_at=datetime(2026, 6, 1, tzinfo=UTC),
ends_at=datetime(2026, 6, 2, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
expected_ids = {
"active": self.campaign.kick_id,
"upcoming": "upcoming-campaign",
"expired": "expired-campaign",
}
with mock.patch(
"kick.api.timezone.now",
return_value=datetime(2026, 7, 15, tzinfo=UTC),
):
for status, expected_id in expected_ids.items():
with self.subTest(status=status):
response = self.client.get(
reverse("kick:campaign_api_list"),
{"status": status},
)
assert response.status_code == 200
assert [item["kick_id"] for item in response.json()["items"]] == [
expected_id
]
def test_status_filters_exclude_campaigns_with_partial_dates(self) -> None:
"""Filtered items never serialize with a contradictory unknown status."""
KickDropCampaign.objects.create(
kick_id="partial-upcoming-campaign",
name="Partial Upcoming Campaign",
starts_at=datetime(2026, 8, 2, tzinfo=UTC),
ends_at=None,
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
KickDropCampaign.objects.create(
kick_id="partial-expired-campaign",
name="Partial Expired Campaign",
starts_at=None,
ends_at=datetime(2026, 6, 2, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
with mock.patch(
"kick.api.timezone.now",
return_value=datetime(2026, 7, 15, tzinfo=UTC),
):
for status in ("upcoming", "expired"):
with self.subTest(status=status):
response = self.client.get(
reverse("kick:campaign_api_list"),
{"status": status},
)
assert all(
item["status"] == status for item in response.json()["items"]
)
def test_paginates_campaigns(self) -> None:
"""Page and page_size select a stable result slice."""
for index in range(2):
KickDropCampaign.objects.create(
kick_id=f"pagination-campaign-{index}",
name=f"Pagination Campaign {index}",
starts_at=datetime(2026, 6, index + 1, tzinfo=UTC),
ends_at=datetime(2026, 6, index + 2, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
response = self.client.get(
reverse("kick:campaign_api_list"),
{"page": 2, "page_size": 1},
)
data = response.json()
assert response.status_code == 200
assert data["total"] == 3
assert data["page"] == 2
assert data["page_size"] == 1
assert [item["kick_id"] for item in data["items"]] == [
"pagination-campaign-1",
]
def test_clamps_page_size(self) -> None:
"""A caller cannot request an unbounded response page."""
response = self.client.get(
reverse("kick:campaign_api_list"),
{"page_size": 1000},
)
assert response.status_code == 200
assert response.json()["page_size"] == 500
def test_clamps_pagination_minimums(self) -> None:
"""Zero and negative pagination values use the first one-item page."""
response = self.client.get(
reverse("kick:campaign_api_list"),
{"page": 0, "page_size": -1},
)
assert response.status_code == 200
assert response.json()["page"] == 1
assert response.json()["page_size"] == 1
def test_rejects_non_integer_pagination_values(self) -> None:
"""Malformed pagination values return field-specific validation errors."""
for field in ("page", "page_size"):
with self.subTest(field=field):
response = self.client.get(
reverse("kick:campaign_api_list"),
{field: "not-an-integer"},
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", field]
def test_page_beyond_result_set_returns_empty_items(self) -> None:
"""An arbitrarily large page does not become a pathological DB offset."""
page = 10**100
response = self.client.get(
reverse("kick:campaign_api_list"),
{"page": page},
)
assert response.status_code == 200
assert response.json()["page"] == page
assert response.json()["items"] == []
def test_rejects_invalid_status(self) -> None:
"""Unknown status values return a client error instead of an empty result."""
response = self.client.get(
reverse("kick:campaign_api_list"),
{"status": "running"},
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "status"]
def test_rejects_empty_status(self) -> None:
"""An explicitly empty status is invalid rather than an omitted filter."""
response = self.client.get(
reverse("kick:campaign_api_list"),
{"status": ""},
)
assert response.status_code == 422
def test_rejects_non_get_methods(self) -> None:
"""The campaign feed is a read-only endpoint."""
response = self.client.post(reverse("kick:campaign_api_list"))
assert response.status_code == 405
def test_rejects_invalid_game_id(self) -> None:
"""A non-numeric Kick category ID returns a validation response."""
response = self.client.get(
reverse("kick:campaign_api_list"),
{"game": "not-an-id"},
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "game"]
def test_returns_every_eligible_channel(self) -> None:
"""The JSON API does not apply the five-channel RSS display limit."""
for index in range(6):
channel = KickChannel.objects.create(
kick_id=1000 + index,
slug=f"extra-streamer-{index}",
)
self.campaign.channels.add(channel)
response = self.client.get(reverse("kick:campaign_api_list"))
channels = response.json()["items"][0]["channels"]
assert len(channels) == 7
def test_returns_every_raw_reward_in_stable_order(self) -> None:
"""Rewards are neither merged nor truncated for downstream importers."""
KickReward.objects.create(
kick_id="reward-api-con",
name="API Reward (Con)",
required_units=30,
campaign=self.campaign,
)
KickReward.objects.create(
kick_id="reward-api-beta",
name="Beta Reward",
required_units=60,
campaign=self.campaign,
)
response = self.client.get(reverse("kick:campaign_api_list"))
rewards = response.json()["items"][0]["rewards"]
assert [reward["name"] for reward in rewards] == [
"API Reward",
"API Reward (Con)",
"Beta Reward",
]
def test_empty_channels_remain_an_empty_list(self) -> None:
"""Missing channel data is not presented as proof of global eligibility."""
campaign = KickDropCampaign.objects.create(
kick_id="campaign-without-channels",
name="Campaign Without Channels",
starts_at=datetime(2026, 5, 1, tzinfo=UTC),
ends_at=datetime(2026, 5, 2, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
response = self.client.get(reverse("kick:campaign_api_list"))
item = next(
item
for item in response.json()["items"]
if item["kick_id"] == campaign.kick_id
)
assert item["channels"] == []
def test_query_count_does_not_grow_with_campaign_count(self) -> None:
"""Nested serialization avoids per-campaign database queries."""
def select_count() -> int:
with CaptureQueriesContext(connection) as queries:
response = self.client.get(reverse("kick:campaign_api_list"))
assert response.status_code == 200
return sum(
query["sql"].lstrip().upper().startswith("SELECT")
for query in queries.captured_queries
)
baseline = select_count()
for index in range(8):
campaign = KickDropCampaign.objects.create(
kick_id=f"query-campaign-{index}",
name=f"Query Campaign {index}",
starts_at=datetime(2026, 4, 1, tzinfo=UTC),
ends_at=datetime(2026, 4, 2, tzinfo=UTC),
organization=self.organization,
category=self.category,
is_fully_imported=True,
)
KickReward.objects.create(
kick_id=f"query-reward-{index}",
name=f"Query Reward {index}",
required_units=15,
campaign=campaign,
)
scaled = select_count()
assert scaled == baseline

View file

@ -2,6 +2,7 @@ from typing import TYPE_CHECKING
from django.urls import path from django.urls import path
from kick import api
from kick import views from kick import views
from kick.feeds import KickCampaignAtomFeed from kick.feeds import KickCampaignAtomFeed
from kick.feeds import KickCampaignDiscordFeed from kick.feeds import KickCampaignDiscordFeed
@ -23,6 +24,12 @@ if TYPE_CHECKING:
app_name = "kick" app_name = "kick"
urlpatterns: list[URLPattern | URLResolver] = [ urlpatterns: list[URLPattern | URLResolver] = [
# /kick/api/v1/campaigns/
path(
route="api/v1/campaigns/",
view=api.campaign_list_api,
name="campaign_api_list",
),
# /kick/ # /kick/
path( path(
route="", route="",

View file

@ -9,9 +9,10 @@
<h2>About this dataset</h2> <h2>About this dataset</h2>
<p>This site tracks and publishes open Twitch and Kick drop campaign data.</p> <p>This site tracks and publishes open Twitch and Kick drop campaign data.</p>
<p> <p>
The exported datasets on this page are released under <strong>CC0</strong> so you can reuse them freely. Campaign metadata in the downloadable datasets and public JSON API is released under
The underlying source data is scraped from Twitch/Kick APIs and pages, so we do not control the <strong>CC0</strong> so you can reuse it freely. The underlying source data is scraped from Twitch/Kick
upstream content and cannot guarantee upstream accuracy or permanence. APIs and pages, so we do not control the upstream content and cannot guarantee upstream accuracy or
permanence. Linked third-party images, logos, and trademarks may remain subject to separate rights.
</p> </p>
<p>Note that some drops has missing or incomplete data due to Twitch API limitations.</p> <p>Note that some drops has missing or incomplete data due to Twitch API limitations.</p>
<p> <p>
@ -28,6 +29,48 @@
and describe what you need. and describe what you need.
</p> </p>
</section> </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
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>.
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": [{
"kick_id": "...",
"name": "...",
"status": "active",
"starts_at": "2026-07-01T00:00:00Z",
"ends_at": "2026-08-01T00:00:00Z",
"category": { "kick_id": 123, "name": "..." },
"organization": { "kick_id": "...", "name": "..." },
"channels": [],
"rewards": [{ "name": "...", "required_minutes_watched": 30 }]
}]
}</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.
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.
</p>
</section>
{% if datasets %} {% if datasets %}
<table> <table>
<thead> <thead>