Add support for a hide_paid query parameter to campaign feeds
All checks were successful
Deploy to Server / deploy (push) Successful in 28s

This commit is contained in:
Joakim Hellsén 2026-05-05 06:05:10 +02:00
commit 8229b0fe80
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
3 changed files with 201 additions and 29 deletions

View file

@ -20,6 +20,11 @@
Twitch JSON API documentation is available at
<a href="{% url 'twitch:twitch-api-v1:openapi-view' %}">/twitch/api/v1/docs</a>.
</p>
<p>
Twitch campaign feeds accept <code>?limit=50</code> to change item count and
<code>?hide_paid=1</code> to hide subscription-gated drops and skip campaigns
with no free drops.
</p>
<section>
<h2>Global RSS Feeds</h2>
<table>

View file

@ -9,6 +9,8 @@ from django.conf import settings
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.contrib.sites.shortcuts import get_current_site
from django.contrib.syndication.views import Feed
from django.db.models import Exists
from django.db.models import OuterRef
from django.db.models import Prefetch
from django.db.models.query import QuerySet
from django.http.request import HttpRequest
@ -47,6 +49,23 @@ if TYPE_CHECKING:
logger: logging.Logger = logging.getLogger("ttvdrops")
RSS_STYLESHEETS: list[str] = [static("rss_styles.xslt")]
TRUE_QUERY_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})
def _query_bool(request: HttpRequest, name: str) -> bool:
"""Return True when a query parameter is present with a truthy value."""
return request.GET.get(name, "").strip().casefold() in TRUE_QUERY_VALUES
def _query_limit(request: HttpRequest) -> int | None:
"""Return an integer ?limit value, or None when it is missing/invalid."""
value: str | None = request.GET.get("limit")
if not value:
return None
try:
return int(value)
except TypeError, ValueError:
return None
def discord_timestamp(dt: datetime.datetime | None) -> SafeText:
@ -256,6 +275,21 @@ def _active_drop_campaigns(queryset: QuerySet[DropCampaign]) -> QuerySet[DropCam
return queryset.filter(start_at__lte=now, end_at__gte=now)
def _campaigns_with_free_drops(
queryset: QuerySet[DropCampaign],
) -> QuerySet[DropCampaign]:
"""Keep campaigns that contain at least one drop without a sub requirement.
Returns:
QuerySet[DropCampaign]: Campaigns that have at least one free drop.
"""
free_drops: QuerySet[TimeBasedDrop] = TimeBasedDrop.objects.filter(
campaign_id=OuterRef("pk"),
required_subs=0,
)
return queryset.filter(Exists(free_drops))
def _active_reward_campaigns(
queryset: QuerySet[RewardCampaign],
) -> QuerySet[RewardCampaign]:
@ -450,11 +484,16 @@ def generate_discord_date_html(item: Model) -> list[SafeText]:
return parts
def generate_drops_summary_html(item: DropCampaign) -> list[SafeString]:
def generate_drops_summary_html(
item: DropCampaign,
*,
hide_paid: bool = False,
) -> list[SafeString]:
"""Generate HTML summary for drops and append to parts list.
Args:
item (DropCampaign): The drop campaign item containing the drops to summarize.
hide_paid: Exclude drops that require paid subscriptions from the summary.
Returns:
list[SafeString]: A list of SafeText elements summarizing the drops, or empty if no drops.
@ -467,7 +506,7 @@ def generate_drops_summary_html(item: DropCampaign) -> list[SafeString]:
drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None)
if drops:
drops_data = _build_drops_data(drops.all())
drops_data = _build_drops_data(drops.all(), hide_paid=hide_paid)
if drops_data:
parts.append(
@ -480,13 +519,14 @@ def generate_drops_summary_html(item: DropCampaign) -> list[SafeString]:
return parts
def generate_channels_html(item: Model) -> list[SafeText]:
def generate_channels_html(item: Model, *, hide_paid: bool = False) -> list[SafeText]:
"""Generate HTML for the list of channels associated with a drop campaign, if applicable.
Only generates channel links if the drop is not subscription-only. If it is subscription-only, it will skip channel links to avoid confusion.
Args:
item (Model): The campaign item which may have an 'is_subscription_only' attribute.
hide_paid: Treat paid drops as hidden when deciding whether to show channels.
Returns:
list[SafeText]: A list containing the HTML for the channels section, or empty if subscription-only or no channels.
@ -498,7 +538,7 @@ def generate_channels_html(item: Model) -> list[SafeText]:
if not channels:
return parts
if getattr(item, "is_subscription_only", False):
if not hide_paid and getattr(item, "is_subscription_only", False):
return parts
game: Game | None = getattr(item, "game", None)
@ -599,7 +639,11 @@ def create_channel_list_html(
parts.append(format_html("<p>Channels with this drop:</p>{}", html))
def _build_drops_data(drops_qs: QuerySet[TimeBasedDrop]) -> list[dict]:
def _build_drops_data(
drops_qs: QuerySet[TimeBasedDrop],
*,
hide_paid: bool = False,
) -> list[dict]:
"""Build a simplified data structure for rendering drops in a template.
Returns:
@ -611,6 +655,8 @@ def _build_drops_data(drops_qs: QuerySet[TimeBasedDrop]) -> list[dict]:
requirements: str = ""
required_minutes: int | None = getattr(drop, "required_minutes_watched", None)
required_subs: int = getattr(drop, "required_subs", 0) or 0
if hide_paid and required_subs > 0:
continue
if required_minutes:
requirements = f"{required_minutes} minutes watched"
if required_subs > 0:
@ -971,6 +1017,7 @@ class DropCampaignFeed(TTVDropsBaseFeed):
item_guid_is_permalink = True
_limit: int | None = None
_hide_paid: bool = False
def __call__(
self,
@ -978,29 +1025,28 @@ class DropCampaignFeed(TTVDropsBaseFeed):
*args: str | int,
**kwargs: str | int,
) -> HttpResponse:
"""Override to capture limit parameter from request.
"""Override to capture supported feed query parameters from request.
Args:
request (HttpRequest): The incoming HTTP request, potentially containing a 'limit' query parameter.
request (HttpRequest): The incoming HTTP request, potentially containing 'limit' and 'hide_paid' query parameters.
*args: Additional positional arguments.
**kwargs: Additional keyword arguments.
Returns:
HttpResponse: The HTTP response generated by the parent Feed class after processing the request.
"""
if request.GET.get("limit"):
try:
self._limit = int(request.GET.get("limit", 200))
except ValueError, TypeError:
self._limit = None
self._limit = _query_limit(request)
self._hide_paid = _query_bool(request, "hide_paid")
return super().__call__(request, *args, **kwargs)
def items(self) -> list[DropCampaign]:
"""Return the latest drop campaigns ordered by most recent start date (default 200, or limited by ?limit query param)."""
"""Return latest active drop campaigns."""
limit: int = self._limit if self._limit is not None else 200
queryset: QuerySet[DropCampaign] = _active_drop_campaigns(
DropCampaign.objects.order_by("-start_at"),
)
if self._hide_paid:
queryset = _campaigns_with_free_drops(queryset)
return list(_with_campaign_related(queryset)[:limit])
def item_title(self, item: DropCampaign) -> SafeText:
@ -1015,8 +1061,8 @@ class DropCampaignFeed(TTVDropsBaseFeed):
parts.extend(generate_item_image(item))
parts.extend(generate_description_html(item=item))
parts.extend(generate_date_html(item=item))
parts.extend(generate_drops_summary_html(item=item))
parts.extend(generate_channels_html(item))
parts.extend(generate_drops_summary_html(item=item, hide_paid=self._hide_paid))
parts.extend(generate_channels_html(item, hide_paid=self._hide_paid))
parts.extend(generate_details_link_html(item))
return SafeText("".join(str(p) for p in parts))
@ -1110,6 +1156,7 @@ class GameCampaignFeed(TTVDropsBaseFeed):
item_guid_is_permalink = True
_limit: int | None = None
_hide_paid: bool = False
def __call__(
self,
@ -1117,21 +1164,18 @@ class GameCampaignFeed(TTVDropsBaseFeed):
*args: str | int,
**kwargs: str | int,
) -> HttpResponse:
"""Override to capture limit parameter from request.
"""Override to capture supported feed query parameters from request.
Args:
request (HttpRequest): The incoming HTTP request, potentially containing a 'limit' query parameter
request (HttpRequest): The incoming HTTP request, potentially containing 'limit' and 'hide_paid' query parameters.
*args: Additional positional arguments.
**kwargs: Additional keyword arguments.
Returns:
HttpResponse: The HTTP response generated by the parent Feed class after processing the request.
"""
if request.GET.get("limit"):
try:
self._limit = int(request.GET.get("limit", 200))
except ValueError, TypeError:
self._limit = None
self._limit = _query_limit(request)
self._hide_paid = _query_bool(request, "hide_paid")
return super().__call__(request, *args, **kwargs)
def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002
@ -1159,13 +1203,15 @@ class GameCampaignFeed(TTVDropsBaseFeed):
return f"Latest drop campaigns for {obj.display_name}"
def items(self, obj: Game) -> list[DropCampaign]:
"""Return the latest drop campaigns for this game, ordered by most recent start date (default 200, or limited by ?limit query param)."""
"""Return latest active drop campaigns for this game."""
limit: int = self._limit if self._limit is not None else 200
queryset: QuerySet[DropCampaign] = _active_drop_campaigns(
DropCampaign.objects.filter(
game=obj,
).order_by("-start_at"),
)
if self._hide_paid:
queryset = _campaigns_with_free_drops(queryset)
return list(_with_campaign_related(queryset)[:limit])
def item_title(self, item: DropCampaign) -> SafeText:
@ -1180,8 +1226,8 @@ class GameCampaignFeed(TTVDropsBaseFeed):
parts.extend(generate_item_image_tag(item))
parts.extend(generate_details_link(item))
parts.extend(generate_date_html(item))
parts.extend(generate_drops_summary_html(item))
parts.extend(generate_channels_html(item))
parts.extend(generate_drops_summary_html(item, hide_paid=self._hide_paid))
parts.extend(generate_channels_html(item, hide_paid=self._hide_paid))
return SafeText("".join(str(p) for p in parts))
@ -1554,8 +1600,8 @@ class DropCampaignDiscordFeed(TTVDropsAtomBaseFeed, DropCampaignFeed):
parts.extend(generate_item_image(item))
parts.extend(generate_description_html(item=item))
parts.extend(generate_discord_date_html(item=item))
parts.extend(generate_drops_summary_html(item=item))
parts.extend(generate_channels_html(item))
parts.extend(generate_drops_summary_html(item=item, hide_paid=self._hide_paid))
parts.extend(generate_channels_html(item, hide_paid=self._hide_paid))
parts.extend(generate_details_link_html(item))
return SafeText("".join(str(p) for p in parts))
@ -1575,8 +1621,8 @@ class GameCampaignDiscordFeed(TTVDropsAtomBaseFeed, GameCampaignFeed):
parts.extend(generate_item_image_tag(item))
parts.extend(generate_details_link(item))
parts.extend(generate_discord_date_html(item))
parts.extend(generate_drops_summary_html(item))
parts.extend(generate_channels_html(item))
parts.extend(generate_drops_summary_html(item, hide_paid=self._hide_paid))
parts.extend(generate_channels_html(item, hide_paid=self._hide_paid))
return SafeText("".join(str(p) for p in parts))

View file

@ -104,6 +104,90 @@ class RSSFeedTestCase(TestCase):
game=self.game,
)
def _create_mixed_paid_and_free_campaign(self) -> DropCampaign:
"""Create an active campaign containing both free and subscription-gated drops.
Returns:
DropCampaign: The mixed campaign fixture.
"""
campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="mixed-campaign-123",
name="Mixed Watch And Subscription Campaign",
game=self.game,
start_at=timezone.now(),
end_at=timezone.now() + timedelta(days=7),
operation_names=["DropCampaignDetails"],
)
channel: Channel = Channel.objects.create(
twitch_id="mixed-channel-123",
name="mixedchannel",
display_name="MixedChannel",
)
campaign.allow_channels.add(channel)
free_drop: TimeBasedDrop = TimeBasedDrop.objects.create(
twitch_id="free-drop-123",
name="Watch Drop",
campaign=campaign,
required_minutes_watched=30,
required_subs=0,
start_at=timezone.now(),
end_at=timezone.now() + timedelta(hours=1),
)
paid_drop: TimeBasedDrop = TimeBasedDrop.objects.create(
twitch_id="paid-drop-123",
name="Subscription Required Drop",
campaign=campaign,
required_minutes_watched=0,
required_subs=1,
start_at=timezone.now(),
end_at=timezone.now() + timedelta(hours=1),
)
free_benefit: DropBenefit = DropBenefit.objects.create(
twitch_id="free-benefit-123",
name="Free Benefit",
distribution_type="ITEM",
)
paid_benefit: DropBenefit = DropBenefit.objects.create(
twitch_id="paid-benefit-123",
name="Paid Benefit",
distribution_type="ITEM",
)
free_drop.benefits.add(free_benefit)
paid_drop.benefits.add(paid_benefit)
return campaign
def _create_paid_only_campaign(self) -> DropCampaign:
"""Create an active campaign where every drop requires a subscription.
Returns:
DropCampaign: The paid-only campaign fixture.
"""
campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="paid-only-campaign-123",
name="Paid Only Campaign",
game=self.game,
start_at=timezone.now(),
end_at=timezone.now() + timedelta(days=7),
operation_names=["DropCampaignDetails"],
)
drop: TimeBasedDrop = TimeBasedDrop.objects.create(
twitch_id="paid-only-drop-123",
name="Paid Only Drop",
campaign=campaign,
required_minutes_watched=0,
required_subs=1,
start_at=timezone.now(),
end_at=timezone.now() + timedelta(hours=1),
)
benefit: DropBenefit = DropBenefit.objects.create(
twitch_id="paid-only-benefit-123",
name="Paid Only Benefit",
distribution_type="ITEM",
)
drop.benefits.add(benefit)
return campaign
def test_organization_feed(self) -> None:
"""Test organization feed returns 200."""
url: str = reverse("core:organization_feed")
@ -494,6 +578,43 @@ class RSSFeedTestCase(TestCase):
assert "Past Campaign" not in content
assert "Upcoming Campaign" not in content
def test_campaign_feeds_can_hide_paid_drops(self) -> None:
"""Campaign feeds should support ?hide_paid=1 for subscription-gated drops."""
mixed_campaign: DropCampaign = self._create_mixed_paid_and_free_campaign()
paid_only_campaign: DropCampaign = self._create_paid_only_campaign()
feed_urls: list[str] = [
reverse("core:campaign_feed"),
reverse("core:game_campaign_feed", args=[self.game.twitch_id]),
reverse("core:campaign_feed_atom"),
reverse("core:game_campaign_feed_atom", args=[self.game.twitch_id]),
reverse("core:campaign_feed_discord"),
reverse("core:game_campaign_feed_discord", args=[self.game.twitch_id]),
]
for url in feed_urls:
hidden_response: _MonkeyPatchedWSGIResponse = self.client.get(
url,
{"hide_paid": "1"},
)
assert hidden_response.status_code == 200
hidden_content: str = hidden_response.content.decode("utf-8")
assert mixed_campaign.name in hidden_content
assert paid_only_campaign.name not in hidden_content
assert "Free Benefit" in hidden_content
assert "Paid Only Benefit" not in hidden_content
assert "30 minutes watched" in hidden_content
assert "1 sub required" not in hidden_content
assert "MixedChannel" in hidden_content
default_response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert default_response.status_code == 200
default_content: str = default_response.content.decode("utf-8")
assert mixed_campaign.name in default_content
assert paid_only_campaign.name in default_content
assert "Paid Only Benefit" in default_content
assert "1 sub required" in default_content
def test_campaign_feed_enclosure_helpers(self) -> None:
"""Helper methods for campaigns should respect new fields."""
feed = DropCampaignFeed()