From c54ceeb7a8055025ee05608cf534d31df7b047e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Tue, 21 Jul 2026 04:05:35 +0200 Subject: [PATCH] Move all the APIs under the same router --- config/api.py | 19 + config/tests/test_site_endpoint_smoke.py | 20 +- config/urls.py | 5 + core/views.py | 25 + kick/api.py | 693 +++++++++++++++---- kick/tests/test_api.py | 509 ++++++++++++-- kick/urls.py | 16 +- pyproject.toml | 4 + templates/base.html | 5 +- templates/core/dashboard.html | 2 +- templates/core/dataset_backups.html | 83 ++- templates/core/debug.html | 32 + templates/core/docs_rss.html | 2 +- templates/twitch/campaign_detail.html | 2 +- templates/twitch/campaign_list.html | 2 +- templates/twitch/dashboard.html | 2 +- templates/twitch/game_detail.html | 2 +- templates/twitch/games_grid.html | 2 +- templates/twitch/games_list.html | 2 +- templates/twitch/org_list.html | 2 +- templates/twitch/reward_campaign_detail.html | 2 +- templates/twitch/reward_campaign_list.html | 2 +- twitch/api.py | 15 +- twitch/models.py | 4 + twitch/tests/test_api.py | 154 ++--- twitch/tests/test_views.py | 4 +- twitch/urls.py | 28 +- 27 files changed, 1288 insertions(+), 350 deletions(-) create mode 100644 config/api.py diff --git a/config/api.py b/config/api.py new file mode 100644 index 0000000..1d96550 --- /dev/null +++ b/config/api.py @@ -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] diff --git a/config/tests/test_site_endpoint_smoke.py b/config/tests/test_site_endpoint_smoke.py index 80be912..2cc7b54 100644 --- a/config/tests/test_site_endpoint_smoke.py +++ b/config/tests/test_site_endpoint_smoke.py @@ -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), diff --git a/config/urls.py b/config/urls.py index f4d56ad..be4ce90 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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 diff --git a/core/views.py b/core/views.py index 6b20b55..963ebe3 100644 --- a/core/views.py +++ b/core/views.py @@ -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( diff --git a/kick/api.py b/kick/api.py index 3063926..50b0065 100644 --- a/kick/api.py +++ b/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], + ) diff --git a/kick/tests/test_api.py b/kick/tests/test_api.py index 76d829d..ee63ae2 100644 --- a/kick/tests/test_api.py +++ b/kick/tests/test_api.py @@ -84,66 +84,63 @@ class KickCampaignApiTest(TestCase): "kick.api.timezone.now", return_value=datetime(2026, 7, 15, tzinfo=UTC), ): - response = self.client.get(reverse("kick:campaign_api_list")) + response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns")) assert response.status_code == 200 - assert response["Content-Type"] == "application/json" - assert response.json() == { - "total": 1, - "page": 1, - "page_size": 100, - "items": [ - { - "kick_id": "campaign-api", - "name": "API Campaign", - "status": "active", - "image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png", - "connect_url": "https://example.com/connect", - "url": "https://example.com/campaign", - "starts_at": "2026-07-01T00:00:00Z", - "ends_at": "2026-08-01T00:00:00Z", - "category": { - "kick_id": 123, - "name": "API Game", - "slug": "api-game", - "image_url": "https://example.com/game.png", - }, - "organization": { - "kick_id": "org-api", - "name": "API Organization", - "logo_url": "https://example.com/org.png", - "url": "https://example.com/org", - }, - "channels": [ - { - "kick_id": 789, - "slug": "api-streamer", - "url": "https://kick.com/api-streamer", - "user": { - "kick_id": 456, - "username": "api-streamer", - "profile_picture": "https://example.com/avatar.png", - }, - }, - ], - "rewards": [ - { - "kick_id": "reward-api", - "name": "API Reward", - "image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png", - "required_minutes_watched": 30, - }, - ], - "is_fully_imported": True, - "added_at": self.campaign.added_at.isoformat().replace( - "+00:00", "Z" - ), - "updated_at": self.campaign.updated_at.isoformat().replace( - "+00:00", "Z" - ), - }, - ], + assert response["Content-Type"].startswith("application/json") + data = response.json() + assert data["total"] == 1 + assert data["page"] == 1 + assert data["page_size"] == 100 + + item = data["items"][0] + assert item["kick_id"] == "campaign-api" + assert item["name"] == "API Campaign" + assert item["status"] == "active" + assert ( + item["image_url"] + == "https://ext.cdn.kick.com/drops/reward-image/reward.png" + ) + assert item["connect_url"] == "https://example.com/connect" + assert item["url"] == "https://example.com/campaign" + assert item["starts_at"] == "2026-07-01T00:00:00Z" + assert item["ends_at"] == "2026-08-01T00:00:00Z" + assert item["category"] == { + "kick_id": 123, + "name": "API Game", + "slug": "api-game", + "image_url": "https://example.com/game.png", } + assert item["organization"] == { + "kick_id": "org-api", + "name": "API Organization", + "logo_url": "https://example.com/org.png", + "url": "https://example.com/org", + } + assert item["reward_count"] == 1 + assert item["channels"] == [ + { + "kick_id": 789, + "slug": "api-streamer", + "url": "https://kick.com/api-streamer", + "user": { + "kick_id": 456, + "username": "api-streamer", + "profile_picture": "https://example.com/avatar.png", + }, + }, + ] + assert item["rewards"] == [ + { + "kick_id": "reward-api", + "name": "API Reward", + "image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png", + "required_minutes_watched": 30, + }, + ] + assert item["is_fully_imported"] is True + assert "added_at" in item + assert "updated_at" in item def test_filters_campaigns_by_game(self) -> None: """A Kick category ID can select a downstream import subset.""" @@ -162,7 +159,7 @@ class KickCampaignApiTest(TestCase): ) response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"game": self.category.kick_id}, ) @@ -204,12 +201,12 @@ class KickCampaignApiTest(TestCase): for status, expected_id in expected_ids.items(): with self.subTest(status=status): response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"status": status}, ) assert response.status_code == 200 assert [item["kick_id"] for item in response.json()["items"]] == [ - expected_id + expected_id, ] def test_status_filters_exclude_campaigns_with_partial_dates(self) -> None: @@ -240,7 +237,7 @@ class KickCampaignApiTest(TestCase): for status in ("upcoming", "expired"): with self.subTest(status=status): response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"status": status}, ) assert all( @@ -261,7 +258,7 @@ class KickCampaignApiTest(TestCase): ) response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"page": 2, "page_size": 1}, ) @@ -277,7 +274,7 @@ class KickCampaignApiTest(TestCase): def test_clamps_page_size(self) -> None: """A caller cannot request an unbounded response page.""" response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"page_size": 1000}, ) @@ -287,7 +284,7 @@ class KickCampaignApiTest(TestCase): def test_clamps_pagination_minimums(self) -> None: """Zero and negative pagination values use the first one-item page.""" response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"page": 0, "page_size": -1}, ) @@ -300,7 +297,7 @@ class KickCampaignApiTest(TestCase): for field in ("page", "page_size"): with self.subTest(field=field): response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {field: "not-an-integer"}, ) assert response.status_code == 422 @@ -311,7 +308,7 @@ class KickCampaignApiTest(TestCase): page = 10**100 response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"page": page}, ) @@ -322,7 +319,7 @@ class KickCampaignApiTest(TestCase): def test_rejects_invalid_status(self) -> None: """Unknown status values return a client error instead of an empty result.""" response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"status": "running"}, ) @@ -332,7 +329,7 @@ class KickCampaignApiTest(TestCase): def test_rejects_empty_status(self) -> None: """An explicitly empty status is invalid rather than an omitted filter.""" response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"status": ""}, ) @@ -340,14 +337,14 @@ class KickCampaignApiTest(TestCase): def test_rejects_non_get_methods(self) -> None: """The campaign feed is a read-only endpoint.""" - response = self.client.post(reverse("kick:campaign_api_list")) + response = self.client.post(reverse("api-v1:kick-api-v1_list_campaigns")) assert response.status_code == 405 def test_rejects_invalid_game_id(self) -> None: """A non-numeric Kick category ID returns a validation response.""" response = self.client.get( - reverse("kick:campaign_api_list"), + reverse("api-v1:kick-api-v1_list_campaigns"), {"game": "not-an-id"}, ) @@ -363,7 +360,7 @@ class KickCampaignApiTest(TestCase): ) self.campaign.channels.add(channel) - response = self.client.get(reverse("kick:campaign_api_list")) + response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns")) channels = response.json()["items"][0]["channels"] assert len(channels) == 7 @@ -383,7 +380,7 @@ class KickCampaignApiTest(TestCase): campaign=self.campaign, ) - response = self.client.get(reverse("kick:campaign_api_list")) + response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns")) rewards = response.json()["items"][0]["rewards"] assert [reward["name"] for reward in rewards] == [ @@ -404,7 +401,7 @@ class KickCampaignApiTest(TestCase): is_fully_imported=True, ) - response = self.client.get(reverse("kick:campaign_api_list")) + response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns")) item = next( item @@ -418,7 +415,7 @@ class KickCampaignApiTest(TestCase): def select_count() -> int: with CaptureQueriesContext(connection) as queries: - response = self.client.get(reverse("kick:campaign_api_list")) + response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns")) assert response.status_code == 200 return sum( query["sql"].lstrip().upper().startswith("SELECT") @@ -447,3 +444,367 @@ class KickCampaignApiTest(TestCase): scaled = select_count() assert scaled == baseline + + +class KickCampaignDetailApiTest(TestCase): + """Tests for the campaign detail endpoint.""" + + @classmethod + def setUpTestData(cls) -> None: + cls.organization = KickOrganization.objects.create( + kick_id="org-detail", + name="Detail Org", + ) + cls.category = KickCategory.objects.create( + kick_id=200, + name="Detail Game", + slug="detail-game", + ) + cls.campaign = KickDropCampaign.objects.create( + kick_id="campaign-detail", + name="Detail Campaign", + starts_at=datetime(2026, 7, 1, tzinfo=UTC), + ends_at=datetime(2026, 8, 1, tzinfo=UTC), + organization=cls.organization, + category=cls.category, + rule_id=1, + rule_name="Watch to earn", + is_fully_imported=True, + ) + cls.user = KickUser.objects.create( + kick_id=457, + username="detail-streamer", + profile_picture="https://example.com/detail.png", + ) + cls.channel = KickChannel.objects.create( + kick_id=790, + slug="detail-streamer", + user=cls.user, + ) + cls.campaign.channels.add(cls.channel) + cls.reward = KickReward.objects.create( + kick_id="reward-detail", + name="Detail Reward", + required_units=45, + campaign=cls.campaign, + category=cls.category, + organization=cls.organization, + ) + + def test_detail_returns_campaign(self) -> None: + """A fully imported campaign returns its full serialized payload.""" + with mock.patch( + "kick.api.timezone.now", + return_value=datetime(2026, 7, 15, tzinfo=UTC), + ): + response = self.client.get( + reverse( + "api-v1:kick-api-v1_get_campaign", + args=[self.campaign.kick_id], + ), + ) + + assert response.status_code == 200 + data = response.json() + assert data["kick_id"] == "campaign-detail" + assert data["status"] == "active" + assert data["name"] == "Detail Campaign" + assert len(data["channels"]) == 1 + assert len(data["rewards"]) == 1 + + def test_detail_not_found(self) -> None: + """A non-existent campaign ID returns 404.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_get_campaign", args=["nonexistent-id"]), + ) + assert response.status_code == 404 + + def test_detail_not_fully_imported(self) -> None: + """A campaign that is not fully imported returns 404.""" + KickDropCampaign.objects.create( + kick_id="not-imported-detail", + name="Not Imported", + organization=self.organization, + category=self.category, + starts_at=datetime(2026, 7, 1, tzinfo=UTC), + ends_at=datetime(2026, 8, 1, tzinfo=UTC), + is_fully_imported=False, + ) + response = self.client.get( + reverse("api-v1:kick-api-v1_get_campaign", args=["not-imported-detail"]), + ) + assert response.status_code == 404 + + +class KickCampaignSearchFilterTest(TestCase): + """Tests for the search and organization filter on the campaign list.""" + + @classmethod + def setUpTestData(cls) -> None: + cls.org_a = KickOrganization.objects.create( + kick_id="org-a", + name="Studio Alpha", + ) + cls.org_b = KickOrganization.objects.create(kick_id="org-b", name="Beta Corp") + cls.cat = KickCategory.objects.create(kick_id=300, name="Test Game") + + cls.camp_alpha = KickDropCampaign.objects.create( + kick_id="alpha-camp", + name="Alpha Drop Event", + starts_at=datetime(2026, 6, 1, tzinfo=UTC), + ends_at=datetime(2026, 7, 1, tzinfo=UTC), + organization=cls.org_a, + category=cls.cat, + is_fully_imported=True, + ) + cls.camp_beta = KickDropCampaign.objects.create( + kick_id="beta-camp", + name="Beta Giveaway", + starts_at=datetime(2026, 7, 1, tzinfo=UTC), + ends_at=datetime(2026, 8, 1, tzinfo=UTC), + organization=cls.org_b, + category=cls.cat, + is_fully_imported=True, + ) + cls.camp_alpha2 = KickDropCampaign.objects.create( + kick_id="alpha-camp-2", + name="Alpha Second Wave", + starts_at=datetime(2026, 8, 1, tzinfo=UTC), + ends_at=datetime(2026, 9, 1, tzinfo=UTC), + organization=cls.org_a, + category=cls.cat, + is_fully_imported=True, + ) + + def test_filters_by_organization(self) -> None: + """An organization kick_id returns only that org's campaigns.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_list_campaigns"), + {"organization": "org-a"}, + ) + assert response.status_code == 200 + ids = [item["kick_id"] for item in response.json()["items"]] + assert sorted(ids) == ["alpha-camp", "alpha-camp-2"] + + def test_organization_filter_with_no_matches(self) -> None: + """An org with no campaigns returns an empty list.""" + KickOrganization.objects.create(kick_id="org-empty", name="Empty Org") + response = self.client.get( + reverse("api-v1:kick-api-v1_list_campaigns"), + {"organization": "org-empty"}, + ) + assert response.status_code == 200 + assert response.json()["items"] == [] + + def test_searches_by_name(self) -> None: + """A search term filters campaigns by case-insensitive name match.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_list_campaigns"), + {"search": "alpha"}, + ) + assert response.status_code == 200 + ids = [item["kick_id"] for item in response.json()["items"]] + assert sorted(ids) == ["alpha-camp", "alpha-camp-2"] + + def test_search_with_no_matches(self) -> None: + """A search term with no matches returns an empty list.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_list_campaigns"), + {"search": "zzzzz"}, + ) + assert response.status_code == 200 + assert response.json()["items"] == [] + + def test_search_case_insensitive(self) -> None: + """Search is case-insensitive.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_list_campaigns"), + {"search": "ALPHA"}, + ) + assert response.status_code == 200 + ids = [item["kick_id"] for item in response.json()["items"]] + assert sorted(ids) == ["alpha-camp", "alpha-camp-2"] + + def test_organization_filter_with_search(self) -> None: + """Organization filter and search can be combined.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_list_campaigns"), + {"organization": "org-a", "search": "Second"}, + ) + assert response.status_code == 200 + ids = [item["kick_id"] for item in response.json()["items"]] + assert ids == ["alpha-camp-2"] + + +class KickOrganizationApiTest(TestCase): + """Tests for the organization API endpoints.""" + + @classmethod + def setUpTestData(cls) -> None: + cls.org_a = KickOrganization.objects.create( + kick_id="org-list-a", + name="Alpha Studios", + logo_url="https://example.com/alpha.png", + url="https://example.com/alpha", + ) + cls.org_b = KickOrganization.objects.create( + kick_id="org-list-b", + name="Beta Games", + logo_url="https://example.com/beta.png", + url="https://example.com/beta", + ) + cat = KickCategory.objects.create(kick_id=400, name="Game") + for org in (cls.org_a, cls.org_b): + KickDropCampaign.objects.create( + kick_id=f"camp-{org.kick_id}", + name=f"{org.name} Campaign", + starts_at=datetime(2026, 7, 1, tzinfo=UTC), + ends_at=datetime(2026, 8, 1, tzinfo=UTC), + organization=org, + category=cat, + is_fully_imported=True, + ) + + def test_list_returns_organizations(self) -> None: + """Organization list returns all orgs with campaign counts.""" + response = self.client.get(reverse("api-v1:kick-api-v1_list_organizations")) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + names = [item["name"] for item in data["items"]] + assert names == ["Alpha Studios", "Beta Games"] + assert all(item["campaign_count"] == 1 for item in data["items"]) + + def test_detail_returns_organization_with_campaigns(self) -> None: + """Organization detail returns org metadata and its campaigns.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_get_organization", args=["org-list-a"]), + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Alpha Studios" + assert data["campaign_count"] == 1 + assert len(data["campaigns"]) == 1 + assert data["campaigns"][0]["name"] == "Alpha Studios Campaign" + + def test_detail_not_found(self) -> None: + """A non-existent org returns 404.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_get_organization", args=["nonexistent"]), + ) + assert response.status_code == 404 + + def test_list_pagination(self) -> None: + """Organization list respects pagination.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_list_organizations"), + {"page": 1, "page_size": 1}, + ) + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["total"] == 2 + + def test_list_search(self) -> None: + """Organization list supports search by name.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_list_organizations"), + {"search": "alpha"}, + ) + assert response.status_code == 200 + names = [item["name"] for item in response.json()["items"]] + assert names == ["Alpha Studios"] + + +class KickCategoryApiTest(TestCase): + """Tests for the category (game) API endpoints.""" + + @classmethod + def setUpTestData(cls) -> None: + cls.cat_a = KickCategory.objects.create( + kick_id=500, + name="Action Game", + slug="action-game", + image_url="https://example.com/action.png", + ) + cls.cat_b = KickCategory.objects.create( + kick_id=501, + name="Racing Game", + slug="racing-game", + image_url="https://example.com/racing.png", + ) + org = KickOrganization.objects.create( + kick_id="cat-org", + name="Category Org", + ) + for cat in (cls.cat_a, cls.cat_b): + KickDropCampaign.objects.create( + kick_id=f"camp-{cat.kick_id}", + name=f"{cat.name} Campaign", + starts_at=datetime(2026, 7, 1, tzinfo=UTC), + ends_at=datetime(2026, 8, 1, tzinfo=UTC), + organization=org, + category=cat, + is_fully_imported=True, + ) + + def test_list_returns_categories(self) -> None: + """Category list returns all categories with campaign counts.""" + response = self.client.get(reverse("api-v1:kick-api-v1_list_games")) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + names = [item["name"] for item in data["items"]] + assert names == ["Action Game", "Racing Game"] + assert all(item["campaign_count"] == 1 for item in data["items"]) + + def test_detail_returns_category_with_campaigns(self) -> None: + """Category detail returns metadata and its campaigns.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_get_game", args=[500]), + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Action Game" + assert data["campaign_count"] == 1 + assert len(data["campaigns"]) == 1 + assert data["campaigns"][0]["name"] == "Action Game Campaign" + + def test_detail_not_found(self) -> None: + """A non-existent category returns 404.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_get_game", args=[99999]), + ) + assert response.status_code == 404 + + def test_list_search(self) -> None: + """Category list supports search by name.""" + response = self.client.get( + reverse("api-v1:kick-api-v1_list_games"), + {"search": "action"}, + ) + assert response.status_code == 200 + names = [item["name"] for item in response.json()["items"]] + assert names == ["Action Game"] + + +class KickStatsApiTest(TestCase): + """Tests for the stats endpoint.""" + + @classmethod + def setUpTestData(cls) -> None: + KickOrganization.objects.create(kick_id="stats-org", name="Stats Org") + KickCategory.objects.create(kick_id=600, name="Stats Game") + KickChannel.objects.create(kick_id=800, slug="stats-channel") + + def test_stats_returns_aggregate_counts(self) -> None: + """Stats endpoint returns counts for all entity types.""" + response = self.client.get(reverse("api-v1:kick-api-v1_stats")) + assert response.status_code == 200 + data = response.json() + assert data["total_campaigns"] == 0 + assert data["total_organizations"] == 1 + assert data["total_categories"] == 1 + assert data["total_channels"] == 1 + for key in ("active", "upcoming", "expired", "partial_dates"): + assert key in data diff --git a/kick/urls.py b/kick/urls.py index 8ec4e4f..6481ae8 100644 --- a/kick/urls.py +++ b/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/", diff --git a/pyproject.toml b/pyproject.toml index dcaf47e..edf95f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/templates/base.html b/templates/base.html index 9fe04e5..12e5b7d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -203,6 +203,7 @@ Dataset | Donate | GitHub | + API Docs |
Channels | Badges | Emotes | - API Docs | Inventory
Kick @@ -234,10 +234,7 @@ Campaigns
Other sites - Steam | YouTube | - TikTok | - Discord
{% if messages %} diff --git a/templates/core/dashboard.html b/templates/core/dashboard.html index c19674e..1c95756 100644 --- a/templates/core/dashboard.html +++ b/templates/core/dashboard.html @@ -47,7 +47,7 @@ title="Atom feed for Twitch campaigns">[atom] [discord] - [api] [explain] diff --git a/templates/core/dataset_backups.html b/templates/core/dataset_backups.html index 2e6835f..a8a1433 100644 --- a/templates/core/dataset_backups.html +++ b/templates/core/dataset_backups.html @@ -7,40 +7,46 @@

