Enhance badge descriptions in campaign detail and RSS feed outputs

This commit is contained in:
Joakim Hellsén 2026-01-16 01:54:18 +01:00
commit fad0821515
No known key found for this signature in database
6 changed files with 141 additions and 9 deletions

View file

@ -40,7 +40,7 @@ These are the global chat badges available on Twitch.
{% endfor %} {% endfor %}
<hr /> <hr />
{% else %} {% else %}
<p>no badge sets found.</p> <p>No badge sets found.</p>
<p> <p>
run: <code>uv run python manage.py import_chat_badges</code> run: <code>uv run python manage.py import_chat_badges</code>
</p> </p>

View file

@ -167,6 +167,9 @@
margin-right: 4px" /> margin-right: 4px" />
{{ drop.awarded_badge.title }} {{ drop.awarded_badge.title }}
</a> </a>
{% if drop.awarded_badge.description %}
<div style="margin-top: 4px; color: #a9a9a9; font-size: 0.9em;">{{ drop.awarded_badge.description|linebreaksbr }}</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View file

@ -16,6 +16,7 @@ from django.utils.safestring import SafeString
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
from twitch.models import Channel from twitch.models import Channel
from twitch.models import ChatBadge
from twitch.models import DropBenefit from twitch.models import DropBenefit
from twitch.models import DropCampaign from twitch.models import DropCampaign
from twitch.models import Game from twitch.models import Game
@ -229,6 +230,18 @@ def _construct_drops_summary(drops_data: list[dict]) -> SafeText:
if not drops_data: if not drops_data:
return SafeText("") return SafeText("")
badge_titles: set[str] = set()
for drop in drops_data:
for b in drop.get("benefits", []):
if getattr(b, "distribution_type", "") == "BADGE" and getattr(b, "name", ""):
badge_titles.add(b.name)
badge_descriptions_by_title: dict[str, str] = {}
if badge_titles:
badge_descriptions_by_title = dict(
ChatBadge.objects.filter(title__in=badge_titles).values_list("title", "description"),
)
def sort_key(drop: dict) -> tuple[bool, int]: def sort_key(drop: dict) -> tuple[bool, int]:
req: str = drop.get("requirements", "") req: str = drop.get("requirements", "")
m: re.Match[str] | None = re.search(r"(\d+) minutes watched", req) m: re.Match[str] | None = re.search(r"(\d+) minutes watched", req)
@ -246,14 +259,19 @@ def _construct_drops_summary(drops_data: list[dict]) -> SafeText:
benefit_names: list[tuple[str]] = [] benefit_names: list[tuple[str]] = []
for b in benefits: for b in benefits:
benefit_name: str = getattr(b, "name", str(b)) benefit_name: str = getattr(b, "name", str(b))
badge_desc: str | None = badge_descriptions_by_title.get(benefit_name)
if is_sub_required and channel_name: if is_sub_required and channel_name:
benefit_names.append(( linked_name: SafeString = format_html(
format_html( '<a href="https://twitch.tv/{}" target="_blank">{}</a>',
'<a href="https://twitch.tv/{}" target="_blank">{}</a>', channel_name,
channel_name, benefit_name,
benefit_name, )
), if badge_desc:
)) benefit_names.append((format_html("{} (<em>{}</em>)", linked_name, badge_desc),))
else:
benefit_names.append((linked_name,))
elif badge_desc:
benefit_names.append((format_html("{} (<em>{}</em>)", benefit_name, badge_desc),))
else: else:
benefit_names.append((benefit_name,)) benefit_names.append((benefit_name,))
benefits_str: SafeString = format_html_join(", ", "{}", benefit_names) if benefit_names else SafeText("") benefits_str: SafeString = format_html_join(", ", "{}", benefit_names) if benefit_names else SafeText("")

View file

@ -8,9 +8,13 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from twitch.models import ChatBadge
from twitch.models import ChatBadgeSet
from twitch.models import DropBenefit
from twitch.models import DropCampaign from twitch.models import DropCampaign
from twitch.models import Game from twitch.models import Game
from twitch.models import Organization from twitch.models import Organization
from twitch.models import TimeBasedDrop
class RSSFeedTestCase(TestCase): class RSSFeedTestCase(TestCase):
@ -59,6 +63,39 @@ class RSSFeedTestCase(TestCase):
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/rss+xml; charset=utf-8" assert response["Content-Type"] == "application/rss+xml; charset=utf-8"
def test_campaign_feed_includes_badge_description(self) -> None:
"""Badge benefit descriptions should be visible in the RSS drop summary."""
drop = TimeBasedDrop.objects.create(
twitch_id="drop-1",
name="Diana Chat Badge",
campaign=self.campaign,
required_minutes_watched=0,
required_subs=1,
)
benefit = DropBenefit.objects.create(
twitch_id="benefit-1",
name="Diana",
distribution_type="BADGE",
)
drop.benefits.add(benefit)
badge_set = ChatBadgeSet.objects.create(set_id="diana")
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="Diana",
description="This badge was earned by subscribing.",
)
url = reverse("twitch:campaign_feed")
response = self.client.get(url)
assert response.status_code == 200
content = response.content.decode("utf-8")
assert "This badge was earned by subscribing." in content
def test_game_campaign_feed(self) -> None: def test_game_campaign_feed(self) -> None:
"""Test game-specific campaign feed returns 200.""" """Test game-specific campaign feed returns 200."""
url = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) url = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id])

