diff --git a/templates/twitch/badge_list.html b/templates/twitch/badge_list.html
index 9c31657..5dfc573 100644
--- a/templates/twitch/badge_list.html
+++ b/templates/twitch/badge_list.html
@@ -40,7 +40,7 @@ These are the global chat badges available on Twitch.
{% endfor %}
{% else %}
- no badge sets found.
+ No badge sets found.
run: uv run python manage.py import_chat_badges
diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html
index 09afc7f..eeae339 100644
--- a/templates/twitch/campaign_detail.html
+++ b/templates/twitch/campaign_detail.html
@@ -167,6 +167,9 @@
margin-right: 4px" />
{{ drop.awarded_badge.title }}
+ {% if drop.awarded_badge.description %}
+ {{ drop.awarded_badge.description|linebreaksbr }}
+ {% endif %}
{% endif %}
{% endfor %}
diff --git a/twitch/feeds.py b/twitch/feeds.py
index db34669..348f42d 100644
--- a/twitch/feeds.py
+++ b/twitch/feeds.py
@@ -16,6 +16,7 @@ from django.utils.safestring import SafeString
from django.utils.safestring import SafeText
from twitch.models import Channel
+from twitch.models import ChatBadge
from twitch.models import DropBenefit
from twitch.models import DropCampaign
from twitch.models import Game
@@ -229,6 +230,18 @@ def _construct_drops_summary(drops_data: list[dict]) -> SafeText:
if not drops_data:
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]:
req: str = drop.get("requirements", "")
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]] = []
for b in benefits:
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:
- benefit_names.append((
- format_html(
- '{}',
- channel_name,
- benefit_name,
- ),
- ))
+ linked_name: SafeString = format_html(
+ '{}',
+ channel_name,
+ benefit_name,
+ )
+ if badge_desc:
+ benefit_names.append((format_html("{} ({})", linked_name, badge_desc),))
+ else:
+ benefit_names.append((linked_name,))
+ elif badge_desc:
+ benefit_names.append((format_html("{} ({})", benefit_name, badge_desc),))
else:
benefit_names.append((benefit_name,))
benefits_str: SafeString = format_html_join(", ", "{}", benefit_names) if benefit_names else SafeText("")
diff --git a/twitch/tests/test_feeds.py b/twitch/tests/test_feeds.py
index 4cf5949..81e9528 100644
--- a/twitch/tests/test_feeds.py
+++ b/twitch/tests/test_feeds.py
@@ -8,9 +8,13 @@ from django.test import TestCase
from django.urls import reverse
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 Game
from twitch.models import Organization
+from twitch.models import TimeBasedDrop
class RSSFeedTestCase(TestCase):
@@ -59,6 +63,39 @@ class RSSFeedTestCase(TestCase):
assert response.status_code == 200
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:
"""Test game-specific campaign feed returns 200."""
url = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id])
diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py
index 5ad8110..d59cff1 100644
--- a/twitch/tests/test_views.py
+++ b/twitch/tests/test_views.py
@@ -10,6 +10,8 @@ from django.urls import reverse
from django.utils import timezone
from twitch.models import Channel
+from twitch.models import ChatBadge
+from twitch.models import ChatBadgeSet
from twitch.models import DropBenefit
from twitch.models import DropCampaign
from twitch.models import Game
@@ -469,6 +471,54 @@ class TestChannelListView:
assert response.status_code == 200
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
def test_games_grid_view(self, client: Client) -> None:
"""Test games grid view returns 200 and has games in context."""
diff --git a/twitch/views.py b/twitch/views.py
index b73b79c..657a279 100644
--- a/twitch/views.py
+++ b/twitch/views.py
@@ -350,7 +350,7 @@ def _enhance_drops_with_context(drops: QuerySet[TimeBasedDrop], now: datetime.da
# MARK: /campaigns//
-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.
Args:
@@ -401,6 +401,16 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
campaign_data = json.loads(serialized_campaign)
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(
"json",
drops,
@@ -436,6 +446,20 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
),
)
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
campaign_data[0]["fields"]["drops"] = drops_data