Refactor RSS feed documentation and optimize query limits in views

This commit is contained in:
Joakim Hellsén 2026-02-09 23:23:36 +01:00
commit 0a0345f217
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
4 changed files with 177 additions and 44 deletions

View file

@ -5,35 +5,32 @@
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<main> <main>
<h1 id="page-title">RSS Feeds Documentation</h1> <h1>RSS Feeds Documentation</h1>
<p>This page lists all available RSS feeds for TTVDrops.</p> <p>This page lists all available RSS feeds for TTVDrops.</p>
<section> <section>
<h2 id="available-feeds-header">Global RSS Feeds</h2> <h2>Global RSS Feeds</h2>
<p>These feeds contain all items across the entire site:</p> <p>These feeds contain all items across the entire site:</p>
<ul id="feeds-list"> <ul>
{% for feed in feeds %} {% for feed in feeds %}
<li id="feed-{{ forloop.counter }}"> <li>
<h3>{{ feed.title }}</h3> <h3>{{ feed.title }}</h3>
<p>{{ feed.description }}</p> <p>{{ feed.description }}</p>
<p> <p>
<a href="{{ feed.url }}">Subscribe to {{ feed.title }} RSS Feed</a> <a href="{{ feed.url }}">Subscribe to {{ feed.title }} RSS Feed</a>
</p> </p>
<details> <pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre>
<summary>Example XML</summary>
<pre><code class="language-xml">{{ feed.example_xml|escape }}</code></pre>
</details>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</section> </section>
<section style="margin-top: 2rem;"> <section>
<h2 id="filtered-feeds-header">Filtered RSS Feeds</h2> <h2>Filtered RSS Feeds</h2>
<p> <p>
You can subscribe to RSS feeds scoped to a specific game or organization. When available, links below point to live examples; otherwise use the endpoint template. You can subscribe to RSS feeds scoped to a specific game or organization. When available, links below point to live examples; otherwise use the endpoint template.
</p> </p>
<ul id="filtered-feeds-list"> <ul>
{% for feed in filtered_feeds %} {% for feed in filtered_feeds %}
<li id="filtered-feed-{{ forloop.counter }}"> <li>
<h3>{{ feed.title }}</h3> <h3>{{ feed.title }}</h3>
<p>{{ feed.description }}</p> <p>{{ feed.description }}</p>
<p> <p>
@ -44,19 +41,13 @@
<a href="{{ feed.url }}">View a live example</a> <a href="{{ feed.url }}">View a live example</a>
</p> </p>
{% endif %} {% endif %}
<details> <pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre>
<summary>Example XML</summary>
<pre><code class="language-xml">{{ feed.example_xml|escape }}</code></pre>
</details>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<p>
Versioned paths under <code>/rss/v1/</code> are available and return the same XML structure.
</p>
</section> </section>
<section style="margin-top: 2rem;"> <section>
<h2 id="usage-header">How to Use RSS Feeds</h2> <h2>How to Use RSS Feeds</h2>
<p> <p>
RSS feeds allow you to stay updated with new content. You can use any RSS reader application to subscribe to these feeds. RSS feeds allow you to stay updated with new content. You can use any RSS reader application to subscribe to these feeds.
</p> </p>

View file

