Make GamesGridView faster
This commit is contained in:
parent
1d524a2ca9
commit
4714894247
3 changed files with 168 additions and 50 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue