diff --git a/twitch/migrations/0019_dropcampaign_campaign_list_indexes.py b/twitch/migrations/0019_dropcampaign_campaign_list_indexes.py new file mode 100644 index 0000000..3c9e52a --- /dev/null +++ b/twitch/migrations/0019_dropcampaign_campaign_list_indexes.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.4 on 2026-04-10 23:25 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + """Add indexes to optimize queries for the campaign list view.""" + + dependencies = [ + ( + "twitch", + "0018_rename_twitch_drop_start_a_929f09_idx_tw_drop_start_desc_idx_and_more", + ), + ] + + operations = [ + migrations.AddIndex( + model_name="dropcampaign", + index=models.Index( + fields=["is_fully_imported", "-start_at"], + name="tw_drop_imported_start_idx", + ), + ), + migrations.AddIndex( + model_name="dropcampaign", + index=models.Index( + fields=["is_fully_imported", "start_at", "end_at"], + name="tw_drop_imported_start_end_idx", + ), + ), + ] diff --git a/twitch/models.py b/twitch/models.py index ad3232b..3a6e466 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -510,11 +510,62 @@ class DropCampaign(auto_prefetch.Model): name="tw_drop_start_end_game_idx", ), models.Index(fields=["end_at", "-start_at"]), + # For campaign list view: is_fully_imported filter + ordering + models.Index( + fields=["is_fully_imported", "-start_at"], + name="tw_drop_imported_start_idx", + ), + # For campaign list view: is_fully_imported + active-window filter + models.Index( + fields=["is_fully_imported", "start_at", "end_at"], + name="tw_drop_imported_start_end_idx", + ), ] def __str__(self) -> str: return self.name + @classmethod + def for_campaign_list( + cls, + now: datetime.datetime, + *, + game_twitch_id: str | None = None, + status: str | None = None, + ) -> models.QuerySet[DropCampaign]: + """Return fully-imported campaigns with relations needed by the campaign list view. + + Args: + now: Current timestamp used to evaluate status filters. + game_twitch_id: Optional Twitch game ID to filter campaigns by. + status: Optional status filter; one of "active", "upcoming", or "expired". + + Returns: + QuerySet of campaigns ordered by newest start date. + """ + queryset = ( + cls.objects + .filter(is_fully_imported=True) + .select_related("game") + .prefetch_related( + "game__owners", + models.Prefetch( + "time_based_drops", + queryset=TimeBasedDrop.objects.prefetch_related("benefits"), + ), + ) + .order_by("-start_at") + ) + if game_twitch_id: + queryset = queryset.filter(game__twitch_id=game_twitch_id) + if status == "active": + queryset = queryset.filter(start_at__lte=now, end_at__gte=now) + elif status == "upcoming": + queryset = queryset.filter(start_at__gt=now) + elif status == "expired": + queryset = queryset.filter(end_at__lt=now) + return queryset + @classmethod def active_for_dashboard( cls, diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index d4106b5..b3d543e 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -3247,3 +3247,228 @@ class TestBadgeSetDetailView: assert uses_index, ( f"DropBenefit query on (distribution_type, name) did not use an index.\n{plan}" ) + + +@pytest.mark.django_db +class TestDropCampaignListView: + """Tests for drop_campaign_list_view index usage and fat-model delegation.""" + + @pytest.fixture + def game_with_campaigns(self) -> dict[str, Any]: + """Create a game with a mix of imported/not-imported campaigns. + + Returns: + Dict with 'org' and 'game' keys for the created Organization and Game. + """ + org: Organization = Organization.objects.create( + twitch_id="org_list_test", + name="List Test Org", + ) + game: Game = Game.objects.create( + twitch_id="game_list_test", + name="game_list_test", + display_name="List Test Game", + ) + game.owners.add(org) + return {"org": org, "game": game} + + def test_campaign_list_returns_200(self, client: Client) -> None: + """Campaign list view loads successfully.""" + response: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:campaign_list"), + ) + assert response.status_code == 200 + + def test_only_fully_imported_campaigns_shown( + self, + client: Client, + game_with_campaigns: dict[str, Any], + ) -> None: + """Only campaigns with is_fully_imported=True appear in the list.""" + game: Game = game_with_campaigns["game"] + imported: DropCampaign = DropCampaign.objects.create( + twitch_id="cl_imported", + name="Imported Campaign", + game=game, + operation_names=["DropCampaignDetails"], + is_fully_imported=True, + ) + DropCampaign.objects.create( + twitch_id="cl_not_imported", + name="Not Imported Campaign", + game=game, + operation_names=["DropCampaignDetails"], + is_fully_imported=False, + ) + + response: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:campaign_list"), + ) + context: ContextList | dict[str, Any] = response.context # type: ignore[assignment] + if isinstance(context, list): + context = context[-1] + + campaign_ids = {c.twitch_id for c in context["campaigns"].object_list} + assert imported.twitch_id in campaign_ids + assert "cl_not_imported" not in campaign_ids + + def test_status_filter_active( + self, + client: Client, + game_with_campaigns: dict[str, Any], + ) -> None: + """Status=active returns only currently-running campaigns.""" + game: Game = game_with_campaigns["game"] + now = timezone.now() + active: DropCampaign = DropCampaign.objects.create( + twitch_id="cl_active", + name="Active", + game=game, + operation_names=[], + is_fully_imported=True, + start_at=now - timedelta(hours=1), + end_at=now + timedelta(hours=1), + ) + DropCampaign.objects.create( + twitch_id="cl_expired", + name="Expired", + game=game, + operation_names=[], + is_fully_imported=True, + start_at=now - timedelta(days=10), + end_at=now - timedelta(days=1), + ) + + response: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:campaign_list") + "?status=active", + ) + context: ContextList | dict[str, Any] = response.context # type: ignore[assignment] + if isinstance(context, list): + context = context[-1] + + campaign_ids = {c.twitch_id for c in context["campaigns"].object_list} + assert active.twitch_id in campaign_ids + assert "cl_expired" not in campaign_ids + + def test_campaign_list_indexes_exist(self) -> None: + """Required composite indexes for the campaign list query must exist on DropCampaign.""" + expected: set[str] = { + "tw_drop_imported_start_idx", + "tw_drop_imported_start_end_idx", + } + with connection.cursor() as cursor: + constraints = connection.introspection.get_constraints( + cursor, + DropCampaign._meta.db_table, + ) + actual: set[str] = { + name for name, meta in constraints.items() if meta.get("index") + } + missing = expected - actual + assert not missing, ( + f"Missing expected DropCampaign campaign-list indexes: {sorted(missing)}" + ) + + @pytest.mark.django_db + def test_campaign_list_query_uses_index(self) -> None: + """for_campaign_list() should use an index when filtering is_fully_imported.""" + now: datetime.datetime = timezone.now() + game: Game = Game.objects.create( + twitch_id="game_cl_idx", + name="game_cl_idx", + display_name="CL Idx Game", + ) + # Bulk-create enough rows to give the query planner a reason to use indexes. + rows: list[DropCampaign] = [ + DropCampaign( + twitch_id=f"cl_idx_not_imported_{i}", + name=f"Not imported {i}", + game=game, + operation_names=[], + is_fully_imported=False, + start_at=now - timedelta(days=i + 1), + end_at=now + timedelta(days=1), + ) + for i in range(300) + ] + rows.append( + DropCampaign( + twitch_id="cl_idx_imported", + name="Imported", + game=game, + operation_names=[], + is_fully_imported=True, + start_at=now - timedelta(hours=1), + end_at=now + timedelta(hours=1), + ), + ) + DropCampaign.objects.bulk_create(rows) + + plan: str = DropCampaign.for_campaign_list(now).explain() + + if connection.vendor == "sqlite": + uses_index: bool = "USING INDEX" in plan.upper() + elif connection.vendor == "postgresql": + uses_index = ( + "INDEX SCAN" in plan.upper() + or "BITMAP INDEX SCAN" in plan.upper() + or "INDEX ONLY SCAN" in plan.upper() + ) + else: + pytest.skip( + f"Unsupported DB vendor for index assertion: {connection.vendor}", + ) + + assert uses_index, f"for_campaign_list() did not use an index.\n{plan}" + + def test_campaign_list_query_count_stays_flat(self, client: Client) -> None: + """Campaign list should not issue N+1 queries as campaign volume grows.""" + game: Game = Game.objects.create( + twitch_id="game_cl_flat", + name="game_cl_flat", + display_name="CL Flat Game", + ) + now = timezone.now() + + def _select_count() -> int: + with CaptureQueriesContext(connection) as ctx: + resp: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:campaign_list"), + ) + assert resp.status_code == 200 + return sum( + 1 + for q in ctx.captured_queries + if q["sql"].lstrip().upper().startswith("SELECT") + ) + + DropCampaign.objects.create( + twitch_id="cl_flat_base", + name="Base campaign", + game=game, + operation_names=[], + is_fully_imported=True, + start_at=now - timedelta(hours=1), + end_at=now + timedelta(hours=1), + ) + baseline: int = _select_count() + + extra = [ + DropCampaign( + twitch_id=f"cl_flat_extra_{i}", + name=f"Extra {i}", + game=game, + operation_names=[], + is_fully_imported=True, + start_at=now - timedelta(hours=2), + end_at=now + timedelta(hours=2), + ) + for i in range(15) + ] + DropCampaign.objects.bulk_create(extra) + scaled: int = _select_count() + + assert scaled <= baseline + 2, ( + f"Campaign list SELECT count grew; possible N+1. " + f"baseline={baseline}, scaled={scaled}" + ) diff --git a/twitch/views.py b/twitch/views.py index 2e9be5b..d03ed62 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -417,7 +417,7 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon # MARK: /campaigns/ -def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914, PLR0915 +def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914 """Function-based view for drop campaigns list. Args: @@ -429,29 +429,13 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0 game_filter: str | None = request.GET.get("game") status_filter: str | None = request.GET.get("status") per_page: int = 100 - queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter( - is_fully_imported=True, - ) - - if game_filter: - queryset = queryset.filter(game__twitch_id=game_filter) - - queryset = queryset.prefetch_related( - "game__owners", - Prefetch( - "time_based_drops", - queryset=TimeBasedDrop.objects.prefetch_related("benefits"), - ), - ).order_by("-start_at") - - # Optionally filter by status (active, upcoming, expired) now: datetime.datetime = timezone.now() - if status_filter == "active": - queryset = queryset.filter(start_at__lte=now, end_at__gte=now) - elif status_filter == "upcoming": - queryset = queryset.filter(start_at__gt=now) - elif status_filter == "expired": - queryset = queryset.filter(end_at__lt=now) + + queryset: QuerySet[DropCampaign] = DropCampaign.for_campaign_list( + now, + game_twitch_id=game_filter, + status=status_filter, + ) paginator: Paginator[DropCampaign] = Paginator(queryset, per_page) page: str | Literal[1] = request.GET.get("page") or 1 @@ -462,30 +446,34 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0 except EmptyPage: campaigns = paginator.page(paginator.num_pages) + status_descriptions: dict[str, str] = { + "active": "Browse active Twitch drops.", + "upcoming": "View upcoming Twitch drops starting soon.", + "expired": "Browse expired Twitch drops.", + } title = "Twitch Drops" + description = "Browse Twitch drops" if status_filter: title += f" ({status_filter.capitalize()})" + description = status_descriptions.get(status_filter, description) if game_filter: try: - game: Game = Game.objects.get(twitch_id=game_filter) - title += f" - {game.display_name}" + game_name: str = ( + Game.objects + .only("display_name") + .values_list("display_name", flat=True) + .get(twitch_id=game_filter) + ) + title += f" - {game_name}" except Game.DoesNotExist: pass - description = "Browse Twitch drops" - if status_filter == "active": - description = "Browse active Twitch drops." - elif status_filter == "upcoming": - description = "View upcoming Twitch drops starting soon." - elif status_filter == "expired": - description = "Browse expired Twitch drops." - # Build base URL for pagination base_url = "/campaigns/" - if status_filter: + if status_filter and game_filter: + base_url += f"?status={status_filter}&game={game_filter}" + elif status_filter: base_url += f"?status={status_filter}" - if game_filter: - base_url += f"&game={game_filter}" elif game_filter: base_url += f"?game={game_filter}" @@ -495,7 +483,6 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0 base_url, ) - # CollectionPage schema for campaign list collection_schema: dict[str, str] = { "@context": "https://schema.org", "@type": "CollectionPage",