Refactor badge list view to use badge_data and optimize badge fetching; add tests for badge list and detail views
This commit is contained in:
parent
b7e10e766e
commit
43077cde0c
5 changed files with 443 additions and 54 deletions
|
|
@ -4,11 +4,11 @@
|
|||
Chat Badges
|
||||
{% endblock title %}
|
||||
{% block content %}
|
||||
<h1>{{ badge_sets.count }} Twitch Chat Badges</h1>
|
||||
<h1>{{ badge_data|length }} Twitch Chat Badges</h1>
|
||||
<div>
|
||||
<a href="{% url 'twitch:dashboard' %}">Twitch</a> > Badges
|
||||
</div>
|
||||
{% if badge_sets %}
|
||||
{% if badge_data %}
|
||||
{% for data in badge_data %}
|
||||
<h2>
|
||||
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">{{ data.set.set_id }}</a>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
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)],
|
||||
# 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),
|
||||
)
|
||||
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue