Add public Kick campaign JSON API
This commit is contained in:
parent
58d0ddeb43
commit
3d46cb5ec9
5 changed files with 716 additions and 3 deletions
|
|
@ -276,6 +276,7 @@ class SiteEndpointSmokeTest(TestCase):
|
|||
("twitch:export_organizations_csv", {}, 200),
|
||||
("twitch:export_organizations_json", {}, 200),
|
||||
# Kick endpoints
|
||||
("kick:campaign_api_list", {}, 200),
|
||||
("kick:dashboard", {}, 200),
|
||||
("kick:campaign_list", {}, 200),
|
||||
("kick:campaign_detail", {"kick_id": self.kick_campaign.kick_id}, 200),
|
||||
|
|
|
|||
213
kick/api.py
Normal file
213
kick/api.py
Normal 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
449
kick/tests/test_api.py
Normal 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
|
||||
|
|
@ -2,6 +2,7 @@ from typing import TYPE_CHECKING
|
|||
|
||||
from django.urls import path
|
||||
|
||||
from kick import api
|
||||
from kick import views
|
||||
from kick.feeds import KickCampaignAtomFeed
|
||||
from kick.feeds import KickCampaignDiscordFeed
|
||||
|
|
@ -23,6 +24,12 @@ 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="",
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@
|
|||
<h2>About this dataset</h2>
|
||||
<p>This site tracks and publishes open Twitch and Kick drop campaign data.</p>
|
||||
<p>
|
||||
The exported datasets on this page are released under <strong>CC0</strong> so you can reuse them 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 permanence.
|
||||
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
|
||||
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>
|
||||
|
|
@ -28,6 +29,48 @@
|
|||
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
|
||||
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 >= 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 %}
|
||||
<table>
|
||||
<thead>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue