diff --git a/templates/twitch/badge_list.html b/templates/twitch/badge_list.html index a91b3cc..ea15c09 100644 --- a/templates/twitch/badge_list.html +++ b/templates/twitch/badge_list.html @@ -4,11 +4,11 @@ Chat Badges {% endblock title %} {% block content %} -

{{ badge_sets.count }} Twitch Chat Badges

+

{{ badge_data|length }} Twitch Chat Badges

Twitch > Badges
- {% if badge_sets %} + {% if badge_data %} {% for data in badge_data %}

{{ data.set.set_id }} diff --git a/twitch/migrations/0017_dropbenefit_twitch_drop_distrib_70d961_idx.py b/twitch/migrations/0017_dropbenefit_twitch_drop_distrib_70d961_idx.py new file mode 100644 index 0000000..ae8e9b2 --- /dev/null +++ b/twitch/migrations/0017_dropbenefit_twitch_drop_distrib_70d961_idx.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.4 on 2026-04-10 23:02 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + """Add an index on DropBenefit for distribution_type and name.""" + + dependencies = [ + ("twitch", "0016_mark_all_drops_fully_imported"), + ] + + operations = [ + migrations.AddIndex( + model_name="dropbenefit", + index=models.Index( + fields=["distribution_type", "name"], + name="twitch_drop_distrib_70d961_idx", + ), + ), + ] diff --git a/twitch/models.py b/twitch/models.py index 3f0f77f..73daa0e 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -8,6 +8,7 @@ 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 Prefetch from django.urls import reverse from django.utils import timezone from django.utils.html import format_html @@ -17,6 +18,7 @@ from twitch.utils import normalize_twitch_box_art_url if TYPE_CHECKING: import datetime + from django.db.models import QuerySet logger: logging.Logger = logging.getLogger("ttvdrops") @@ -882,6 +884,8 @@ class DropBenefit(auto_prefetch.Model): models.Index(fields=["is_ios_available"]), models.Index(fields=["added_at"]), models.Index(fields=["updated_at"]), + # Composite index for badge award lookups (distribution_type="BADGE", name__in=titles) + models.Index(fields=["distribution_type", "name"]), ] def __str__(self) -> str: @@ -1261,6 +1265,20 @@ class ChatBadgeSet(auto_prefetch.Model): """Return a string representation of the badge set.""" return self.set_id + @classmethod + def for_list_view(cls) -> QuerySet[ChatBadgeSet]: + """Return all badge sets with badges prefetched, ordered by set_id.""" + return cls.objects.prefetch_related( + Prefetch("badges", queryset=ChatBadge.objects.order_by("badge_id")), + ).order_by("set_id") + + @classmethod + def for_detail_view(cls, set_id: str) -> ChatBadgeSet: + """Return a single badge set with badges prefetched.""" + return cls.objects.prefetch_related( + Prefetch("badges", queryset=ChatBadge.objects.order_by("badge_id")), + ).get(set_id=set_id) + # MARK: ChatBadge class ChatBadge(auto_prefetch.Model): @@ -1355,3 +1373,43 @@ class ChatBadge(auto_prefetch.Model): def __str__(self) -> str: """Return a string representation of the badge.""" return f"{self.badge_set.set_id}/{self.badge_id}: {self.title}" + + @staticmethod + def award_campaigns_by_title(titles: list[str]) -> dict[str, list[DropCampaign]]: + """Batch-fetch DropCampaigns that award badges matching the given titles. + + Avoids N+1 queries: one query traverses DropBenefit → TimeBasedDrop → DropCampaign + to get (benefit_name, campaign_pk) pairs, then one more query fetches the campaigns. + + Returns: + Mapping of badge title to a list of DropCampaigns awarding it. + Titles with no matching campaigns are omitted. + """ + if not titles: + return {} + + # Single JOIN query: (benefit_name, campaign_pk) via the M2M chain + # DropBenefit -> DropBenefitEdge -> TimeBasedDrop -> DropCampaign (FK column) + pairs: list[tuple[str, int | None]] = list( + DropBenefit.objects + .filter(distribution_type="BADGE", name__in=titles) + .values_list("name", "drops__campaign_id") + .distinct(), + ) + + title_to_campaign_pks: dict[str, set[int]] = {} + for name, campaign_pk in pairs: + if campaign_pk is not None: + title_to_campaign_pks.setdefault(name, set()).add(campaign_pk) + + if not title_to_campaign_pks: + return {} + + all_campaign_pks = {pk for pks in title_to_campaign_pks.values() for pk in pks} + campaigns_by_pk: dict[int, DropCampaign] = { + c.pk: c for c in DropCampaign.objects.filter(pk__in=all_campaign_pks) + } + return { + title: [campaigns_by_pk[pk] for pk in sorted(pks) if pk in campaigns_by_pk] + for title, pks in title_to_campaign_pks.items() + } diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index c393247..2297d33 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -2761,3 +2761,347 @@ class TestImageObjectStructuredData: schema: dict[str, Any] = json.loads(response.context["schema_data"]) assert schema["image"]["creditText"] == "Real Campaign Publisher" assert schema["organizer"]["name"] == "Real Campaign Publisher" + + +@pytest.mark.django_db +class TestBadgeListView: + """Tests for the badge_list_view function.""" + + def test_badge_list_returns_200(self, client: Client) -> None: + """Badge list view renders successfully with no badge sets.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:badge_list")) + assert response.status_code == 200 + + def test_badge_list_context_has_badge_data(self, client: Client) -> None: + """Badge list view passes badge_data list (not badge_sets queryset) to template.""" + badge_set: ChatBadgeSet = ChatBadgeSet.objects.create(set_id="test_vip") + ChatBadge.objects.create( + badge_set=badge_set, + badge_id="1", + image_url_1x="https://example.com/1x.png", + image_url_2x="https://example.com/2x.png", + image_url_4x="https://example.com/4x.png", + title="VIP", + description="VIP badge", + ) + + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:badge_list")) + context: ContextList | dict[str, Any] = response.context # type: ignore[assignment] + if isinstance(context, list): + context = context[-1] + + assert "badge_data" in context + assert len(context["badge_data"]) == 1 + assert context["badge_data"][0]["set"].set_id == "test_vip" + assert len(context["badge_data"][0]["badges"]) == 1 + assert "badge_sets" not in context + + def test_badge_list_query_count_stays_flat(self, client: Client) -> None: + """badge_list_view should not issue N+1 queries as badge set count grows.""" + for i in range(3): + bs: ChatBadgeSet = ChatBadgeSet.objects.create(set_id=f"set_flat_{i}") + for j in range(4): + ChatBadge.objects.create( + badge_set=bs, + badge_id=str(j), + image_url_1x="https://example.com/1x.png", + image_url_2x="https://example.com/2x.png", + image_url_4x="https://example.com/4x.png", + title=f"Badge {i}-{j}", + description="desc", + ) + + def _count_selects() -> int: + with CaptureQueriesContext(connection) as ctx: + resp: _MonkeyPatchedWSGIResponse = client.get( + reverse("twitch:badge_list"), + ) + assert resp.status_code == 200 + return sum( + 1 + for q in ctx.captured_queries + if q["sql"].lstrip().upper().startswith("SELECT") + ) + + baseline: int = _count_selects() + + # Add 10 more badge sets with badges + for i in range(3, 13): + bs = ChatBadgeSet.objects.create(set_id=f"set_flat_{i}") + for j in range(4): + ChatBadge.objects.create( + badge_set=bs, + badge_id=str(j), + image_url_1x="https://example.com/1x.png", + image_url_2x="https://example.com/2x.png", + image_url_4x="https://example.com/4x.png", + title=f"Badge {i}-{j}", + description="desc", + ) + + scaled: int = _count_selects() + assert scaled <= baseline + 1, ( + f"badge_list_view SELECT count grew with data; possible N+1. " + f"baseline={baseline}, scaled={scaled}" + ) + + +@pytest.mark.django_db +class TestBadgeSetDetailView: + """Tests for the badge_set_detail_view function.""" + + @pytest.fixture + def badge_set_with_badges(self) -> dict[str, Any]: + """Create a badge set with numeric badge IDs and a campaign awarding one badge. + + Returns: + Dict with badge_set, badge1-3, campaign, and benefit instances. + """ + org: Organization = Organization.objects.create( + twitch_id="org_badge_test", + name="Badge Test Org", + ) + game: Game = Game.objects.create( + twitch_id="game_badge_test", + name="badge_test_game", + display_name="Badge Test Game", + ) + game.owners.add(org) + + badge_set: ChatBadgeSet = ChatBadgeSet.objects.create(set_id="drops") + badge1: ChatBadge = ChatBadge.objects.create( + badge_set=badge_set, + badge_id="1", + image_url_1x="https://example.com/1x.png", + image_url_2x="https://example.com/2x.png", + image_url_4x="https://example.com/4x.png", + title="Drop 1", + description="First drop badge", + ) + badge2: ChatBadge = ChatBadge.objects.create( + badge_set=badge_set, + badge_id="10", + image_url_1x="https://example.com/1x.png", + image_url_2x="https://example.com/2x.png", + image_url_4x="https://example.com/4x.png", + title="Drop 10", + description="Tenth drop badge", + ) + badge3: ChatBadge = ChatBadge.objects.create( + badge_set=badge_set, + badge_id="2", + image_url_1x="https://example.com/1x.png", + image_url_2x="https://example.com/2x.png", + image_url_4x="https://example.com/4x.png", + title="Drop 2", + description="Second drop badge", + ) + + campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="badge_test_campaign", + name="Badge Test Campaign", + game=game, + operation_names=["DropCampaignDetails"], + ) + drop: TimeBasedDrop = TimeBasedDrop.objects.create( + twitch_id="badge_test_drop", + name="Badge Test Drop", + campaign=campaign, + ) + benefit: DropBenefit = DropBenefit.objects.create( + twitch_id="badge_test_benefit", + name="Drop 1", + distribution_type="BADGE", + ) + drop.benefits.add(benefit) + + return { + "badge_set": badge_set, + "badge1": badge1, + "badge2": badge2, + "badge3": badge3, + "campaign": campaign, + "benefit": benefit, + } + + def test_badge_set_detail_returns_200( + self, + client: Client, + badge_set_with_badges: dict[str, Any], + ) -> None: + """Badge set detail view renders successfully.""" + set_id: str = badge_set_with_badges["badge_set"].set_id + url: str = reverse("twitch:badge_set_detail", args=[set_id]) + response: _MonkeyPatchedWSGIResponse = client.get(url) + assert response.status_code == 200 + + def test_badge_set_detail_404_for_missing_set(self, client: Client) -> None: + """Badge set detail view returns 404 for unknown set_id.""" + url: str = reverse("twitch:badge_set_detail", args=["nonexistent"]) + response: _MonkeyPatchedWSGIResponse = client.get(url) + assert response.status_code == 404 + + def test_badges_sorted_numerically( + self, + client: Client, + badge_set_with_badges: dict[str, Any], + ) -> None: + """Numeric badge_ids should be sorted as integers (1, 2, 10) not strings (1, 10, 2).""" + set_id: str = badge_set_with_badges["badge_set"].set_id + url: str = reverse("twitch:badge_set_detail", args=[set_id]) + response: _MonkeyPatchedWSGIResponse = client.get(url) + context: ContextList | dict[str, Any] = response.context # type: ignore[assignment] + if isinstance(context, list): + context = context[-1] + + badge_ids: list[str] = [b.badge_id for b in context["badges"]] + assert badge_ids == ["1", "2", "10"], ( + f"Expected numeric sort order [1, 2, 10], got {badge_ids}" + ) + + def test_award_campaigns_attached_to_badges( + self, + client: Client, + badge_set_with_badges: dict[str, Any], + ) -> None: + """Badges with matching BADGE benefits should have award_campaigns populated.""" + set_id: str = badge_set_with_badges["badge_set"].set_id + url: str = reverse("twitch:badge_set_detail", args=[set_id]) + response: _MonkeyPatchedWSGIResponse = client.get(url) + context: ContextList | dict[str, Any] = response.context # type: ignore[assignment] + if isinstance(context, list): + context = context[-1] + + badges: list[ChatBadge] = list(context["badges"]) + badge_titled_drop1: ChatBadge = next(b for b in badges if b.title == "Drop 1") + badge_titled_drop2: ChatBadge = next(b for b in badges if b.title == "Drop 2") + + assert len(badge_titled_drop1.award_campaigns) == 1 # pyright: ignore[reportAttributeAccessIssue] + assert badge_titled_drop1.award_campaigns[0].twitch_id == "badge_test_campaign" # pyright: ignore[reportAttributeAccessIssue] + assert len(badge_titled_drop2.award_campaigns) == 0 # pyright: ignore[reportAttributeAccessIssue] + + def test_badge_set_detail_avoids_n_plus_one( + self, + client: Client, + ) -> None: + """badge_set_detail_view should not issue per-badge queries for award campaigns.""" + org: Organization = Organization.objects.create( + twitch_id="org_n1_badge", + name="N+1 Badge Org", + ) + game: Game = Game.objects.create( + twitch_id="game_n1_badge", + name="game_n1_badge", + display_name="N+1 Badge Game", + ) + game.owners.add(org) + + badge_set: ChatBadgeSet = ChatBadgeSet.objects.create(set_id="n1_test") + + def _make_badge_and_campaign(idx: int) -> None: + badge: ChatBadge = ChatBadge.objects.create( + badge_set=badge_set, + badge_id=str(idx), + image_url_1x="https://example.com/1x.png", + image_url_2x="https://example.com/2x.png", + image_url_4x="https://example.com/4x.png", + title=f"N1 Badge {idx}", + description="desc", + ) + campaign: DropCampaign = DropCampaign.objects.create( + twitch_id=f"n1_campaign_{idx}", + name=f"N+1 Campaign {idx}", + game=game, + operation_names=["DropCampaignDetails"], + ) + drop: TimeBasedDrop = TimeBasedDrop.objects.create( + twitch_id=f"n1_drop_{idx}", + name=f"N+1 Drop {idx}", + campaign=campaign, + ) + benefit: DropBenefit = DropBenefit.objects.create( + twitch_id=f"n1_benefit_{idx}", + name=badge.title, + distribution_type="BADGE", + ) + drop.benefits.add(benefit) + + for i in range(3): + _make_badge_and_campaign(i) + + url: str = reverse("twitch:badge_set_detail", args=[badge_set.set_id]) + + def _count_selects() -> int: + with CaptureQueriesContext(connection) as ctx: + resp: _MonkeyPatchedWSGIResponse = client.get(url) + assert resp.status_code == 200 + return sum( + 1 + for q in ctx.captured_queries + if q["sql"].lstrip().upper().startswith("SELECT") + ) + + baseline: int = _count_selects() + + # Add 10 more badges, each with their own campaigns + for i in range(3, 13): + _make_badge_and_campaign(i) + + scaled: int = _count_selects() + assert scaled <= baseline + 1, ( + f"badge_set_detail_view SELECT count grew with badge count; possible N+1. " + f"baseline={baseline}, scaled={scaled}" + ) + + def test_drop_benefit_index_used_for_badge_award_lookup(self) -> None: + """DropBenefit queries filtering by distribution_type+name should use indexes.""" + org: Organization = Organization.objects.create( + twitch_id="org_benefit_idx", + name="Benefit Index Org", + ) + game: Game = Game.objects.create( + twitch_id="game_benefit_idx", + name="game_benefit_idx", + display_name="Benefit Index Game", + ) + game.owners.add(org) + + # Create enough non-BADGE benefits so the planner has reason to use an index + for i in range(300): + DropBenefit.objects.create( + twitch_id=f"non_badge_{i}", + name=f"Emote {i}", + distribution_type="EMOTE", + ) + + badge_titles: list[str] = [] + for i in range(5): + DropBenefit.objects.create( + twitch_id=f"badge_benefit_idx_{i}", + name=f"Badge Title {i}", + distribution_type="BADGE", + ) + badge_titles.append(f"Badge Title {i}") + + qs = DropBenefit.objects.filter( + distribution_type="BADGE", + name__in=badge_titles, + ) + plan: str = qs.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-plan assertion: {connection.vendor}", + ) + + assert uses_index, ( + f"DropBenefit query on (distribution_type, name) did not use an index.\n{plan}" + ) diff --git a/twitch/views.py b/twitch/views.py index f017dfd..2e9be5b 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -14,11 +14,9 @@ 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 Case from django.db.models import Count from django.db.models import Prefetch from django.db.models import Q -from django.db.models import When from django.db.models.query import QuerySet from django.http import Http404 from django.http import HttpResponse @@ -1585,22 +1583,12 @@ def badge_list_view(request: HttpRequest) -> HttpResponse: Returns: HttpResponse: The rendered badge list page. """ - badge_sets: QuerySet[ChatBadgeSet] = ( - ChatBadgeSet.objects - .all() - .prefetch_related( - Prefetch("badges", queryset=ChatBadge.objects.order_by("badge_id")), - ) - .order_by("set_id") - ) - - # Group badges by set for easier display badge_data: list[dict[str, Any]] = [ { "set": badge_set, "badges": list(badge_set.badges.all()), # pyright: ignore[reportAttributeAccessIssue] } - for badge_set in badge_sets + for badge_set in ChatBadgeSet.for_list_view() ] # CollectionPage schema for badges list @@ -1618,7 +1606,6 @@ def badge_list_view(request: HttpRequest) -> HttpResponse: seo_meta={"schema_data": collection_schema}, ) context: dict[str, Any] = { - "badge_sets": badge_sets, "badge_data": badge_data, **seo_context, } @@ -1641,52 +1628,30 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse: Http404: If the badge set is not found. """ try: - badge_set: ChatBadgeSet = ChatBadgeSet.objects.prefetch_related( - Prefetch("badges", queryset=ChatBadge.objects.order_by("badge_id")), - ).get(set_id=set_id) + badge_set: ChatBadgeSet = ChatBadgeSet.for_detail_view(set_id) except ChatBadgeSet.DoesNotExist as exc: msg = "No badge set found matching the query" raise Http404(msg) from exc - def get_sorted_badges(badge_set: ChatBadgeSet) -> QuerySet[ChatBadge]: - badges = badge_set.badges.all() # pyright: ignore[reportAttributeAccessIssue] + # Sort badges treating pure-numeric badge_ids as integers, strings alphabetically after + badges: list[ChatBadge] = sorted( + badge_set.badges.all(), # pyright: ignore[reportAttributeAccessIssue] + key=lambda b: (0, int(b.badge_id)) if b.badge_id.isdigit() else (1, b.badge_id), + ) - def sort_badges(badge: ChatBadge) -> tuple: - """Sort badges by badge_id, treating numeric IDs as integers. - - Args: - badge: The ChatBadge to sort. - - Returns: - A tuple used for sorting, where numeric badge_ids are sorted as integers. - """ - try: - return (int(badge.badge_id),) - except ValueError: - return (badge.badge_id,) - - sorted_badges: list[ChatBadge] = sorted(badges, key=sort_badges) - badge_ids: list[int] = [badge.pk for badge in sorted_badges] - preserved_order = Case( - *[When(pk=pk, then=pos) for pos, pk in enumerate(badge_ids)], - ) - return ChatBadge.objects.filter(pk__in=badge_ids).order_by(preserved_order) - - badges: QuerySet[ChatBadge, ChatBadge] = get_sorted_badges(badge_set) - - # Attach award_campaigns attribute to each badge for template use + # Batch-fetch award campaigns for all badge titles (2 queries regardless of badge count) + award_map: dict[str, list[DropCampaign]] = ChatBadge.award_campaigns_by_title( + [b.title for b in badges], + ) for badge in badges: - benefits: QuerySet[DropBenefit, DropBenefit] = DropBenefit.objects.filter( - distribution_type="BADGE", - name=badge.title, - ) - campaigns: QuerySet[DropCampaign, DropCampaign] = DropCampaign.objects.filter( - time_based_drops__benefits__in=benefits, - ).distinct() - badge.award_campaigns = list(campaigns) # pyright: ignore[reportAttributeAccessIssue] + badge.award_campaigns = award_map.get(badge.title, []) # pyright: ignore[reportAttributeAccessIssue] badge_set_name: str = badge_set.set_id - badge_set_description: str = f"Twitch chat badge set {badge_set_name} with {len(badges)} badge{'s' if len(badges) != 1 else ''} awarded through drop campaigns." + badge_count: int = len(badges) + badge_set_description: str = ( + f"Twitch chat badge set {badge_set_name} with {badge_count} " + f"badge{'s' if badge_count != 1 else ''} awarded through drop campaigns." + ) badge_schema: dict[str, Any] = { "@context": "https://schema.org",