Add Silk middleware and related settings for performance monitoring

- Introduced SILK_ENABLED setting to toggle Silk middleware.
- Updated ALLOWED_HOSTS to include "testserver" when not in DEBUG mode.
- Modified urlpatterns to conditionally include Silk URLs.
- Added django-silk dependency to pyproject.toml.
- Enhanced feed queries to optimize performance and reduce N+1 issues.
- Updated tests to verify query limits for various feeds.
This commit is contained in:
Joakim Hellsén 2026-02-09 20:02:19 +01:00
commit e968f5cdea
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
9 changed files with 289 additions and 57 deletions

View file

@ -21,7 +21,7 @@ repos:
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.14 rev: v0.15.0
hooks: hooks:
- id: ruff-check - id: ruff-check
args: ["--fix", "--exit-non-zero-on-fix"] args: ["--fix", "--exit-non-zero-on-fix"]

View file

@ -39,6 +39,7 @@ def env_int(key: str, default: int) -> int:
DEBUG: bool = env_bool(key="DEBUG", default=True) DEBUG: bool = env_bool(key="DEBUG", default=True)
RUNNING_TESTS: bool = "PYTEST_VERSION" in os.environ or any("pytest" in arg for arg in sys.argv)
def get_data_dir() -> Path: def get_data_dir() -> Path:
@ -110,7 +111,7 @@ INTERNAL_IPS: list[str] = []
if DEBUG: if DEBUG:
INTERNAL_IPS = ["127.0.0.1", "localhost"] # pyright: ignore[reportConstantRedefinition] INTERNAL_IPS = ["127.0.0.1", "localhost"] # pyright: ignore[reportConstantRedefinition]
ALLOWED_HOSTS: list[str] = [".localhost", "127.0.0.1", "[::1]"] ALLOWED_HOSTS: list[str] = [".localhost", "127.0.0.1", "[::1]", "testserver"]
if not DEBUG: if not DEBUG:
ALLOWED_HOSTS = ["ttvdrops.lovinator.space"] # pyright: ignore[reportConstantRedefinition] ALLOWED_HOSTS = ["ttvdrops.lovinator.space"] # pyright: ignore[reportConstantRedefinition]
@ -141,10 +142,12 @@ INSTALLED_APPS: list[str] = [
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"twitch.apps.TwitchConfig", "twitch.apps.TwitchConfig",
*(["silk"] if not RUNNING_TESTS else []),
] ]
MIDDLEWARE: list[str] = [ MIDDLEWARE: list[str] = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
*(["silk.middleware.SilkyMiddleware"] if not RUNNING_TESTS else []),
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",

View file

@ -114,7 +114,7 @@ def test_allowed_hosts_when_debug_true(reload_settings_module: Callable[..., Mod
reloaded: ModuleType = reload_settings_module(DEBUG="1") reloaded: ModuleType = reload_settings_module(DEBUG="1")
assert reloaded.DEBUG is True assert reloaded.DEBUG is True
assert reloaded.ALLOWED_HOSTS == [".localhost", "127.0.0.1", "[::1]"] assert reloaded.ALLOWED_HOSTS == [".localhost", "127.0.0.1", "[::1]", "testserver"]
def test_debug_defaults_true_when_missing(reload_settings_module: Callable[..., ModuleType]) -> None: def test_debug_defaults_true_when_missing(reload_settings_module: Callable[..., ModuleType]) -> None:

View file

@ -11,10 +11,13 @@ if TYPE_CHECKING:
from django.urls.resolvers import URLPattern from django.urls.resolvers import URLPattern
from django.urls.resolvers import URLResolver from django.urls.resolvers import URLResolver
urlpatterns: list[URLResolver] | list[URLPattern | URLResolver] = [ # type: ignore[assignment] urlpatterns: [URLPattern | URLResolver] = [ # type: ignore[assignment]
path(route="", view=include("twitch.urls", namespace="twitch")), path(route="", view=include("twitch.urls", namespace="twitch")),
] ]
if getattr(settings, "ENABLE_SILK", False):
urlpatterns += [path("silk/", include("silk.urls", namespace="silk"))]
# Serve media in development # Serve media in development
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static( urlpatterns += static(

View file

@ -16,6 +16,7 @@ dependencies = [
"tqdm", "tqdm",
"colorama", "colorama",
"gunicorn", "gunicorn",
"django-silk>=5.4.3",
] ]
[dependency-groups] [dependency-groups]

View file

@ -7,6 +7,7 @@ from typing import Literal
from django.contrib.humanize.templatetags.humanize import naturaltime from django.contrib.humanize.templatetags.humanize import naturaltime
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.db.models import Prefetch
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.urls import reverse from django.urls import reverse
from django.utils import feedgenerator from django.utils import feedgenerator
@ -35,6 +36,20 @@ if TYPE_CHECKING:
logger: logging.Logger = logging.getLogger("ttvdrops") logger: logging.Logger = logging.getLogger("ttvdrops")
def _with_campaign_related(queryset: QuerySet[DropCampaign]) -> QuerySet[DropCampaign]:
"""Apply related-selects/prefetches needed by feed rendering to avoid N+1 queries.
Returns:
QuerySet[DropCampaign]: Queryset with related data preloaded for feed rendering.
"""
drops_prefetch: Prefetch = Prefetch(
"time_based_drops",
queryset=TimeBasedDrop.objects.prefetch_related("benefits"),
)
return queryset.select_related("game").prefetch_related("game__owners", "allow_channels", drops_prefetch)
def insert_date_info(item: Model, parts: list[SafeText]) -> None: def insert_date_info(item: Model, parts: list[SafeText]) -> None:
"""Insert start and end date information into parts list. """Insert start and end date information into parts list.
@ -460,9 +475,8 @@ class DropCampaignFeed(Feed):
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 200 drop campaigns ordered by most recent start date."""
return list( queryset: QuerySet[DropCampaign] = DropCampaign.objects.order_by("-start_at")
DropCampaign.objects.select_related("game").order_by("-start_at")[:200], return list(_with_campaign_related(queryset)[:200])
)
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)."""
@ -477,7 +491,7 @@ class DropCampaignFeed(Feed):
drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None) drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None)
if drops: if drops:
drops_data = _build_drops_data(drops.select_related().prefetch_related("benefits").all()) drops_data = _build_drops_data(drops.all())
parts: list[SafeText] = [] parts: list[SafeText] = []
@ -610,9 +624,8 @@ class GameCampaignFeed(Feed):
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 200 drop campaigns for this game, ordered by most recent start date."""
return list( queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter(game=obj).order_by("-start_at")
DropCampaign.objects.filter(game=obj).select_related("game").order_by("-start_at")[:200], return list(_with_campaign_related(queryset)[:200])
)
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)."""
@ -625,7 +638,7 @@ class GameCampaignFeed(Feed):
drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None) drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None)
if drops: if drops:
drops_data = _build_drops_data(drops.select_related().prefetch_related("benefits").all()) drops_data = _build_drops_data(drops.all())
parts: list[SafeText] = [] parts: list[SafeText] = []
@ -751,9 +764,8 @@ class OrganizationCampaignFeed(Feed):
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 200 drop campaigns for this organization, ordered by most recent start date."""
return list( queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter(game__owners=obj).order_by("-start_at")
DropCampaign.objects.filter(game__owners=obj).select_related("game").order_by("-start_at")[:200], return list(_with_campaign_related(queryset)[:200])
)
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."""
@ -818,7 +830,7 @@ class OrganizationCampaignFeed(Feed):
drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None) drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None)
if drops: if drops:
drops_data = _build_drops_data(drops.select_related().prefetch_related("benefits").all()) drops_data = _build_drops_data(drops.all())
parts: list[SafeText] = [] parts: list[SafeText] = []

View file

@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractContextManager
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -188,6 +190,237 @@ class RSSFeedTestCase(TestCase):
assert "Other Campaign 2" not in content assert "Other Campaign 2" not in content
QueryAsserter = Callable[..., AbstractContextManager[object]]
def _build_campaign(game: Game, idx: int) -> DropCampaign:
"""Create a campaign with a channel, drop, and benefit for query counting.
Returns:
DropCampaign: Newly created campaign instance.
"""
campaign: DropCampaign = DropCampaign.objects.create(
twitch_id=f"test-campaign-{idx}",
name=f"Test Campaign {idx}",
game=game,
start_at=timezone.now(),
end_at=timezone.now() + timedelta(days=7),
operation_names=["DropCampaignDetails"],
)
channel: Channel = Channel.objects.create(
twitch_id=f"test-channel-{idx}",
name=f"testchannel{idx}",
display_name=f"TestChannel{idx}",
)
campaign.allow_channels.add(channel)
drop: TimeBasedDrop = TimeBasedDrop.objects.create(
twitch_id=f"drop-{idx}",
name=f"Drop {idx}",
campaign=campaign,
required_minutes_watched=30,
start_at=timezone.now(),
end_at=timezone.now() + timedelta(hours=1),
)
benefit: DropBenefit = DropBenefit.objects.create(
twitch_id=f"benefit-{idx}",
name=f"Benefit {idx}",
distribution_type="ITEM",
)
drop.benefits.add(benefit)
return campaign
def _build_reward_campaign(game: Game, idx: int) -> RewardCampaign:
"""Create a reward campaign for query counting.
Returns:
RewardCampaign: Newly created reward campaign instance.
"""
return RewardCampaign.objects.create(
twitch_id=f"test-reward-{idx}",
name=f"Test Reward {idx}",
brand="Test Brand",
starts_at=timezone.now(),
ends_at=timezone.now() + timedelta(days=14),
status="ACTIVE",
summary="Test reward summary",
instructions="Watch and complete objectives",
external_url="https://example.com/reward",
about_url="https://example.com/about",
is_sitewide=False,
game=game,
)
@pytest.mark.django_db
def test_campaign_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
"""Campaign feed should stay within a small, fixed query budget."""
org: Organization = Organization.objects.create(
twitch_id="test-org-queries",
name="Query Org",
)
game: Game = Game.objects.create(
twitch_id="test-game-queries",
slug="query-game",
name="Query Game",
display_name="Query Game",
)
game.owners.add(org)
for i in range(3):
_build_campaign(game, i)
url: str = reverse("twitch:campaign_feed")
with django_assert_num_queries(20, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_game_campaign_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
"""Game campaign feed should not issue excess queries when rendering multiple campaigns."""
org: Organization = Organization.objects.create(
twitch_id="test-org-game-queries",
name="Query Org Game",
)
game: Game = Game.objects.create(
twitch_id="test-game-campaign-queries",
slug="query-game-campaign",
name="Query Game Campaign",
display_name="Query Game Campaign",
)
game.owners.add(org)
for i in range(3):
_build_campaign(game, i)
url: str = reverse("twitch:game_campaign_feed", args=[game.twitch_id])
with django_assert_num_queries(22, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_organization_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
"""Organization RSS feed should stay within a modest query budget."""
for i in range(5):
Organization.objects.create(
twitch_id=f"org-feed-{i}",
name=f"Org Feed {i}",
)
url: str = reverse("twitch:organization_feed")
with django_assert_num_queries(6, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_game_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
"""Game RSS feed should stay within a modest query budget with multiple games."""
org: Organization = Organization.objects.create(
twitch_id="game-feed-org",
name="Game Feed Org",
)
for i in range(3):
game: Game = Game.objects.create(
twitch_id=f"game-feed-{i}",
slug=f"game-feed-{i}",
name=f"Game Feed {i}",
display_name=f"Game Feed {i}",
)
game.owners.add(org)
url: str = reverse("twitch:game_feed")
with django_assert_num_queries(10, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_organization_campaign_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
"""Organization campaign feed should not regress in query count."""
org: Organization = Organization.objects.create(
twitch_id="org-campaign-feed",
name="Org Campaign Feed",
)
game: Game = Game.objects.create(
twitch_id="org-campaign-game",
slug="org-campaign-game",
name="Org Campaign Game",
display_name="Org Campaign Game",
)
game.owners.add(org)
for i in range(3):
_build_campaign(game, i)
url: str = reverse("twitch:organization_campaign_feed", args=[org.twitch_id])
with django_assert_num_queries(22, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_reward_campaign_feed_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
"""Reward campaign feed should stay within a modest query budget."""
org: Organization = Organization.objects.create(
twitch_id="reward-feed-org",
name="Reward Feed Org",
)
game: Game = Game.objects.create(
twitch_id="reward-feed-game",
slug="reward-feed-game",
name="Reward Feed Game",
display_name="Reward Feed Game",
)
game.owners.add(org)
for i in range(3):
_build_reward_campaign(game, i)
url: str = reverse("twitch:reward_campaign_feed")
with django_assert_num_queries(8, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_docs_rss_queries_bounded(client: Client, django_assert_num_queries: QueryAsserter) -> None:
"""Docs RSS page should stay within a reasonable query budget."""
org: Organization = Organization.objects.create(
twitch_id="docs-org",
name="Docs Org",
)
game: Game = Game.objects.create(
twitch_id="docs-game",
slug="docs-game",
name="Docs Game",
display_name="Docs Game",
)
game.owners.add(org)
for i in range(2):
_build_campaign(game, i)
_build_reward_campaign(game, i)
url: str = reverse("twitch:docs_rss")
with django_assert_num_queries(60, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
URL_NAMES: list[tuple[str, dict[str, str]]] = [ URL_NAMES: list[tuple[str, dict[str, str]]] = [
("twitch:dashboard", {}), ("twitch:dashboard", {}),
("twitch:badge_list", {}), ("twitch:badge_list", {}),
@ -213,12 +446,6 @@ URL_NAMES: list[tuple[str, dict[str, str]]] = [
("twitch:organization_feed", {}), ("twitch:organization_feed", {}),
("twitch:organization_campaign_feed", {"twitch_id": "test-org-123"}), ("twitch:organization_campaign_feed", {"twitch_id": "test-org-123"}),
("twitch:reward_campaign_feed", {}), ("twitch:reward_campaign_feed", {}),
("twitch:campaign_feed_v1", {}),
("twitch:game_feed_v1", {}),
("twitch:game_campaign_feed_v1", {"twitch_id": "test-game-123"}),
("twitch:organization_feed_v1", {}),
("twitch:organization_campaign_feed_v1", {"twitch_id": "test-org-123"}),
("twitch:reward_campaign_feed_v1", {}),
] ]

View file

@ -17,31 +17,6 @@ if TYPE_CHECKING:
app_name = "twitch" app_name = "twitch"
# We have /rss/ that is always the latest, and versioned version to not break users regexes.
rss_feeds_latest: list[URLPattern] = [
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
path("rss/games/", GameFeed(), name="game_feed"),
path("rss/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"),
path("rss/organizations/", OrganizationRSSFeed(), name="organization_feed"),
path("rss/organizations/<str:twitch_id>/campaigns/", OrganizationCampaignFeed(), name="organization_campaign_feed"),
path("rss/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed"),
]
v1_rss_feeds: list[URLPattern] = [
path("rss/v1/campaigns/", DropCampaignFeed(), name="campaign_feed_v1"),
path("rss/v1/games/", GameFeed(), name="game_feed_v1"),
path("rss/v1/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed_v1"),
path("rss/v1/organizations/", OrganizationRSSFeed(), name="organization_feed_v1"),
path(
"rss/v1/organizations/<str:twitch_id>/campaigns/",
OrganizationCampaignFeed(),
name="organization_campaign_feed_v1",
),
path("rss/v1/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed_v1"),
]
urlpatterns: list[URLPattern] = [ urlpatterns: list[URLPattern] = [
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
@ -62,6 +37,10 @@ urlpatterns: list[URLPattern] = [
path("reward-campaigns/", views.reward_campaign_list_view, name="reward_campaign_list"), path("reward-campaigns/", views.reward_campaign_list_view, name="reward_campaign_list"),
path("reward-campaigns/<str:twitch_id>/", views.reward_campaign_detail_view, name="reward_campaign_detail"), path("reward-campaigns/<str:twitch_id>/", views.reward_campaign_detail_view, name="reward_campaign_detail"),
path("search/", views.search_view, name="search"), path("search/", views.search_view, name="search"),
*rss_feeds_latest, path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
*v1_rss_feeds, path("rss/games/", GameFeed(), name="game_feed"),
path("rss/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"),
path("rss/organizations/", OrganizationRSSFeed(), name="organization_feed"),
path("rss/organizations/<str:twitch_id>/campaigns/", OrganizationCampaignFeed(), name="organization_campaign_feed"),
path("rss/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed"),
] ]

View file

@ -1017,6 +1017,13 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
Rendered HTML response with list of RSS feeds. Rendered HTML response with list of RSS feeds.
""" """
def absolute(path: str) -> str:
try:
return request.build_absolute_uri(path)
except Exception: # pragma: no cover - defensive logging for docs only
logger.exception("Failed to build absolute URL for %s", path)
return path
def _pretty_example(xml_str: str, max_items: int = 1) -> str: def _pretty_example(xml_str: str, max_items: int = 1) -> str:
try: try:
trimmed = xml_str.strip() trimmed = xml_str.strip()
@ -1045,25 +1052,25 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
{ {
"title": "All Organizations", "title": "All Organizations",
"description": "Latest organizations added to TTVDrops", "description": "Latest organizations added to TTVDrops",
"url": reverse("twitch:organization_feed"), "url": absolute(reverse("twitch:organization_feed")),
"example_xml": render_feed(OrganizationRSSFeed()), "example_xml": render_feed(OrganizationRSSFeed()),
}, },
{ {
"title": "All Games", "title": "All Games",
"description": "Latest games added to TTVDrops", "description": "Latest games added to TTVDrops",
"url": reverse("twitch:game_feed"), "url": absolute(reverse("twitch:game_feed")),
"example_xml": render_feed(GameFeed()), "example_xml": render_feed(GameFeed()),
}, },
{ {
"title": "All Drop Campaigns", "title": "All Drop Campaigns",
"description": "Latest drop campaigns across all games", "description": "Latest drop campaigns across all games",
"url": reverse("twitch:campaign_feed"), "url": absolute(reverse("twitch:campaign_feed")),
"example_xml": render_feed(DropCampaignFeed()), "example_xml": render_feed(DropCampaignFeed()),
}, },
{ {
"title": "All Reward Campaigns", "title": "All Reward Campaigns",
"description": "Latest reward campaigns (Quest rewards) on Twitch", "description": "Latest reward campaigns (Quest rewards) on Twitch",
"url": reverse("twitch:reward_campaign_feed"), "url": absolute(reverse("twitch:reward_campaign_feed")),
"example_xml": render_feed(RewardCampaignFeed()), "example_xml": render_feed(RewardCampaignFeed()),
}, },
] ]
@ -1076,9 +1083,9 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
"title": "Campaigns for a Single Game", "title": "Campaigns for a Single Game",
"description": "Latest drop campaigns for one game.", "description": "Latest drop campaigns for one game.",
"url": ( "url": (
reverse("twitch:game_campaign_feed", args=[sample_game.twitch_id]) absolute(reverse("twitch:game_campaign_feed", args=[sample_game.twitch_id]))
if sample_game if sample_game
else "/rss/games/<game_id>/campaigns/" else absolute("/rss/games/<game_id>/campaigns/")
), ),
"has_sample": bool(sample_game), "has_sample": bool(sample_game),
"example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id) if sample_game else "", "example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id) if sample_game else "",
@ -1087,9 +1094,9 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
"title": "Campaigns for an Organization", "title": "Campaigns for an Organization",
"description": "Drop campaigns across games owned by one organization.", "description": "Drop campaigns across games owned by one organization.",
"url": ( "url": (
reverse("twitch:organization_campaign_feed", args=[sample_org.twitch_id]) absolute(reverse("twitch:organization_campaign_feed", args=[sample_org.twitch_id]))
if sample_org if sample_org
else "/rss/organizations/<org_id>/campaigns/" else absolute("/rss/organizations/<org_id>/campaigns/")
), ),
"has_sample": bool(sample_org), "has_sample": bool(sample_org),
"example_xml": render_feed(OrganizationCampaignFeed(), sample_org.twitch_id) if sample_org else "", "example_xml": render_feed(OrganizationCampaignFeed(), sample_org.twitch_id) if sample_org else "",