Dataset Backups

About this dataset

-

This site tracks and publishes open Twitch and Kick drop campaign data.

+

This site tracks and publishes open drop campaign data from Twitch and Kick.

Campaign metadata in the downloadable datasets and public JSON API is released under - CC0 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 + CC0 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.

-

Note that some drops has missing or incomplete data due to Twitch API limitations.

+

Note that some drops have missing or incomplete data due to Twitch API limitations.

Reward campaign data is sourced from SunkwiBOT/twitch-drops-api. - Raw JSON exports are available in the repository: + Raw JSON exports are available in the same repository as drops.json and rewards.json.

- Need a special format for your workflow or research pipeline? - Contact me via GitHub issues + Kick data is fetched directly from the public + Kick campaigns API + on a recurring schedule. +

+

+ Need a custom format for your workflow or research pipeline? + Open a GitHub issue and describe what you need.

Kick JSON API

- Fully imported Kick campaigns are also available through the public, paginated - Kick campaign JSON API. - Responses include campaign dates, game and organization metadata, rewards, and the complete + Fully imported Kick campaigns are also available through the public paginated + Kick campaign JSON API. + Responses include campaign dates, game and organization metadata, rewards, and the full channel list stored for each campaign.

Use page and page_size for pagination. Filter by Kick category ID with game, or by the computed campaign state with - status=active, status=upcoming, or status=expired. + status=active, status=upcoming, status=expired, + or status=unknown. Page numbers and page sizes have a minimum of 1, and the maximum page size is 500.

{
@@ -60,15 +66,60 @@
   }]
 }

- Timestamps use ISO 8601 in UTC and may be null when the source lacks a date. Campaigns with - incomplete dates have the computed status unknown and are not returned by a status filter. + Timestamps use ISO 8601 in UTC and may be null when the source does not provide a date. Campaigns with + incomplete dates receive the computed status unknown 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 items is empty or when page * page_size >= total.

- An empty channels 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 channels list means no channel restriction data is stored. It does not + by itself guarantee that a campaign is available on every channel. +

+
+
+

Twitch JSON API

+

+ Fully imported Twitch campaigns are available through the public paginated + Twitch campaign JSON API. + Responses include campaign dates, game and organization metadata, allowed channels, drops, + and rewards for each campaign. +

+

+ Use page and page_size for pagination. Filter by Twitch game ID with + game, or by the computed campaign state with + status=active, status=upcoming, status=expired, + or status=unknown. + Page numbers and page sizes have a minimum of 1, and the maximum page size is 500. +

+
{
+  "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": ["..."] }]
+  }]
+}
+

+ Timestamps use ISO 8601 in UTC and may be null when the source does not provide a date. Campaigns with + incomplete dates receive the computed status unknown and are excluded from status-based filtering. +

+

+ Additional endpoints include + games, + organizations, + channels, + reward campaigns, and + chat badges + all public and paginated. Each list endpoint has a corresponding detail view by ID.