@ -32,6 +32,7 @@ if TYPE_CHECKING:
from django.db.models import Model from django.db.models import Model
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from django.http import HttpResponse
logger: logging.Logger = logging.getLogger("ttvdrops") logger: logging.Logger = logging.getLogger("ttvdrops")
@ -308,10 +309,30 @@ class OrganizationRSSFeed(Feed):
link: str = "/organizations/" link: str = "/organizations/"
description: str = "Latest organizations on TTVDrops" description: str = "Latest organizations on TTVDrops"
feed_copyright: str = "Information wants to be free." feed_copyright: str = "Information wants to be free."
_limit: int | None = None
def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse:
"""Override to capture limit parameter from request.
Args:
request (HttpRequest): The incoming HTTP request, potentially containing a 'limit' query parameter.
*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
return super().__call__(request, *args, **kwargs)
def items(self) -> QuerySet[Organization, Organization]: def items(self) -> QuerySet[Organization, Organization]:
"""Return the latest 200 organizations.""" """Return the latest organizations (default 200, or limited by ?limit query param)."""
return Organization.objects.order_by("-added_at")[:200] limit: int = self._limit if self._limit is not None else 200
return Organization.objects.order_by("-added_at")[:limit]
def item_title(self, item: Organization) -> SafeText: def item_title(self, item: Organization) -> SafeText:
"""Return the organization name as the item title.""" """Return the organization name as the item title."""
@ -355,10 +376,30 @@ class GameFeed(Feed):
link: str = "/games/" link: str = "/games/"
description: str = "Latest games on TTVDrops" description: str = "Latest games on TTVDrops"
feed_copyright: str = "Information wants to be free." feed_copyright: str = "Information wants to be free."
_limit: int | None = None
def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse:
"""Override to capture limit parameter from request.
Args:
request (HttpRequest): The incoming HTTP request, potentially containing a 'limit' query parameter.
*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
return super().__call__(request, *args, **kwargs)
def items(self) -> list[Game]: def items(self) -> list[Game]:
"""Return the latest 200 games.""" """Return the latest games (default 200, or limited by ?limit query param)."""
return list(Game.objects.order_by("-added_at")[:200]) limit: int = self._limit if self._limit is not None else 200
return list(Game.objects.order_by("-added_at")[:limit])
def item_title(self, item: Model) -> SafeText: def item_title(self, item: Model) -> SafeText:
"""Return the game name as the item title (SafeText for RSS).""" """Return the game name as the item title (SafeText for RSS)."""
@ -472,11 +513,31 @@ class DropCampaignFeed(Feed):
description: str = "Latest Twitch drop campaigns on TTVDrops" description: str = "Latest Twitch drop campaigns on TTVDrops"
feed_url: str = "/rss/campaigns/" feed_url: str = "/rss/campaigns/"
feed_copyright: str = "Information wants to be free." feed_copyright: str = "Information wants to be free."
_limit: int | None = None
def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse:
"""Override to capture limit parameter from request.
Args:
request (HttpRequest): The incoming HTTP request, potentially containing a 'limit' query parameter.
*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
return super().__call__(request, *args, **kwargs)
def items(self) -> list[DropCampaign]: def items(self) -> list[DropCampaign]:
"""Return the latest 200 drop campaigns ordered by most recent start date.""" """Return the latest drop campaigns ordered by most recent start date (default 200, or limited by ?limit query param).""" # noqa: E501
limit: int = self._limit if self._limit is not None else 200
queryset: QuerySet[DropCampaign] = DropCampaign.objects.order_by("-start_at") queryset: QuerySet[DropCampaign] = DropCampaign.objects.order_by("-start_at")
return list(_with_campaign_related(queryset)[:200]) return list(_with_campaign_related(queryset)[:limit])
def item_title(self, item: Model) -> SafeText: def item_title(self, item: Model) -> SafeText:
"""Return the campaign name as the item title (SafeText for RSS).""" """Return the campaign name as the item title (SafeText for RSS)."""
@ -593,6 +654,25 @@ class GameCampaignFeed(Feed):
"""RSS feed for the latest drop campaigns of a specific game.""" """RSS feed for the latest drop campaigns of a specific game."""
feed_copyright: str = "Information wants to be free." feed_copyright: str = "Information wants to be free."
_limit: int | None = None
def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse:
"""Override to capture limit parameter from request.
Args:
request (HttpRequest): The incoming HTTP request, potentially containing a 'limit' query parameter
*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
return super().__call__(request, *args, **kwargs)
def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002 def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002
"""Retrieve the Game instance for the given Twitch ID. """Retrieve the Game instance for the given Twitch ID.
@ -623,9 +703,10 @@ class GameCampaignFeed(Feed):
return reverse("twitch:game_campaign_feed", args=[obj.twitch_id]) return reverse("twitch:game_campaign_feed", args=[obj.twitch_id])
def items(self, obj: Game) -> list[DropCampaign]: def items(self, obj: Game) -> list[DropCampaign]:
"""Return the latest 200 drop campaigns for this game, ordered by most recent start date.""" """Return the latest drop campaigns for this game, ordered by most recent start date (default 200, or limited by ?limit query param).""" # noqa: E501
limit: int = self._limit if self._limit is not None else 200
queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter(game=obj).order_by("-start_at") queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter(game=obj).order_by("-start_at")
return list(_with_campaign_related(queryset)[:200]) return list(_with_campaign_related(queryset)[:limit])
def item_title(self, item: Model) -> SafeText: def item_title(self, item: Model) -> SafeText:
"""Return the campaign name as the item title (SafeText for RSS).""" """Return the campaign name as the item title (SafeText for RSS)."""
@ -738,6 +819,26 @@ class GameCampaignFeed(Feed):
class OrganizationCampaignFeed(Feed): class OrganizationCampaignFeed(Feed):
"""RSS feed for campaigns of a specific organization.""" """RSS feed for campaigns of a specific organization."""
_limit: int | None = None
def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse:
"""Override to capture limit parameter from request.
Args:
request (HttpRequest): The incoming HTTP request, potentially containing a 'limit' query parameter
*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
return super().__call__(request, *args, **kwargs)
def get_object(self, request: HttpRequest, twitch_id: str) -> Organization: # noqa: ARG002 def get_object(self, request: HttpRequest, twitch_id: str) -> Organization: # noqa: ARG002
"""Retrieve the Organization instance for the given Twitch ID. """Retrieve the Organization instance for the given Twitch ID.
@ -763,9 +864,10 @@ class OrganizationCampaignFeed(Feed):
return f"Latest drop campaigns for organization {obj.name}" return f"Latest drop campaigns for organization {obj.name}"
def items(self, obj: Organization) -> list[DropCampaign]: def items(self, obj: Organization) -> list[DropCampaign]:
"""Return the latest 200 drop campaigns for this organization, ordered by most recent start date.""" """Return the latest drop campaigns for this organization, ordered by most recent start date (default 200, or limited by ?limit query param).""" # noqa: E501
limit: int = self._limit if self._limit is not None else 200
queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter(game__owners=obj).order_by("-start_at") queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter(game__owners=obj).order_by("-start_at")
return list(_with_campaign_related(queryset)[:200]) return list(_with_campaign_related(queryset)[:limit])
def item_author_name(self, item: DropCampaign) -> str: def item_author_name(self, item: DropCampaign) -> str:
"""Return the author name for the campaign, typically the game name.""" """Return the author name for the campaign, typically the game name."""
@ -874,11 +976,31 @@ class RewardCampaignFeed(Feed):
description: str = "Latest Twitch reward campaigns (Quest rewards) on TTVDrops" description: str = "Latest Twitch reward campaigns (Quest rewards) on TTVDrops"
feed_url: str = "/rss/reward-campaigns/" feed_url: str = "/rss/reward-campaigns/"
feed_copyright: str = "Information wants to be free." feed_copyright: str = "Information wants to be free."
_limit: int | None = None
def __call__(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse:
"""Override to capture limit parameter from request.
Args:
request (HttpRequest): The incoming HTTP request, potentially containing a 'limit' query parameter.
*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
return super().__call__(request, *args, **kwargs)
def items(self) -> list[RewardCampaign]: def items(self) -> list[RewardCampaign]:
"""Return the latest 200 reward campaigns.""" """Return the latest reward campaigns (default 200, or limited by ?limit query param)."""
limit: int = self._limit if self._limit is not None else 200
return list( return list(
RewardCampaign.objects.select_related("game").order_by("-added_at")[:200], RewardCampaign.objects.select_related("game").order_by("-added_at")[:limit],
) )
def item_title(self, item: Model) -> SafeText: def item_title(self, item: Model) -> SafeText:

