Move all the APIs under the same router
This commit is contained in:
parent
3d46cb5ec9
commit
c54ceeb7a8
27 changed files with 1289 additions and 351 deletions
19
config/api.py
Normal file
19
config/api.py
Normal 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]
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
693
kick/api.py
693
kick/api.py
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
kick/urls.py
16
kick/urls.py
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 >= 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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' %}"
|
||||
|
|
|
|||
|
|
@ -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' %}"
|
||||
|
|
|
|||
|
|
@ -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' %}"
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue