Add support for a hide_paid query parameter to campaign feeds
All checks were successful
Deploy to Server / deploy (push) Successful in 28s
All checks were successful
Deploy to Server / deploy (push) Successful in 28s
This commit is contained in:
parent
e960b09084
commit
8229b0fe80
3 changed files with 201 additions and 29 deletions
|
|
@ -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>
|
||||
|
|
|
|||
104
twitch/feeds.py
104
twitch/feeds.py
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue