From e960b090849dc19d884f491963fb7ce199cec7d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Tue, 5 May 2026 05:01:48 +0200 Subject: [PATCH] Add API for Twitch data --- pyproject.toml | 3 +- templates/base.html | 1 + templates/core/dashboard.html | 2 + templates/core/docs_rss.html | 4 + 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 | 822 +++++++++++++++++++ twitch/models.py | 15 + twitch/tests/test_api.py | 438 ++++++++++ twitch/tests/test_views.py | 221 +++++ twitch/urls.py | 3 + 18 files changed, 1526 insertions(+), 1 deletion(-) create mode 100644 twitch/api.py create mode 100644 twitch/tests/test_api.py diff --git a/pyproject.toml b/pyproject.toml index 74d15fa..ea57c1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,9 @@ dependencies = [ "django-celery-beat", "django-celery-results", "django-debug-toolbar", + "django-ninja", "django-silk", + "django-zeal", "django", "flower", "gunicorn", @@ -31,7 +33,6 @@ dependencies = [ "setproctitle", "sitemap-parser", "tqdm", - "django-zeal>=2.1.0", ] diff --git a/templates/base.html b/templates/base.html index be2daed..d558273 100644 --- a/templates/base.html +++ b/templates/base.html @@ -220,6 +220,7 @@ Channels | Badges | Emotes | + API Docs | Inventory
Kick diff --git a/templates/core/dashboard.html b/templates/core/dashboard.html index c563b8c..317eaf7 100644 --- a/templates/core/dashboard.html +++ b/templates/core/dashboard.html @@ -48,6 +48,8 @@ title="Atom feed for Twitch campaigns">[atom] [discord] + [api] [explain] diff --git a/templates/core/docs_rss.html b/templates/core/docs_rss.html index 13de544..0e6c3e4 100644 --- a/templates/core/docs_rss.html +++ b/templates/core/docs_rss.html @@ -16,6 +16,10 @@ that include Discord relative timestamps (e.g., <t:1773450272:R>) for dates, making them ideal for Discord bots and integrations. Future enhancements may include Discord-specific formatting or content.

+

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

Global RSS Feeds

diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index 6251304..08dd892 100644 --- a/templates/twitch/campaign_detail.html +++ b/templates/twitch/campaign_detail.html @@ -103,6 +103,8 @@ title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom][discord] + [api][explain] {% endif %} diff --git a/templates/twitch/campaign_list.html b/templates/twitch/campaign_list.html index 219217a..9fbe54d 100644 --- a/templates/twitch/campaign_list.html +++ b/templates/twitch/campaign_list.html @@ -33,6 +33,8 @@ title="Atom feed for all campaigns">[atom][discord] + [api][explain][csv] diff --git a/templates/twitch/dashboard.html b/templates/twitch/dashboard.html index 5e6f7cd..7051e2d 100644 --- a/templates/twitch/dashboard.html +++ b/templates/twitch/dashboard.html @@ -36,6 +36,8 @@ title="Atom feed for campaigns">[atom][discord] + [api][explain]
diff --git a/templates/twitch/game_detail.html b/templates/twitch/game_detail.html index ed24fbb..f48e13f 100644 --- a/templates/twitch/game_detail.html +++ b/templates/twitch/game_detail.html @@ -65,6 +65,8 @@ title="Atom feed for {{ game.display_name }} campaigns">[atom][discord] + [api][explain] diff --git a/templates/twitch/games_grid.html b/templates/twitch/games_grid.html index 7facdde..b85eb34 100644 --- a/templates/twitch/games_grid.html +++ b/templates/twitch/games_grid.html @@ -32,6 +32,8 @@ title="Atom feed for all games">[atom][discord] + [api][explain][csv] diff --git a/templates/twitch/games_list.html b/templates/twitch/games_list.html index 3497856..130a4ef 100644 --- a/templates/twitch/games_list.html +++ b/templates/twitch/games_list.html @@ -30,6 +30,8 @@ title="Atom feed for all games">[atom][discord] + [api][explain][csv] diff --git a/templates/twitch/org_list.html b/templates/twitch/org_list.html index 911e08a..43e1fde 100644 --- a/templates/twitch/org_list.html +++ b/templates/twitch/org_list.html @@ -15,6 +15,8 @@ title="Atom feed for all organizations">[atom][discord] + [api][explain][csv] diff --git a/templates/twitch/reward_campaign_detail.html b/templates/twitch/reward_campaign_detail.html index 40cb357..bdca15a 100644 --- a/templates/twitch/reward_campaign_detail.html +++ b/templates/twitch/reward_campaign_detail.html @@ -110,6 +110,8 @@ title="Atom feed for all reward campaigns">[atom][discord] + [api][explain] {% if reward_campaign.external_url %} [atom][discord] + [api][explain] {% if reward_campaigns %} diff --git a/twitch/api.py b/twitch/api.py new file mode 100644 index 0000000..a657e31 --- /dev/null +++ b/twitch/api.py @@ -0,0 +1,822 @@ +from __future__ import annotations + +import datetime # noqa: TC003 +from dataclasses import dataclass +from typing import TYPE_CHECKING +from typing import Literal + +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 Schema + +from twitch.models import Channel +from twitch.models import ChatBadgeSet +from twitch.models import DropCampaign +from twitch.models import Game +from twitch.models import Organization +from twitch.models import RewardCampaign +from twitch.utils import normalize_twitch_box_art_url + +if TYPE_CHECKING: + from django.db.models import QuerySet + from django.http import HttpRequest + + from twitch.models import ChatBadge + from twitch.models import DropBenefit + from twitch.models import TimeBasedDrop + +V1StatusFilter = Literal["active", "upcoming", "expired"] + +DEFAULT_PAGE_SIZE = 100 +MAX_PAGE_SIZE = 500 + + +@dataclass(frozen=True) +class V1Page[ModelT: models.Model]: + """Typed slice metadata for v1 list responses.""" + + items: list[ModelT] + total: int + page: int + page_size: int + + +class V1PaginationSchema(Schema): + """Common pagination fields for v1 list responses.""" + + total: int + page: int + page_size: int + + +class V1OrganizationSummarySchema(Schema): + """Compact Twitch organization representation.""" + + twitch_id: str + name: str + + +class V1GameSummarySchema(Schema): + """Compact Twitch game representation.""" + + twitch_id: str + slug: str + name: str + display_name: str + box_art_url: str + organizations: list[V1OrganizationSummarySchema] + campaign_count: int + active_campaign_count: int + + +class V1OrganizationSchema(V1OrganizationSummarySchema): + """Twitch organization response.""" + + added_at: datetime.datetime + updated_at: datetime.datetime + + +class V1GameSchema(V1GameSummarySchema): + """Twitch game response.""" + + added_at: datetime.datetime + updated_at: datetime.datetime + + +class V1ChannelSummarySchema(Schema): + """Compact Twitch channel representation.""" + + twitch_id: str + name: str + display_name: str + + +class V1ChannelSchema(V1ChannelSummarySchema): + """Twitch channel response.""" + + allowed_campaign_count: int + added_at: datetime.datetime + updated_at: datetime.datetime + + +class V1DropBenefitSchema(Schema): + """Twitch drop benefit response.""" + + twitch_id: str + name: str + image_url: str + distribution_type: str + created_at: datetime.datetime | None + entitlement_limit: int + is_ios_available: bool + + +class V1TimeBasedDropSchema(Schema): + """Twitch time-based drop response.""" + + twitch_id: str + name: str + required_minutes_watched: int | None + required_subs: int + start_at: datetime.datetime | None + end_at: datetime.datetime | None + benefits: list[V1DropBenefitSchema] + + +class V1DropCampaignSummarySchema(Schema): + """Compact Twitch drop campaign representation.""" + + twitch_id: str + name: str + description: str + status: str + image_url: str + details_url: str + account_link_url: str + start_at: datetime.datetime | None + end_at: datetime.datetime | None + game: V1GameSummarySchema + allow_is_enabled: bool + is_fully_imported: bool + added_at: datetime.datetime + updated_at: datetime.datetime + + +class V1DropCampaignDetailSchema(V1DropCampaignSummarySchema): + """Twitch drop campaign detail response.""" + + operation_names: list[str] + allowed_channels: list[V1ChannelSummarySchema] + drops: list[V1TimeBasedDropSchema] + + +class V1OrganizationDetailSchema(V1OrganizationSchema): + """Twitch organization detail response.""" + + games: list[V1GameSummarySchema] + campaigns: list[V1DropCampaignDetailSchema] + + +class V1GameDetailSchema(V1GameSchema): + """Twitch game detail response.""" + + campaigns: list[V1DropCampaignSummarySchema] + + +class V1ChannelDetailSchema(V1ChannelSchema): + """Twitch channel detail response.""" + + campaigns: list[V1DropCampaignSummarySchema] + + +class V1RewardCampaignSchema(Schema): + """Twitch reward campaign response.""" + + twitch_id: str + name: str + brand: str + status: str + computed_status: str + summary: str + instructions: str + external_url: str + reward_value_url_param: str + about_url: str + image_url: str + is_sitewide: bool + starts_at: datetime.datetime | None + ends_at: datetime.datetime | None + game: V1GameSummarySchema | None + added_at: datetime.datetime + updated_at: datetime.datetime + + +class V1ChatBadgeSchema(Schema): + """Twitch chat badge response.""" + + badge_id: str + image_url_1x: str + image_url_2x: str + image_url_4x: str + title: str + description: str + click_action: str | None + click_url: str | None + + +class V1ChatBadgeSetSchema(Schema): + """Twitch chat badge set response.""" + + set_id: str + badges: list[V1ChatBadgeSchema] + added_at: datetime.datetime + updated_at: datetime.datetime + + +class V1DropCampaignListSchema(V1PaginationSchema): + """Paginated Twitch drop campaign list.""" + + items: list[V1DropCampaignSummarySchema] + + +class V1GameListSchema(V1PaginationSchema): + """Paginated Twitch game list.""" + + items: list[V1GameSchema] + + +class V1OrganizationListSchema(V1PaginationSchema): + """Paginated Twitch organization list.""" + + items: list[V1OrganizationSchema] + + +class V1ChannelListSchema(V1PaginationSchema): + """Paginated Twitch channel list.""" + + items: list[V1ChannelSchema] + + +class V1RewardCampaignListSchema(V1PaginationSchema): + """Paginated Twitch reward campaign list.""" + + items: list[V1RewardCampaignSchema] + + +class V1ChatBadgeSetListSchema(V1PaginationSchema): + """Paginated Twitch chat badge set list.""" + + items: list[V1ChatBadgeSetSchema] + + +api = NinjaAPI( + title="TTVDrops Twitch API", + version="1.0.0", + urls_namespace="twitch:twitch-api-v1", +) + + +def _paginate[ModelT: models.Model]( + queryset: QuerySet[ModelT, ModelT], + *, + page: int, + page_size: int, +) -> V1Page[ModelT]: + page = max(page, 1) + page_size = min(max(page_size, 1), MAX_PAGE_SIZE) + offset = (page - 1) * page_size + return V1Page( + items=list(queryset[offset : offset + page_size]), + page=page, + page_size=page_size, + total=queryset.count(), + ) + + +def _campaign_status( + start_at: datetime.datetime | None, + end_at: datetime.datetime | None, + now: datetime.datetime, +) -> str: + if start_at and end_at: + if start_at <= now <= end_at: + return "active" + if start_at > now: + return "upcoming" + return "expired" + return "unknown" + + +def _apply_status_filter[ModelT: models.Model]( + queryset: QuerySet[ModelT, ModelT], + status: V1StatusFilter | None, + now: datetime.datetime, + *, + start_field: str, + end_field: str, +) -> QuerySet[ModelT, ModelT]: + if status == "active": + return queryset.filter(**{f"{start_field}__lte": now, f"{end_field}__gte": now}) + if status == "upcoming": + return queryset.filter(**{f"{start_field}__gt": now}) + if status == "expired": + return queryset.filter(**{f"{end_field}__lt": now}) + return queryset + + +def _serialize_organization_summary(org: Organization) -> V1OrganizationSummarySchema: + return V1OrganizationSummarySchema(twitch_id=org.twitch_id, name=org.name) + + +def _serialize_organization(org: Organization) -> V1OrganizationSchema: + return V1OrganizationSchema( + twitch_id=org.twitch_id, + name=org.name, + added_at=org.added_at, + updated_at=org.updated_at, + ) + + +def _game_campaign_count(game: Game) -> int: + return DropCampaign.objects.filter(game=game).count() + + +def _game_active_campaign_count(game: Game, now: datetime.datetime) -> int: + return DropCampaign.objects.filter( + game=game, + start_at__lte=now, + end_at__gte=now, + ).count() + + +def _game_box_art_url(game: Game) -> str: + deferred_fields: set[str] = game.get_deferred_fields() + local_image_fields = {"box_art_file", "box_art_width", "box_art_height"} + if deferred_fields.isdisjoint(local_image_fields): + return game.box_art_best_url + if "box_art" not in deferred_fields: + return normalize_twitch_box_art_url(game.box_art or "") + return "" + + +def _campaign_summary_queryset( + queryset: QuerySet[DropCampaign, DropCampaign], +) -> QuerySet[DropCampaign, DropCampaign]: + return ( + queryset + .select_related("game") + .only( + "twitch_id", + "name", + "description", + "details_url", + "account_link_url", + "image_url", + "image_file", + "image_width", + "image_height", + "start_at", + "end_at", + "allow_is_enabled", + "is_fully_imported", + "added_at", + "updated_at", + "game", + "game__twitch_id", + "game__name", + "game__display_name", + "game__slug", + "game__box_art", + "game__box_art_file", + "game__box_art_width", + "game__box_art_height", + ) + .prefetch_related("game__owners") + ) + + +def _channel_list_queryset(search_query: str | None) -> QuerySet[Channel, Channel]: + queryset = Channel.objects.only( + "twitch_id", + "name", + "display_name", + "allowed_campaign_count", + "added_at", + "updated_at", + ) + normalized_query = (search_query or "").strip() + if normalized_query: + queryset = queryset.filter( + models.Q(name__icontains=normalized_query) + | models.Q(display_name__icontains=normalized_query), + ) + return queryset.annotate( + campaign_count=models.F("allowed_campaign_count"), + ).order_by("-campaign_count", "name") + + +def _serialize_game_summary( + game: Game, + now: datetime.datetime, +) -> V1GameSummarySchema: + return V1GameSummarySchema( + twitch_id=game.twitch_id, + slug=game.slug, + name=game.name, + display_name=game.display_name, + box_art_url=_game_box_art_url(game), + organizations=[ + _serialize_organization_summary(org) for org in game.owners.all() + ], + campaign_count=_game_campaign_count(game), + active_campaign_count=_game_active_campaign_count(game, now), + ) + + +def _serialize_game(game: Game, now: datetime.datetime) -> V1GameSchema: + return V1GameSchema( + twitch_id=game.twitch_id, + slug=game.slug, + name=game.name, + display_name=game.display_name, + box_art_url=_game_box_art_url(game), + organizations=[ + _serialize_organization_summary(org) for org in game.owners.all() + ], + campaign_count=_game_campaign_count(game), + active_campaign_count=_game_active_campaign_count(game, now), + added_at=game.added_at, + updated_at=game.updated_at, + ) + + +def _serialize_channel_summary(channel: Channel) -> V1ChannelSummarySchema: + return V1ChannelSummarySchema( + twitch_id=channel.twitch_id, + name=channel.name, + display_name=channel.display_name, + ) + + +def _serialize_channel(channel: Channel) -> V1ChannelSchema: + return V1ChannelSchema( + twitch_id=channel.twitch_id, + name=channel.name, + display_name=channel.display_name, + allowed_campaign_count=channel.allowed_campaign_count, + added_at=channel.added_at, + updated_at=channel.updated_at, + ) + + +def _serialize_benefit(benefit: DropBenefit) -> V1DropBenefitSchema: + return V1DropBenefitSchema( + twitch_id=benefit.twitch_id, + name=benefit.name, + image_url=benefit.image_best_url, + distribution_type=benefit.distribution_type, + created_at=benefit.created_at, + entitlement_limit=benefit.entitlement_limit, + is_ios_available=benefit.is_ios_available, + ) + + +def _serialize_drop(drop: TimeBasedDrop) -> V1TimeBasedDropSchema: + return V1TimeBasedDropSchema( + twitch_id=drop.twitch_id, + name=drop.name, + required_minutes_watched=drop.required_minutes_watched, + required_subs=drop.required_subs, + start_at=drop.start_at, + end_at=drop.end_at, + benefits=[_serialize_benefit(benefit) for benefit in drop.benefits.all()], + ) + + +def _serialize_campaign_summary( + campaign: DropCampaign, + now: datetime.datetime, +) -> V1DropCampaignSummarySchema: + return V1DropCampaignSummarySchema( + twitch_id=campaign.twitch_id, + name=campaign.name, + description=campaign.description, + status=_campaign_status(campaign.start_at, campaign.end_at, now), + image_url=campaign.listing_image_url, + details_url=campaign.details_url, + account_link_url=campaign.account_link_url, + start_at=campaign.start_at, + end_at=campaign.end_at, + game=_serialize_game_summary(campaign.game, now), + allow_is_enabled=campaign.allow_is_enabled, + is_fully_imported=campaign.is_fully_imported, + added_at=campaign.added_at, + updated_at=campaign.updated_at, + ) + + +def _serialize_campaign_detail( + campaign: DropCampaign, + now: datetime.datetime, +) -> V1DropCampaignDetailSchema: + return V1DropCampaignDetailSchema( + twitch_id=campaign.twitch_id, + name=campaign.name, + description=campaign.description, + status=_campaign_status(campaign.start_at, campaign.end_at, now), + image_url=campaign.listing_image_url, + details_url=campaign.details_url, + account_link_url=campaign.account_link_url, + start_at=campaign.start_at, + end_at=campaign.end_at, + game=_serialize_game_summary(campaign.game, now), + allow_is_enabled=campaign.allow_is_enabled, + is_fully_imported=campaign.is_fully_imported, + added_at=campaign.added_at, + updated_at=campaign.updated_at, + operation_names=campaign.operation_names, + allowed_channels=[ + _serialize_channel_summary(channel) + for channel in campaign.allow_channels.all() + ], + drops=[_serialize_drop(drop) for drop in campaign.time_based_drops.all()], # pyright: ignore[reportAttributeAccessIssue] + ) + + +def _serialize_reward_campaign( + campaign: RewardCampaign, + now: datetime.datetime, +) -> V1RewardCampaignSchema: + return V1RewardCampaignSchema( + twitch_id=campaign.twitch_id, + name=campaign.name, + brand=campaign.brand, + status=campaign.status, + computed_status=_campaign_status(campaign.starts_at, campaign.ends_at, now), + summary=campaign.summary, + instructions=campaign.instructions, + external_url=campaign.external_url, + reward_value_url_param=campaign.reward_value_url_param, + about_url=campaign.about_url, + image_url=campaign.image_best_url, + is_sitewide=campaign.is_sitewide, + starts_at=campaign.starts_at, + ends_at=campaign.ends_at, + game=_serialize_game_summary(campaign.game, now) if campaign.game else None, + added_at=campaign.added_at, + updated_at=campaign.updated_at, + ) + + +def _serialize_badge(badge: ChatBadge) -> V1ChatBadgeSchema: + return V1ChatBadgeSchema( + badge_id=badge.badge_id, + image_url_1x=badge.image_url_1x, + image_url_2x=badge.image_url_2x, + image_url_4x=badge.image_url_4x, + title=badge.title, + description=badge.description, + click_action=badge.click_action, + click_url=badge.click_url, + ) + + +def _serialize_badge_set(badge_set: ChatBadgeSet) -> V1ChatBadgeSetSchema: + return V1ChatBadgeSetSchema( + set_id=badge_set.set_id, + badges=[_serialize_badge(badge) for badge in badge_set.badges.all()], # pyright: ignore[reportAttributeAccessIssue] + added_at=badge_set.added_at, + updated_at=badge_set.updated_at, + ) + + +@api.get("/campaigns/", response=V1DropCampaignListSchema) +def list_campaigns( + request: HttpRequest, + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, + game: str | None = None, + status: V1StatusFilter | None = None, +) -> V1DropCampaignListSchema: + """Return paginated Twitch drop campaigns.""" + now = timezone.now() + queryset = DropCampaign.for_campaign_list(now, game_twitch_id=game, status=status) + page_data = _paginate(queryset, page=page, page_size=page_size) + return V1DropCampaignListSchema( + total=page_data.total, + page=page_data.page, + page_size=page_data.page_size, + items=[ + _serialize_campaign_summary(campaign, now) for campaign in page_data.items + ], + ) + + +@api.get("/campaigns/{twitch_id}/", response=V1DropCampaignDetailSchema) +def get_campaign(request: HttpRequest, twitch_id: str) -> V1DropCampaignDetailSchema: + """Return one Twitch drop campaign. + + Raises: + Http404: if not found + """ + try: + campaign = DropCampaign.for_detail_view(twitch_id) + except DropCampaign.DoesNotExist as exc: + msg = "Campaign not found" + raise Http404(msg) from exc + return _serialize_campaign_detail(campaign, timezone.now()) + + +@api.get("/games/", response=V1GameListSchema) +def list_games( + request: HttpRequest, + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, +) -> V1GameListSchema: + """Return paginated Twitch games.""" + now = timezone.now() + queryset = Game.with_campaign_counts(now).order_by("display_name") + page_data = _paginate(queryset, page=page, page_size=page_size) + return V1GameListSchema( + total=page_data.total, + page=page_data.page, + page_size=page_data.page_size, + items=[_serialize_game(game, now) for game in page_data.items], + ) + + +@api.get("/games/{twitch_id}/", response=V1GameDetailSchema) +def get_game(request: HttpRequest, twitch_id: str) -> V1GameDetailSchema: + """Return one Twitch game.""" + game = get_object_or_404(Game.for_detail_view(), twitch_id=twitch_id) + campaigns = _campaign_summary_queryset( + DropCampaign.objects.filter(game=game), + ).order_by("-end_at") + now = timezone.now() + return V1GameDetailSchema( + twitch_id=game.twitch_id, + slug=game.slug, + name=game.name, + display_name=game.display_name, + box_art_url=_game_box_art_url(game), + organizations=[ + _serialize_organization_summary(org) for org in game.owners.all() + ], + campaign_count=_game_campaign_count(game), + active_campaign_count=_game_active_campaign_count(game, now), + added_at=game.added_at, + updated_at=game.updated_at, + campaigns=[ + _serialize_campaign_summary(campaign, now) for campaign in campaigns + ], + ) + + +@api.get("/organizations/", response=V1OrganizationListSchema) +def list_organizations( + request: HttpRequest, + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, +) -> V1OrganizationListSchema: + """Return paginated Twitch organizations.""" + page_data = _paginate( + Organization.objects.all().order_by("name"), + page=page, + page_size=page_size, + ) + return V1OrganizationListSchema( + total=page_data.total, + page=page_data.page, + page_size=page_data.page_size, + items=[_serialize_organization(org) for org in page_data.items], + ) + + +@api.get("/organizations/{twitch_id}/", response=V1OrganizationDetailSchema) +def get_organization( + request: HttpRequest, + twitch_id: str, +) -> V1OrganizationDetailSchema: + """Return one Twitch organization.""" + org = get_object_or_404(Organization.for_detail_view(), twitch_id=twitch_id) + now = timezone.now() + campaigns = ( + DropCampaign.objects + .filter(game__owners=org) + .select_related("game") + .prefetch_related( + "game__owners", + "allow_channels", + "time_based_drops__benefits", + ) + .order_by("-start_at") + .distinct() + ) + return V1OrganizationDetailSchema( + twitch_id=org.twitch_id, + name=org.name, + added_at=org.added_at, + updated_at=org.updated_at, + games=[_serialize_game_summary(game, now) for game in org.games.all()], # pyright: ignore[reportAttributeAccessIssue] + campaigns=[_serialize_campaign_detail(campaign, now) for campaign in campaigns], + ) + + +@api.get("/channels/", response=V1ChannelListSchema) +def list_channels( + request: HttpRequest, + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, + search: str | None = None, +) -> V1ChannelListSchema: + """Return paginated Twitch channels.""" + page_data = _paginate( + _channel_list_queryset(search), + page=page, + page_size=page_size, + ) + return V1ChannelListSchema( + total=page_data.total, + page=page_data.page, + page_size=page_data.page_size, + items=[_serialize_channel(channel) for channel in page_data.items], + ) + + +@api.get("/channels/{twitch_id}/", response=V1ChannelDetailSchema) +def get_channel(request: HttpRequest, twitch_id: str) -> V1ChannelDetailSchema: + """Return one Twitch channel.""" + channel = get_object_or_404(Channel.for_detail_view(), twitch_id=twitch_id) + campaigns = _campaign_summary_queryset( + DropCampaign.objects.filter(allow_channels=channel), + ).order_by("-start_at") + now = timezone.now() + return V1ChannelDetailSchema( + twitch_id=channel.twitch_id, + name=channel.name, + display_name=channel.display_name, + allowed_campaign_count=channel.allowed_campaign_count, + added_at=channel.added_at, + updated_at=channel.updated_at, + campaigns=[ + _serialize_campaign_summary(campaign, now) for campaign in campaigns + ], + ) + + +@api.get("/reward-campaigns/", response=V1RewardCampaignListSchema) +def list_reward_campaigns( + request: HttpRequest, + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, + game: str | None = None, + status: V1StatusFilter | None = None, +) -> V1RewardCampaignListSchema: + """Return paginated Twitch reward campaigns.""" + now = timezone.now() + queryset = RewardCampaign.objects.select_related("game").prefetch_related( + "game__owners", + ) + if game: + queryset = queryset.filter(game__twitch_id=game) + queryset = _apply_status_filter( + queryset.order_by("-starts_at"), + status, + now, + start_field="starts_at", + end_field="ends_at", + ) + page_data = _paginate(queryset, page=page, page_size=page_size) + return V1RewardCampaignListSchema( + total=page_data.total, + page=page_data.page, + page_size=page_data.page_size, + items=[ + _serialize_reward_campaign(campaign, now) for campaign in page_data.items + ], + ) + + +@api.get("/reward-campaigns/{twitch_id}/", response=V1RewardCampaignSchema) +def get_reward_campaign( + request: HttpRequest, + twitch_id: str, +) -> V1RewardCampaignSchema: + """Return one Twitch reward campaign.""" + campaign = get_object_or_404( + RewardCampaign.objects.select_related("game").prefetch_related( + "game__owners", + ), + twitch_id=twitch_id, + ) + return _serialize_reward_campaign(campaign, timezone.now()) + + +@api.get("/badges/", response=V1ChatBadgeSetListSchema) +def list_badges( + request: HttpRequest, + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, +) -> V1ChatBadgeSetListSchema: + """Return paginated Twitch chat badge sets.""" + page_data = _paginate( + ChatBadgeSet.for_list_view(), + page=page, + page_size=page_size, + ) + return V1ChatBadgeSetListSchema( + total=page_data.total, + page=page_data.page, + page_size=page_data.page_size, + items=[_serialize_badge_set(badge_set) for badge_set in page_data.items], + ) + + +@api.get("/badges/{set_id}/", response=V1ChatBadgeSetSchema) +def get_badge_set(request: HttpRequest, set_id: str) -> V1ChatBadgeSetSchema: + """Return one Twitch chat badge set.""" + badge_set = get_object_or_404(ChatBadgeSet.for_list_view(), set_id=set_id) + return _serialize_badge_set(badge_set) diff --git a/twitch/models.py b/twitch/models.py index 4b93c99..943e520 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -91,6 +91,10 @@ class Organization(auto_prefetch.Model): "name", "display_name", "slug", + "box_art", + "box_art_file", + "box_art_width", + "box_art_height", ).order_by("display_name"), to_attr="games_for_detail", ), @@ -340,6 +344,8 @@ class Game(auto_prefetch.Model): "box_art_file", "box_art_width", "box_art_height", + "added_at", + "updated_at", ) .prefetch_related( Prefetch( @@ -803,6 +809,8 @@ class DropCampaign(auto_prefetch.Model): "start_at", "end_at", "game", + "game__twitch_id", + "game__name", "game__display_name", ) .prefetch_related( @@ -854,12 +862,19 @@ class DropCampaign(auto_prefetch.Model): "image_height", "start_at", "end_at", + "allow_is_enabled", + "operation_names", "added_at", "updated_at", + "is_fully_imported", "game__twitch_id", "game__name", "game__display_name", "game__slug", + "game__box_art", + "game__box_art_file", + "game__box_art_width", + "game__box_art_height", ) .prefetch_related( Prefetch( diff --git a/twitch/tests/test_api.py b/twitch/tests/test_api.py new file mode 100644 index 0000000..40edecc --- /dev/null +++ b/twitch/tests/test_api.py @@ -0,0 +1,438 @@ +from datetime import timedelta + +from django.db import connection +from django.test import Client +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from django.utils import timezone + +from twitch import api as twitch_api +from twitch.models import Channel +from twitch.models import ChatBadge +from twitch.models import ChatBadgeSet +from twitch.models import DropBenefit +from twitch.models import DropCampaign +from twitch.models import Game +from twitch.models import Organization +from twitch.models import RewardCampaign +from twitch.models import TimeBasedDrop + + +class TwitchApiV1TestCase(TestCase): + """Tests for the versioned Twitch API.""" + + def setUp(self) -> None: + """Create representative Twitch API fixture data.""" + self.client = Client() + now = timezone.now() + + self.org = Organization.objects.create( + twitch_id="org123", + name="Test Organization", + ) + self.game = Game.objects.create( + twitch_id="game123", + slug="test-game", + name="Test Game", + display_name="Test Game", + box_art="https://example.com/game.png", + ) + self.game.owners.add(self.org) + + self.channel = Channel.objects.create( + twitch_id="channel123", + name="testchannel", + display_name="TestChannel", + allowed_campaign_count=1, + ) + + self.campaign = DropCampaign.objects.create( + twitch_id="campaign123", + name="Test Campaign", + description="A test campaign", + details_url="https://example.com/details", + account_link_url="https://example.com/link", + image_url="https://example.com/campaign.png", + game=self.game, + start_at=now - timedelta(days=1), + end_at=now + timedelta(days=1), + operation_names=["DropCampaignDetails"], + is_fully_imported=True, + ) + self.campaign.allow_channels.add(self.channel) + + self.drop = TimeBasedDrop.objects.create( + twitch_id="drop123", + name="Test Drop", + campaign=self.campaign, + required_minutes_watched=30, + start_at=now - timedelta(days=1), + end_at=now + timedelta(days=1), + ) + self.benefit = DropBenefit.objects.create( + twitch_id="benefit123", + name="Test Benefit", + image_asset_url="https://example.com/benefit.png", + distribution_type="ITEM", + ) + self.drop.benefits.add(self.benefit) + + self.reward_campaign = RewardCampaign.objects.create( + twitch_id="reward123", + name="Test Reward", + brand="Test Brand", + starts_at=now - timedelta(days=1), + ends_at=now + timedelta(days=1), + status="ACTIVE", + summary="Reward summary", + external_url="https://example.com/reward", + game=self.game, + ) + + self.badge_set = ChatBadgeSet.objects.create(set_id="test-badge-set") + ChatBadge.objects.create( + badge_set=self.badge_set, + badge_id="1", + image_url_1x="https://example.com/badge-1x.png", + image_url_2x="https://example.com/badge-2x.png", + image_url_4x="https://example.com/badge-4x.png", + title="Test Badge", + description="Test badge description", + ) + + def _create_secondary_api_fixture(self) -> None: + now = timezone.now() + org = Organization.objects.create( + twitch_id="org456", + name="Second Organization", + ) + game = Game.objects.create( + twitch_id="game456", + slug="second-game", + name="Second Game", + display_name="Second Game", + box_art="https://example.com/second-game.png", + ) + game.owners.add(org) + + channel = Channel.objects.create( + twitch_id="channel456", + name="secondchannel", + display_name="SecondChannel", + allowed_campaign_count=1, + ) + + campaign = DropCampaign.objects.create( + twitch_id="campaign456", + name="Second Campaign", + description="Another test campaign", + details_url="https://example.com/second-details", + account_link_url="https://example.com/second-link", + image_url="https://example.com/second-campaign.png", + game=game, + start_at=now - timedelta(days=2), + end_at=now + timedelta(days=2), + operation_names=["DropCampaignDetails"], + is_fully_imported=True, + ) + campaign.allow_channels.add(channel) + + drop = TimeBasedDrop.objects.create( + twitch_id="drop456", + name="Second Drop", + campaign=campaign, + required_minutes_watched=60, + start_at=now - timedelta(days=2), + end_at=now + timedelta(days=2), + ) + benefit = DropBenefit.objects.create( + twitch_id="benefit456", + name="Second Benefit", + image_asset_url="https://example.com/second-benefit.png", + distribution_type="ITEM", + ) + drop.benefits.add(benefit) + + RewardCampaign.objects.create( + twitch_id="reward456", + name="Second Reward", + brand="Second Brand", + starts_at=now - timedelta(days=2), + ends_at=now + timedelta(days=2), + status="ACTIVE", + summary="Second reward summary", + external_url="https://example.com/second-reward", + game=game, + ) + + badge_set = ChatBadgeSet.objects.create(set_id="second-badge-set") + ChatBadge.objects.create( + badge_set=badge_set, + badge_id="1", + image_url_1x="https://example.com/second-badge-1x.png", + image_url_2x="https://example.com/second-badge-2x.png", + image_url_4x="https://example.com/second-badge-4x.png", + title="Second Badge", + description="Second badge description", + ) + + 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") + + assert response.status_code == 200 + assert "Content-Disposition" not in response + data = response.json() + assert data["total"] == 1 + assert data["page"] == 1 + assert data["items"][0]["twitch_id"] == "campaign123" + assert data["items"][0]["status"] == "active" + assert data["items"][0]["game"]["twitch_id"] == "game123" + + 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/") + + assert response.status_code == 200 + data = response.json() + assert data["operation_names"] == ["DropCampaignDetails"] + assert data["game"]["box_art_url"] == "https://example.com/game.png" + assert data["allowed_channels"][0]["twitch_id"] == "channel123" + assert data["drops"][0]["benefits"][0]["twitch_id"] == "benefit123" + + def test_v1_campaign_detail_game_box_art_does_not_load_deferred_file(self) -> None: + """Serialize campaign game box art without lazy-loading ImageField data.""" + campaign = DropCampaign.for_detail_view("campaign123") + + image_fields = {"box_art_file", "box_art_width", "box_art_height"} + assert campaign.game.get_deferred_fields().isdisjoint(image_fields) + with CaptureQueriesContext(connection) as capture: + box_art_url = twitch_api._game_box_art_url(campaign.game) + + assert box_art_url == "https://example.com/game.png" + assert len(capture) == 0 + + def test_v1_campaign_detail_uses_local_game_box_art(self) -> None: + """Return locally cached game box art from campaign detail responses.""" + self.game.box_art_file = "games/box_art/local.png" + self.game.box_art_width = 285 + self.game.box_art_height = 380 + self.game.save( + update_fields=["box_art_file", "box_art_width", "box_art_height"], + ) + + response = self.client.get("/twitch/api/v1/campaigns/campaign123/") + + assert response.status_code == 200 + data = response.json() + assert data["game"]["box_art_url"] == self.game.box_art_file.url + + def test_v1_all_endpoints_handle_multiple_rows(self) -> None: + """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), + ] + 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/", + ] + + for url, expected_total in list_urls: + response = self.client.get(url) + assert response.status_code == 200, url + data = response.json() + assert data["total"] == expected_total + assert len(data["items"]) == expected_total + + for url in detail_urls: + response = self.client.get(url) + assert response.status_code == 200, url + assert response.json() + + schema_response = self.client.get(reverse("twitch:twitch-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")) + 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"), + ] + + for url, expected_id in checks: + response = self.client.get(url) + assert response.status_code == 200 + data = response.json() + actual_id = data["items"][0].get( + "twitch_id", + data["items"][0].get("set_id"), + ) + assert actual_id == expected_id + + games_response = self.client.get("/twitch/api/v1/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/") + + assert response.status_code == 200 + data = response.json() + assert data["games"][0]["twitch_id"] == "game123" + assert data["games"][0]["campaign_count"] == 1 + assert data["games"][0]["active_campaign_count"] == 1 + assert data["campaigns"][0]["twitch_id"] == "campaign123" + assert data["campaigns"][0]["operation_names"] == ["DropCampaignDetails"] + assert data["campaigns"][0]["allowed_channels"][0]["twitch_id"] == "channel123" + assert data["campaigns"][0]["drops"][0]["twitch_id"] == "drop123" + assert ( + data["campaigns"][0]["drops"][0]["benefits"][0]["twitch_id"] == "benefit123" + ) + + 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/", + ] + + for url in checks: + response = self.client.get(url) + assert response.status_code == 200 + data = response.json() + campaign = data["campaigns"][0] + assert campaign["description"] == "A test campaign" + assert campaign["details_url"] == "https://example.com/details" + assert campaign["account_link_url"] == "https://example.com/link" + assert campaign["image_url"] == "https://example.com/campaign.png" + + 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/") + + 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") + + assert response.status_code == 200 + assert reverse("twitch:twitch-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.""" + checks = [ + ( + reverse("core:docs_rss"), + "API Docs", + "/twitch/api/v1/docs", + reverse("twitch:twitch-api-v1:openapi-view"), + ), + ( + reverse("twitch:dashboard"), + "API Docs", + "[api]", + reverse("twitch:twitch-api-v1:list_campaigns"), + ), + ( + reverse("twitch:campaign_list"), + "API Docs", + "[api]", + reverse("twitch:twitch-api-v1:list_campaigns"), + ), + ( + reverse("twitch:campaign_detail", args=[self.campaign.twitch_id]), + "API Docs", + "[api]", + reverse( + "twitch:twitch-api-v1:get_campaign", + args=[self.campaign.twitch_id], + ), + ), + ( + 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("twitch:games_grid"), + "API Docs", + "[api]", + reverse("twitch:twitch-api-v1:list_games"), + ), + ( + reverse("twitch:org_list"), + "API Docs", + "[api]", + reverse("twitch:twitch-api-v1:list_organizations"), + ), + ( + reverse("twitch:reward_campaign_list"), + "API Docs", + "[api]", + reverse("twitch:twitch-api-v1:list_reward_campaigns"), + ), + ( + reverse( + "twitch:reward_campaign_detail", + args=[self.reward_campaign.twitch_id], + ), + "API Docs", + "[api]", + reverse( + "twitch:twitch-api-v1:get_reward_campaign", + args=[self.reward_campaign.twitch_id], + ), + ), + ] + + for url, nav_text, feed_text, api_href in checks: + 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 api_href in content + assert nav_text in content + assert feed_text in content + + def test_campaign_detail_api_link_targets_campaign_endpoint(self) -> None: + """Link campaign detail [api] directly to that campaign JSON endpoint.""" + response = self.client.get( + reverse("twitch:campaign_detail", args=[self.campaign.twitch_id]), + ) + + assert response.status_code == 200 + content = response.content.decode() + campaign_api_url = reverse( + "twitch:twitch-api-v1:get_campaign", + args=[self.campaign.twitch_id], + ) + assert f'href="{campaign_api_url}"' in content + assert 'title="Twitch campaign API">[api]' in content diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 62cf1d8..4beb39d 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -2504,6 +2504,227 @@ class TestChannelListView: assert "g-rss" in response.content.decode("utf-8") +@pytest.mark.django_db +class TestRewardCampaignViews: + """Tests for Twitch reward campaign list and detail views.""" + + def _create_game(self, twitch_id: str, display_name: str) -> Game: + game: Game = Game.objects.create( + twitch_id=twitch_id, + slug=twitch_id, + name=display_name, + display_name=display_name, + box_art=f"https://example.com/{twitch_id}.png", + ) + org: Organization = Organization.objects.create( + twitch_id=f"{twitch_id}-org", + name=f"{display_name} Org", + ) + game.owners.add(org) + return game + + def _create_reward_campaign( # noqa: PLR0913 + self, + twitch_id: str, + *, + brand: str, + name: str, + game: Game | None, + starts_delta: timedelta, + ends_delta: timedelta, + ) -> RewardCampaign: + now: datetime.datetime = timezone.now() + return RewardCampaign.objects.create( + twitch_id=twitch_id, + brand=brand, + name=name, + summary=f"{name} summary", + instructions=f"{name} instructions", + external_url=f"https://example.com/{twitch_id}/external", + about_url=f"https://example.com/{twitch_id}/about", + image_url=f"https://example.com/{twitch_id}.png", + starts_at=now + starts_delta, + ends_at=now + ends_delta, + status="ACTIVE", + is_sitewide=game is None, + game=game, + ) + + def test_reward_campaign_list_renders_expired_campaigns( + self, + client: Client, + ) -> None: + """Render expired reward campaigns with feed and API links.""" + game: Game = self._create_game("reward-list-game", "Reward List Game") + expired = self._create_reward_campaign( + "reward-list-expired", + brand="Expired Brand", + name="Expired Reward", + game=game, + starts_delta=-timedelta(days=4), + ends_delta=-timedelta(days=1), + ) + self._create_reward_campaign( + "reward-list-active", + brand="Active Brand", + name="Active Reward", + game=game, + starts_delta=-timedelta(days=1), + ends_delta=timedelta(days=1), + ) + + response: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:reward_campaign_list"), + ) + + assert response.status_code == 200 + content: str = response.content.decode() + assert "Expired Brand: Expired Reward" in content + assert ( + reverse("twitch:reward_campaign_detail", args=[expired.twitch_id]) + in content + ) + 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 response.context["reward_campaigns"].paginator.count == 2 + + def test_reward_campaign_list_filters_status_and_game( + self, + client: Client, + ) -> None: + """Filter reward campaign context by status and game.""" + selected_game: Game = self._create_game("reward-filter-game", "Reward Filter") + other_game: Game = self._create_game("reward-filter-other", "Reward Other") + active = self._create_reward_campaign( + "reward-filter-active", + brand="Filter Brand", + name="Active Filter Reward", + game=selected_game, + starts_delta=-timedelta(days=1), + ends_delta=timedelta(days=1), + ) + self._create_reward_campaign( + "reward-filter-other-active", + brand="Other Brand", + name="Other Active Reward", + game=other_game, + starts_delta=-timedelta(days=1), + ends_delta=timedelta(days=1), + ) + self._create_reward_campaign( + "reward-filter-expired", + brand="Expired Brand", + name="Expired Filter Reward", + game=selected_game, + starts_delta=-timedelta(days=4), + ends_delta=-timedelta(days=1), + ) + + response: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:reward_campaign_list") + + f"?status=active&game={selected_game.twitch_id}", + ) + + assert response.status_code == 200 + campaigns = list(response.context["reward_campaigns"]) + assert campaigns == [active] + assert response.context["selected_status"] == "active" + assert response.context["selected_game"] == selected_game.twitch_id + + def test_reward_campaign_detail_renders_campaign_data( + self, + client: Client, + ) -> None: + """Render reward campaign detail fields and resource links.""" + game: Game = self._create_game("reward-detail-game", "Reward Detail Game") + reward = self._create_reward_campaign( + "reward-detail", + brand="Detail Brand", + name="Detail Reward", + game=game, + starts_delta=-timedelta(days=1), + ends_delta=timedelta(days=1), + ) + + response: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:reward_campaign_detail", args=[reward.twitch_id]), + ) + + assert response.status_code == 200 + content: str = response.content.decode() + assert "Detail Brand: Detail Reward" in content + assert "Detail Reward summary" in content + assert "Detail Reward instructions" in content + assert reverse("twitch:game_detail", args=[game.twitch_id]) in content + assert ( + reverse( + "twitch:twitch-api-v1:get_reward_campaign", + args=[reward.twitch_id], + ) + in content + ) + assert reward.external_url in content + assert reward.about_url in content + assert response.context["is_active"] is True + + def test_reward_campaign_detail_404_for_missing_campaign( + self, + client: Client, + ) -> None: + """Return 404 for missing reward campaign detail pages.""" + response: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:reward_campaign_detail", args=["missing-reward"]), + ) + + assert response.status_code == 404 + + def test_reward_campaign_list_query_count_stays_flat( + self, + client: Client, + ) -> None: + """Reward campaign list should not issue N+1 queries as rows grow.""" + game: Game = self._create_game("reward-flat-game", "Reward Flat Game") + + def _select_count() -> int: + with CaptureQueriesContext(connection) as ctx: + response: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:reward_campaign_list"), + ) + assert response.status_code == 200 + return sum( + 1 + for query in ctx.captured_queries + if query["sql"].lstrip().upper().startswith("SELECT") + ) + + self._create_reward_campaign( + "reward-flat-base", + brand="Flat Brand", + name="Flat Base Reward", + game=game, + starts_delta=-timedelta(days=4), + ends_delta=-timedelta(days=1), + ) + baseline: int = _select_count() + + for index in range(10): + self._create_reward_campaign( + f"reward-flat-extra-{index}", + brand="Flat Brand", + name=f"Flat Extra Reward {index}", + game=game, + starts_delta=-timedelta(days=4), + ends_delta=-timedelta(days=1), + ) + + scaled: int = _select_count() + assert scaled <= baseline + 2, ( + "Reward campaign list SELECT count grew; possible N+1. " + f"baseline={baseline}, scaled={scaled}" + ) + + @pytest.mark.django_db class TestSEOHelperFunctions: """Tests for SEO helper functions.""" diff --git a/twitch/urls.py b/twitch/urls.py index 0ca95d1..8acb346 100644 --- a/twitch/urls.py +++ b/twitch/urls.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from django.urls import path from twitch import views +from twitch.api import api as twitch_api_v1 if TYPE_CHECKING: from django.urls.resolvers import URLPattern @@ -12,6 +13,8 @@ app_name = "twitch" urlpatterns: list[URLPattern | URLResolver] = [ + # /twitch/api/v1/ + path("api/v1/", twitch_api_v1.urls), # /twitch/ path("", views.dashboard, name="dashboard"), # /twitch/badges/