View file

@ -274,7 +274,8 @@ def test_campaign_feed_queries_bounded(client: Client, django_assert_num_queries
_build_campaign(game, i) _build_campaign(game, i)
url: str = reverse("twitch:campaign_feed") url: str = reverse("twitch:campaign_feed")
with django_assert_num_queries(20, exact=False): # TODO(TheLovinator): 14 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: E501, TD003
with django_assert_num_queries(14, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -299,7 +300,9 @@ def test_game_campaign_feed_queries_bounded(client: Client, django_assert_num_qu
_build_campaign(game, i) _build_campaign(game, i)
url: str = reverse("twitch:game_campaign_feed", args=[game.twitch_id]) url: str = reverse("twitch:game_campaign_feed", args=[game.twitch_id])
with django_assert_num_queries(22, exact=False):
# TODO(TheLovinator): 15 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: E501, TD003
with django_assert_num_queries(15, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -315,7 +318,7 @@ def test_organization_feed_queries_bounded(client: Client, django_assert_num_que
) )
url: str = reverse("twitch:organization_feed") url: str = reverse("twitch:organization_feed")
with django_assert_num_queries(6, exact=False): with django_assert_num_queries(1, exact=True):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -339,7 +342,7 @@ def test_game_feed_queries_bounded(client: Client, django_assert_num_queries: Qu
game.owners.add(org) game.owners.add(org)
url: str = reverse("twitch:game_feed") url: str = reverse("twitch:game_feed")
with django_assert_num_queries(10, exact=False): with django_assert_num_queries(1, exact=True):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -364,7 +367,8 @@ def test_organization_campaign_feed_queries_bounded(client: Client, django_asser
_build_campaign(game, i) _build_campaign(game, i)
url: str = reverse("twitch:organization_campaign_feed", args=[org.twitch_id]) url: str = reverse("twitch:organization_campaign_feed", args=[org.twitch_id])
with django_assert_num_queries(22, exact=False): # TODO(TheLovinator): 15 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: E501, TD003
with django_assert_num_queries(15, exact=True):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -389,7 +393,7 @@ def test_reward_campaign_feed_queries_bounded(client: Client, django_assert_num_
_build_reward_campaign(game, i) _build_reward_campaign(game, i)
url: str = reverse("twitch:reward_campaign_feed") url: str = reverse("twitch:reward_campaign_feed")
with django_assert_num_queries(8, exact=False): with django_assert_num_queries(1, exact=True):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -397,7 +401,11 @@ def test_reward_campaign_feed_queries_bounded(client: Client, django_assert_num_
@pytest.mark.django_db @pytest.mark.django_db
def test_docs_rss_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None: def test_docs_rss_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
"""Docs RSS page should stay within a reasonable query budget.""" """Docs RSS page should stay within a reasonable query budget.
With limit=1 for documentation examples, we should have dramatically fewer queries
than if we were rendering 200+ items per feed.
"""
org: Organization = Organization.objects.create( org: Organization = Organization.objects.create(
twitch_id="docs-org", twitch_id="docs-org",
name="Docs Org", name="Docs Org",
@ -415,7 +423,9 @@ def test_docs_rss_queries_bounded(client: Client, django_assert_num_queries: Que
_build_reward_campaign(game, i) _build_reward_campaign(game, i)
url: str = reverse("twitch:docs_rss") url: str = reverse("twitch:docs_rss")
with django_assert_num_queries(60, exact=False):
# TODO(TheLovinator): 31 queries is still quite high for a feed - we should be able to optimize this further, but this is a good starting point to prevent regressions for now. # noqa: E501, TD003
with django_assert_num_queries(31, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200 assert response.status_code == 200

View file

@ -5,6 +5,7 @@ import json
import logging import logging
from collections import OrderedDict from collections import OrderedDict
from collections import defaultdict from collections import defaultdict
from copy import copy
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
from typing import Literal from typing import Literal
@ -52,6 +53,7 @@ from twitch.models import TimeBasedDrop
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from debug_toolbar.utils import QueryDict
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
logger: logging.Logger = logging.getLogger("ttvdrops.views") logger: logging.Logger = logging.getLogger("ttvdrops.views")
@ -1042,7 +1044,13 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
def render_feed(feed_view: Callable[..., HttpResponse], *args: object) -> str: def render_feed(feed_view: Callable[..., HttpResponse], *args: object) -> str:
try: try:
response: HttpResponse = feed_view(request, *args) limited_request: HttpRequest = copy(request)
# Add limit=1 to GET parameters
get_data: QueryDict = request.GET.copy()
get_data["limit"] = "1"
limited_request.GET = get_data # pyright: ignore[reportAttributeAccessIssue]
response: HttpResponse = feed_view(limited_request, *args)
return _pretty_example(response.content.decode("utf-8")) return _pretty_example(response.content.decode("utf-8"))
except Exception: # pragma: no cover - defensive logging for docs only except Exception: # pragma: no cover - defensive logging for docs only
logger.exception("Failed to render %s for RSS docs", feed_view.__class__.__name__) logger.exception("Failed to render %s for RSS docs", feed_view.__class__.__name__)
@ -1075,8 +1083,10 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
}, },
] ]
sample_game: Game | None = Game.objects.first() sample_game: Game | None = Game.objects.order_by("-added_at").first()
sample_org: Organization | None = Organization.objects.first() sample_org: Organization | None = Organization.objects.order_by("-added_at").first()
if sample_org is None and sample_game is not None:
sample_org = sample_game.owners.order_by("-pk").first()
filtered_feeds: list[dict[str, str | bool]] = [ filtered_feeds: list[dict[str, str | bool]] = [
{ {