From fb087a01c0599a870d0e27d75462222470b29404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Fri, 10 Apr 2026 22:32:38 +0200 Subject: [PATCH] Enhance performance by prefetching more --- chzzk/tests/test_views.py | 71 +++++++++++++++++++++++++++++++++++++++ chzzk/views.py | 1 + config/settings.py | 4 +++ kick/models.py | 17 +++++++--- pyproject.toml | 1 + twitch/views.py | 22 +++++++----- 6 files changed, 104 insertions(+), 12 deletions(-) diff --git a/chzzk/tests/test_views.py b/chzzk/tests/test_views.py index 38e84be..1af3cdd 100644 --- a/chzzk/tests/test_views.py +++ b/chzzk/tests/test_views.py @@ -1,7 +1,9 @@ from datetime import timedelta from typing import TYPE_CHECKING +from django.db import connection from django.test import TestCase +from django.test.utils import CaptureQueriesContext from django.urls import reverse from django.utils import timezone @@ -16,6 +18,75 @@ if TYPE_CHECKING: class ChzzkDashboardViewTests(TestCase): """Test cases for the dashboard view of the chzzk app.""" + def test_dashboard_view_no_n_plus_one_on_rewards(self) -> None: + """Test that the dashboard view does not trigger an N+1 query for rewards.""" + now = timezone.now() + base_kwargs = { + "category_type": "game", + "category_id": "1", + "category_value": "TestGame", + "service_id": "chzzk", + "state": "ACTIVE", + "start_date": now - timedelta(days=1), + "end_date": now + timedelta(days=1), + "has_ios_based_reward": False, + "drops_campaign_not_started": False, + "source_api": "unit-test", + } + reward_kwargs = { + "reward_type": "ITEM", + "campaign_reward_type": "Standard", + "condition_type": "watch", + "ios_based_reward": False, + "code_remaining_count": 100, + } + + campaign1 = ChzzkCampaign.objects.create( + campaign_no=9001, + title="C1", + **base_kwargs, + ) + campaign1.rewards.create( # pyright: ignore[reportAttributeAccessIssue] + reward_no=901, + title="R1", + condition_for_minutes=10, + **reward_kwargs, + ) # pyright: ignore[reportAttributeAccessIssue] + campaign2 = ChzzkCampaign.objects.create( + campaign_no=9002, + title="C2", + **base_kwargs, + ) + campaign2.rewards.create( # pyright: ignore[reportAttributeAccessIssue] + reward_no=902, + title="R2", + condition_for_minutes=20, + **reward_kwargs, + ) # pyright: ignore[reportAttributeAccessIssue] + + with CaptureQueriesContext(connection) as one_campaign_ctx: + self.client.get(reverse("chzzk:dashboard")) + query_count_two = len(one_campaign_ctx) + + campaign3 = ChzzkCampaign.objects.create( + campaign_no=9003, + title="C3", + **base_kwargs, + ) + campaign3.rewards.create( # pyright: ignore[reportAttributeAccessIssue] + reward_no=903, + title="R3", + condition_for_minutes=30, + **reward_kwargs, + ) # pyright: ignore[reportAttributeAccessIssue] + + with CaptureQueriesContext(connection) as three_campaign_ctx: + self.client.get(reverse("chzzk:dashboard")) + query_count_three = len(three_campaign_ctx) + + # With prefetch_related, adding more campaigns should not add extra queries per campaign. + assert query_count_two == query_count_three + def test_dashboard_view_excludes_testing_state_campaigns(self) -> None: """Test that the dashboard view excludes campaigns in the TESTING state.""" now: datetime = timezone.now() diff --git a/chzzk/views.py b/chzzk/views.py index 6146c31..16cd547 100644 --- a/chzzk/views.py +++ b/chzzk/views.py @@ -34,6 +34,7 @@ def dashboard_view(request: HttpRequest) -> HttpResponse: models.ChzzkCampaign.objects .filter(end_date__gte=timezone.now()) .exclude(state="TESTING") + .prefetch_related("rewards") .order_by("-start_date") ) return render( diff --git a/config/settings.py b/config/settings.py index 7fb2c0f..c65859b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -224,6 +224,10 @@ DATABASES: dict[str, dict[str, Any]] = configure_databases( base_dir=BASE_DIR, ) +if DEBUG: + INSTALLED_APPS.append("zeal") + MIDDLEWARE.append("zeal.middleware.zeal_middleware") + if not TESTING: INSTALLED_APPS = [*INSTALLED_APPS, "debug_toolbar", "silk"] MIDDLEWARE = [ diff --git a/kick/models.py b/kick/models.py index 1c2b4c0..d58a379 100644 --- a/kick/models.py +++ b/kick/models.py @@ -292,10 +292,19 @@ class KickDropCampaign(auto_prefetch.Model): def image_url(self) -> str: """Return the image URL for the campaign.""" # Image from first drop - if self.rewards.exists(): # pyright: ignore[reportAttributeAccessIssue] - first_reward: KickReward | None = self.rewards.first() # pyright: ignore[reportAttributeAccessIssue] - if first_reward and first_reward.image_url: - return first_reward.full_image_url + rewards_prefetched: list[KickReward] | None = getattr( + self, + "rewards_ordered", + None, + ) + if rewards_prefetched is not None: + first_reward: KickReward | None = ( + rewards_prefetched[0] if rewards_prefetched else None + ) + else: + first_reward = self.rewards.first() # pyright: ignore[reportAttributeAccessIssue] + if first_reward and first_reward.image_url: + return first_reward.full_image_url if self.category and self.category.image_url: return self.category.image_url diff --git a/pyproject.toml b/pyproject.toml index 836228a..9fee185 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "setproctitle", "sitemap-parser", "tqdm", + "django-zeal>=2.1.0", ] diff --git a/twitch/views.py b/twitch/views.py index ed1c0c7..974f829 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -438,7 +438,13 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0 if game_filter: queryset = queryset.filter(game__twitch_id=game_filter) - queryset = queryset.prefetch_related("game__owners").order_by("-start_at") + 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() @@ -588,18 +594,18 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo queryset=Channel.objects.order_by("display_name"), to_attr="channels_ordered", ), + Prefetch( + "time_based_drops", + queryset=TimeBasedDrop.objects.prefetch_related("benefits").order_by( + "required_minutes_watched", + ), + ), ).get(twitch_id=twitch_id) except DropCampaign.DoesNotExist as exc: msg = "No campaign found matching the query" raise Http404(msg) from exc - drops: QuerySet[TimeBasedDrop] = ( - TimeBasedDrop.objects - .filter(campaign=campaign) - .select_related("campaign") - .prefetch_related("benefits") - .order_by("required_minutes_watched") - ) + drops: QuerySet[TimeBasedDrop] = campaign.time_based_drops.all() # pyright: ignore[reportAttributeAccessIssue] now: datetime.datetime = timezone.now() enhanced_drops: list[dict[str, Any]] = _enhance_drops_with_context(drops, now)