diff --git a/twitch/models.py b/twitch/models.py index e26c419..11d2ef8 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -9,9 +9,11 @@ import auto_prefetch from django.conf import settings from django.contrib.postgres.indexes import GinIndex from django.db import models +from django.db.models import Exists from django.db.models import F from django.db.models import Prefetch from django.db.models import Q +from django.db.models.functions import Coalesce from django.urls import reverse from django.utils import timezone from django.utils.html import format_html @@ -242,6 +244,102 @@ class Game(auto_prefetch.Model): """Return dashboard-safe box art URL without touching deferred image fields.""" return normalize_twitch_box_art_url(self.box_art or "") + @classmethod + def with_campaign_counts( + cls, + now: datetime.datetime, + *, + with_campaigns_only: bool = False, + ) -> models.QuerySet[Game]: + """Return games annotated with total/active campaign counts. + + Args: + now: Current timestamp used to evaluate active campaigns. + with_campaigns_only: If True, include only games with at least one campaign. + + Returns: + QuerySet optimized for games list/grid rendering. + """ + campaigns_for_game = DropCampaign.objects.filter( + game_id=models.OuterRef("pk"), + ) + campaign_count_subquery = ( + campaigns_for_game + .order_by() + .values("game_id") + .annotate(total=models.Count("id")) + .values("total")[:1] + ) + active_count_subquery = ( + campaigns_for_game + .filter(start_at__lte=now, end_at__gte=now) + .order_by() + .values("game_id") + .annotate(total=models.Count("id")) + .values("total")[:1] + ) + + queryset: models.QuerySet[Game] = ( + cls.objects + .only( + "twitch_id", + "display_name", + "name", + "slug", + "box_art", + "box_art_file", + "box_art_width", + "box_art_height", + ) + .prefetch_related( + Prefetch( + "owners", + queryset=Organization.objects.only("twitch_id", "name").order_by( + "name", + ), + ), + ) + .annotate( + campaign_count=Coalesce( + models.Subquery( + campaign_count_subquery, + output_field=models.IntegerField(), + ), + models.Value(0), + ), + active_count=Coalesce( + models.Subquery( + active_count_subquery, + output_field=models.IntegerField(), + ), + models.Value(0), + ), + ) + .order_by("display_name") + ) + if with_campaigns_only: + queryset = queryset.filter(Exists(campaigns_for_game)) + return queryset + + @staticmethod + def grouped_by_owner_for_grid( + games: models.QuerySet[Game], + ) -> OrderedDict[Organization, list[dict[str, Game]]]: + """Group games by owner organization for games grid/list pages. + + Args: + games: QuerySet of games with prefetched owners. + + Returns: + Ordered mapping of organizations to game dictionaries. + """ + grouped: OrderedDict[Organization, list[dict[str, Game]]] = OrderedDict() + for game in games: + for owner in game.owners.all(): + grouped.setdefault(owner, []).append({"game": game}) + + return OrderedDict(sorted(grouped.items(), key=lambda item: item[0].name)) + # MARK: TwitchGame class TwitchGameData(auto_prefetch.Model): diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 8efb123..df9f76d 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -2151,6 +2151,70 @@ class TestChannelListView: assert response.status_code == 200 assert "games" in response.context + @pytest.mark.django_db + def test_games_grid_view_groups_only_games_with_campaigns( + self, + client: Client, + ) -> None: + """Games grid should group only games that actually have campaigns.""" + now: datetime.datetime = timezone.now() + + org_with_campaign: Organization = Organization.objects.create( + twitch_id="org-games-grid-with-campaign", + name="Org Games Grid With Campaign", + ) + org_without_campaign: Organization = Organization.objects.create( + twitch_id="org-games-grid-without-campaign", + name="Org Games Grid Without Campaign", + ) + + game_with_campaign: Game = Game.objects.create( + twitch_id="game-games-grid-with-campaign", + name="Game Games Grid With Campaign", + display_name="Game Games Grid With Campaign", + ) + game_with_campaign.owners.add(org_with_campaign) + + game_without_campaign: Game = Game.objects.create( + twitch_id="game-games-grid-without-campaign", + name="Game Games Grid Without Campaign", + display_name="Game Games Grid Without Campaign", + ) + game_without_campaign.owners.add(org_without_campaign) + + DropCampaign.objects.create( + twitch_id="campaign-games-grid-with-campaign", + name="Campaign Games Grid With Campaign", + game=game_with_campaign, + operation_names=["DropCampaignDetails"], + start_at=now - timedelta(hours=1), + end_at=now + timedelta(hours=1), + ) + + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:games_grid")) + assert response.status_code == 200 + + context: ContextList | dict[str, Any] = response.context # type: ignore[assignment] + if isinstance(context, list): + context = context[-1] + + games: list[Game] = list(context["games"]) + games_by_org: OrderedDict[Organization, list[dict[str, Game]]] = context[ + "games_by_org" + ] + + game_ids: set[str] = {game.twitch_id for game in games} + assert game_with_campaign.twitch_id in game_ids + assert game_without_campaign.twitch_id not in game_ids + + grouped_ids: set[str] = { + item["game"].twitch_id + for grouped_games in games_by_org.values() + for item in grouped_games + } + assert game_with_campaign.twitch_id in grouped_ids + assert game_without_campaign.twitch_id not in grouped_ids + @pytest.mark.django_db def test_games_list_view(self, client: Client) -> None: """Test games list view returns 200 and has games in context.""" diff --git a/twitch/views.py b/twitch/views.py index b90a3b1..0ce640b 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -4,8 +4,6 @@ import csv import datetime import json import logging -from collections import OrderedDict -from collections import defaultdict from typing import TYPE_CHECKING from typing import Any from typing import Literal @@ -15,9 +13,7 @@ from django.core.paginator import EmptyPage from django.core.paginator import Page from django.core.paginator import PageNotAnInteger from django.core.paginator import Paginator -from django.db.models import Count from django.db.models import Prefetch -from django.db.models import Q from django.db.models.query import QuerySet from django.http import Http404 from django.http import HttpResponse @@ -654,23 +650,9 @@ class GamesGridView(ListView): Returns: QuerySet: Annotated games queryset. """ - now: datetime.datetime = timezone.now() - return ( - super() - .get_queryset() - .prefetch_related("owners") - .annotate( - campaign_count=Count("drop_campaigns", distinct=True), - active_count=Count( - "drop_campaigns", - filter=Q( - drop_campaigns__start_at__lte=now, - drop_campaigns__end_at__gte=now, - ), - distinct=True, - ), - ) - .order_by("display_name") + return Game.with_campaign_counts( + timezone.now(), + with_campaigns_only=True, ) def get_context_data(self, **kwargs) -> dict[str, Any]: @@ -685,35 +667,9 @@ class GamesGridView(ListView): dict: Context data with games grouped by organization. """ context: dict[str, Any] = super().get_context_data(**kwargs) - now: datetime.datetime = timezone.now() - - games_with_campaigns: QuerySet[Game] = ( - Game.objects - .filter(drop_campaigns__isnull=False) - .prefetch_related("owners") - .annotate( - campaign_count=Count("drop_campaigns", distinct=True), - active_count=Count( - "drop_campaigns", - filter=Q( - drop_campaigns__start_at__lte=now, - drop_campaigns__end_at__gte=now, - ), - distinct=True, - ), - ) - .order_by("display_name") - ) - - games_by_org: defaultdict[Organization, list[dict[str, Game]]] = defaultdict( - list, - ) - for game in games_with_campaigns: - for org in game.owners.all(): - games_by_org[org].append({"game": game}) - - context["games_by_org"] = OrderedDict( - sorted(games_by_org.items(), key=lambda item: item[0].name), + games: QuerySet[Game] = context["games"] + context["games_by_org"] = Game.grouped_by_owner_for_grid( + games, ) # CollectionPage schema for games list