View file

@ -10,6 +10,8 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from twitch.models import Channel from twitch.models import Channel
from twitch.models import ChatBadge
from twitch.models import ChatBadgeSet
from twitch.models import DropBenefit from twitch.models import DropBenefit
from twitch.models import DropCampaign from twitch.models import DropCampaign
from twitch.models import Game from twitch.models import Game
@ -469,6 +471,54 @@ class TestChannelListView:
assert response.status_code == 200 assert response.status_code == 200
assert "campaign" in response.context assert "campaign" in response.context
@pytest.mark.django_db
def test_drop_campaign_detail_view_badge_benefit_includes_description_from_chatbadge(
self,
client: Client,
) -> None:
"""Test campaign detail view includes badge benefit description from ChatBadge."""
game: Game = Game.objects.create(twitch_id="g-badge", name="Game", display_name="Game")
campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="c-badge",
name="Campaign",
game=game,
operation_name="DropCampaignDetails",
)
drop = TimeBasedDrop.objects.create(
twitch_id="d1",
name="Drop",
campaign=campaign,
required_minutes_watched=0,
required_subs=1,
)
benefit = DropBenefit.objects.create(
twitch_id="b1",
name="Diana",
distribution_type="BADGE",
)
drop.benefits.add(benefit)
badge_set = ChatBadgeSet.objects.create(set_id="diana")
ChatBadge.objects.create(
badge_set=badge_set,
badge_id="1",
image_url_1x="https://example.com/1",
image_url_2x="https://example.com/2",
image_url_4x="https://example.com/4",
title="Diana",
description="This badge was earned by subscribing.",
)
url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
# The campaign detail page prints a syntax-highlighted JSON block; the badge description should be present.
html = response.content.decode("utf-8")
assert "This badge was earned by subscribing." in html
@pytest.mark.django_db @pytest.mark.django_db
def test_games_grid_view(self, client: Client) -> None: def test_games_grid_view(self, client: Client) -> None:
"""Test games grid view returns 200 and has games in context.""" """Test games grid view returns 200 and has games in context."""

View file

@ -350,7 +350,7 @@ def _enhance_drops_with_context(drops: QuerySet[TimeBasedDrop], now: datetime.da
# MARK: /campaigns/<twitch_id>/ # MARK: /campaigns/<twitch_id>/
def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914
"""Function-based view for a drop campaign detail. """Function-based view for a drop campaign detail.
Args: Args:
@ -401,6 +401,16 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
campaign_data = json.loads(serialized_campaign) campaign_data = json.loads(serialized_campaign)
if drops.exists(): if drops.exists():
badge_benefit_names: set[str] = {
benefit.name
for drop in drops
for benefit in drop.benefits.all()
if benefit.distribution_type == "BADGE" and benefit.name
}
badge_descriptions_by_title: dict[str, str] = dict(
ChatBadge.objects.filter(title__in=badge_benefit_names).values_list("title", "description"),
)
serialized_drops = serialize( serialized_drops = serialize(
"json", "json",
drops, drops,
@ -436,6 +446,20 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
), ),
) )
benefits_data = json.loads(serialized_benefits) benefits_data = json.loads(serialized_benefits)
for benefit_data in benefits_data:
fields: dict[str, Any] = benefit_data.get("fields", {})
if fields.get("distribution_type") != "BADGE":
continue
# DropBenefit doesn't have a description field; fetch it from ChatBadge when possible.
if fields.get("description"):
continue
badge_description: str | None = badge_descriptions_by_title.get(fields.get("name", ""))
if badge_description:
fields["description"] = badge_description
drops_data[i]["fields"]["benefits"] = benefits_data drops_data[i]["fields"]["benefits"] = benefits_data
campaign_data[0]["fields"]["drops"] = drops_data campaign_data[0]["fields"]["drops"] = drops_data