{% if datasets %} @@ -97,7 +148,7 @@ {% endfor %} -

Found {{ dataset_count }} datasets.

+

{{ dataset_count }} dataset{% if dataset_count != 1 %}s{% endif %} available.

{% else %}

No dataset backups found.

{% endif %} diff --git a/templates/core/debug.html b/templates/core/debug.html index 32c41e3..c62e4b7 100644 --- a/templates/core/debug.html +++ b/templates/core/debug.html @@ -127,6 +127,38 @@

None ✅

{% endif %} +
+

Kick Campaigns With Unknown Status ({{ kick_unknown_campaigns|length }})

+ {% if kick_unknown_campaigns %} +
    + {% for c in kick_unknown_campaigns %} +
  • + {{ c.name }} + (Game: {{ c.category.name }}) + - Start: {{ c.starts_at|default:'(none)' }} / End: {{ c.ends_at|default:'(none)' }} +
  • + {% endfor %} +
+ {% else %} +

None ✅

+ {% endif %} +
+
+

Twitch Reward Campaigns With Unknown Status ({{ twitch_reward_unknown_campaigns|length }})

+ {% if twitch_reward_unknown_campaigns %} +
    + {% for c in twitch_reward_unknown_campaigns %} +
  • + {{ c.name }} + (Game: {{ c.game.display_name }}) + - Start: {{ c.starts_at|default:'(none)' }} / End: {{ c.ends_at|default:'(none)' }} +
  • + {% endfor %} +
+ {% else %} +

None ✅

+ {% endif %} +

Duplicate Campaign Names Per Game ({{ duplicate_name_campaigns|length }})

{% if duplicate_name_campaigns %} diff --git a/templates/core/docs_rss.html b/templates/core/docs_rss.html index 6d3d163..49e41e2 100644 --- a/templates/core/docs_rss.html +++ b/templates/core/docs_rss.html @@ -18,7 +18,7 @@

Twitch JSON API documentation is available at - /twitch/api/v1/docs. + /api/v1/docs/.

Twitch campaign feeds accept ?limit=50 to change item count and diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index 85ec252..94fa6eb 100644 --- a/templates/twitch/campaign_detail.html +++ b/templates/twitch/campaign_detail.html @@ -121,7 +121,7 @@ [discord] [explain] - [api] {% endif %} diff --git a/templates/twitch/campaign_list.html b/templates/twitch/campaign_list.html index 9fbe54d..b2ce75b 100644 --- a/templates/twitch/campaign_list.html +++ b/templates/twitch/campaign_list.html @@ -33,7 +33,7 @@ title="Atom feed for all campaigns">[atom] [discord] - [api] [explain] [atom] [discord] - [api] [explain] diff --git a/templates/twitch/game_detail.html b/templates/twitch/game_detail.html index fd52995..a5b7e56 100644 --- a/templates/twitch/game_detail.html +++ b/templates/twitch/game_detail.html @@ -91,7 +91,7 @@ title="Discord feed for {{ game.display_name }} rewards">[discord]

- [api] [explain]
diff --git a/templates/twitch/games_grid.html b/templates/twitch/games_grid.html index b85eb34..fd60bd8 100644 --- a/templates/twitch/games_grid.html +++ b/templates/twitch/games_grid.html @@ -32,7 +32,7 @@ title="Atom feed for all games">[atom] [discord] - [api] [explain] [atom] [discord] - [api] [explain] [atom] [discord] - [api] [explain] [atom] [discord] - [api] [explain] {% if reward_campaign.external_url %} diff --git a/templates/twitch/reward_campaign_list.html b/templates/twitch/reward_campaign_list.html index 24c1d11..b6e25bf 100644 --- a/templates/twitch/reward_campaign_list.html +++ b/templates/twitch/reward_campaign_list.html @@ -30,7 +30,7 @@
[explain] - [api]
diff --git a/twitch/api.py b/twitch/api.py index 96a409d..4e94366 100644 --- a/twitch/api.py +++ b/twitch/api.py @@ -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 diff --git a/twitch/models.py b/twitch/models.py index 6aeb0f1..934d169 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -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 diff --git a/twitch/tests/test_api.py b/twitch/tests/test_api.py index a753a34..b6b847b 100644 --- a/twitch/tests/test_api.py +++ b/twitch/tests/test_api.py @@ -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 diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 7e15e41..2034481 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -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 diff --git a/twitch/urls.py b/twitch/urls.py index b114c9e..854ac66 100644 --- a/twitch/urls.py +++ b/twitch/urls.py @@ -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/", + 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//