Remove /rss/organizations/<org_id>/campaigns/

This commit is contained in:
Joakim Hellsén 2026-03-09 05:56:57 +01:00
commit 6b936f4cf7
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
6 changed files with 17 additions and 302 deletions

View file

@ -13,12 +13,6 @@
<div>
<a href="{% url 'twitch:game_campaign_feed' game.twitch_id %}"
title="RSS feed for {{ game.display_name }} campaigns">RSS feed for {{ game.display_name }} campaigns</a>
{% if owners %}
{% for owner in owners %}
<a href="{% url 'twitch:organization_campaign_feed' owner.twitch_id %}"
title="RSS feed for {{ owner.name }} campaigns">RSS feed for {{ owner.name }} campaigns</a>
{% endfor %}
{% endif %}
<a href="{% url 'twitch:campaign_feed' %}"
title="RSS feed for all campaigns">RSS feed for all campaigns</a>
</div>

View file

@ -4,12 +4,6 @@
{% endblock title %}
{% block content %}
<h1 id="org-name">{{ organization.name }}</h1>
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
<a href="{% url 'twitch:organization_campaign_feed' organization.twitch_id %}"
style="margin-right: 1rem"
title="RSS feed for {{ organization.name }} campaigns">RSS feed for {{ organization.name }} campaigns</a>
</div>
<theader>
<h2 id="games-header">Games by {{ organization.name }}</h2>
</theader>

View file

@ -306,9 +306,8 @@ def _construct_drops_summary(
class OrganizationRSSFeed(Feed):
"""RSS feed for latest organizations."""
# Spec: https://cyber.harvard.edu/rss/rss.html
feed_type = feedgenerator.Rss201rev2Feed
title: str = "TTVDrops Organizations"
title: str = "TTVDrops Twitch Organizations"
link: str = "/organizations/"
description: str = "Latest organizations on TTVDrops"
feed_copyright: str = "Information wants to be free."
@ -378,11 +377,11 @@ class OrganizationRSSFeed(Feed):
# MARK: /rss/games/
class GameFeed(Feed):
"""RSS feed for latest games."""
"""RSS feed for newly added games."""
title: str = "Games - TTVDrops"
link: str = "/games/"
description: str = "Latest games on TTVDrops"
description: str = "Newly added games on TTVDrops"
feed_copyright: str = "Information wants to be free."
_limit: int | None = None
@ -810,159 +809,6 @@ class GameCampaignFeed(Feed):
return "image/jpeg"
# MARK: /rss/organizations/<twitch_id>/campaigns/
class OrganizationCampaignFeed(Feed):
"""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
"""Retrieve the Organization instance for the given Twitch ID.
Returns:
Organization: The corresponding Organization object.
"""
return Organization.objects.get(twitch_id=twitch_id)
def item_link(self, item: DropCampaign) -> str:
"""Return the link to the campaign detail."""
return reverse("twitch:campaign_detail", args=[item.twitch_id])
def title(self, obj: Organization) -> str:
"""Return the feed title for the organization's campaigns."""
return f"TTVDrops: {obj.name} Campaigns"
def link(self, obj: Organization) -> str:
"""Return the absolute URL to the organization detail page."""
return reverse("twitch:organization_detail", args=[obj.twitch_id])
def description(self, obj: Organization) -> str:
"""Return a description for the feed."""
return f"Latest drop campaigns for organization {obj.name}"
def items(self, obj: Organization) -> list[DropCampaign]:
"""Return the latest drop campaigns for this organization, ordered by most recent start date (default 200, or limited by ?limit query param)."""
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")
return list(_with_campaign_related(queryset)[:limit])
def item_author_name(self, item: DropCampaign) -> str:
"""Return the author name for the campaign, typically the game name."""
return item.get_feed_author_name()
def item_guid(self, item: DropCampaign) -> str:
"""Return a unique identifier for each campaign."""
return item.get_feed_guid()
def item_enclosure_url(self, item: DropCampaign) -> str:
"""Returns the URL of the campaign image for enclosure."""
return item.get_feed_enclosure_url()
def item_enclosure_length(self, item: DropCampaign) -> int: # noqa: ARG002
"""Returns the length of the enclosure."""
# TODO(TheLovinator): Track image size for proper length # noqa: TD003
return 0
def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002
"""Returns the MIME type of the enclosure."""
# TODO(TheLovinator): Determine actual MIME type if needed # noqa: TD003
return "image/jpeg"
def item_categories(self, item: DropCampaign) -> tuple[str, ...]:
"""Returns the associated game's name as a category."""
return item.get_feed_categories()
def item_updateddate(self, item: DropCampaign) -> datetime.datetime:
"""Returns the campaign's last update time."""
return item.updated_at
def item_pubdate(self, item: DropCampaign) -> datetime.datetime:
"""Returns the publication date to the feed item.
Uses start_at (when the drop starts). Fallback to added_at or now if missing.
"""
if item.start_at:
return item.start_at
if item.added_at:
return item.added_at
return timezone.now()
def item_description(self, item: DropCampaign) -> SafeText:
"""Return a description of the campaign."""
drops_data: list[dict] = []
channels: list[Channel] | None = getattr(item, "channels_ordered", None)
channel_name: str | None = channels[0].name if channels else None
drops: QuerySet[TimeBasedDrop] | None = getattr(item, "time_based_drops", None)
if drops:
drops_data = _build_drops_data(drops.all())
parts: list[SafeText] = []
image_url: str | None = getattr(item, "image_url", None)
if image_url:
item_name: str = getattr(item, "name", str(object=item))
parts.append(
format_html(
'<img src="{}" alt="{}" width="160" height="160" />',
image_url,
item_name,
),
)
desc_text: str | None = getattr(item, "description", None)
if desc_text:
parts.append(format_html("<p>{}</p>", desc_text))
# Insert start and end date info
insert_date_info(item, parts)
if drops_data:
parts.append(
format_html(
"<p>{}</p>",
_construct_drops_summary(drops_data, channel_name=channel_name),
),
)
# Only show channels if drop is not subscription only
if not getattr(item, "is_subscription_only", False) and channels is not None:
game: Game | None = getattr(item, "game", None)
parts.append(_build_channels_html(channels, game=game))
details_url: str | None = getattr(item, "details_url", None)
if details_url:
parts.append(format_html('<a href="{}">About</a>', details_url))
return SafeText("".join(str(p) for p in parts))
# MARK: /rss/reward-campaigns/
class RewardCampaignFeed(Feed):
"""RSS feed for latest reward campaigns (Quest rewards)."""

View file

@ -115,19 +115,6 @@ class RSSFeedTestCase(TestCase):
content: str = response.content.decode("utf-8")
assert "Test Game" in content
def test_organization_campaign_feed(self) -> None:
"""Test organization-specific campaign feed returns 200."""
url: str = reverse(
"twitch:organization_campaign_feed",
args=[self.org.twitch_id],
)
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200
assert response["Content-Type"] == "application/rss+xml; charset=utf-8"
# Verify the organization name is in the feed
content: str = response.content.decode("utf-8")
assert "Test Organization" in content
def test_game_campaign_feed_filters_correctly(self) -> None:
"""Test game campaign feed only shows campaigns for that game."""
# Create another game with a campaign
@ -157,42 +144,6 @@ class RSSFeedTestCase(TestCase):
# Should NOT contain other campaign
assert "Other Campaign" not in content
def test_organization_campaign_feed_filters_correctly(self) -> None:
"""Test organization campaign feed only shows campaigns for that organization."""
# Create another organization with a game and campaign
other_org = Organization.objects.create(
twitch_id="other-org-123",
name="Other Organization",
)
other_game = Game.objects.create(
twitch_id="other-game-456",
slug="other-game-2",
name="Other Game 2",
display_name="Other Game 2",
)
other_game.owners.add(other_org)
DropCampaign.objects.create(
twitch_id="other-campaign-456",
name="Other Campaign 2",
game=other_game,
start_at=timezone.now(),
end_at=timezone.now() + timedelta(days=7),
operation_names=["DropCampaignDetails"],
)
# Get feed for first organization
url: str = reverse(
"twitch:organization_campaign_feed",
args=[self.org.twitch_id],
)
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
content: str = response.content.decode("utf-8")
# Should contain first campaign
assert "Test Campaign" in content
# Should NOT contain other campaign
assert "Other Campaign 2" not in content
QueryAsserter = Callable[..., AbstractContextManager[object]]
@ -447,64 +398,6 @@ def test_game_feed_queries_bounded(
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])
# TODO(TheLovinator): 12 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: TD003
with django_assert_num_queries(12, exact=False):
response: _MonkeyPatchedWSGIResponse = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_organization_campaign_feed_queries_do_not_scale_with_items(
client: Client,
django_assert_num_queries: QueryAsserter,
) -> None:
"""Organization campaign RSS feed query count should remain bounded as item count grows."""
org: Organization = Organization.objects.create(
twitch_id="test-org-org-scale-queries",
name="Org Scale Query Org",
)
game: Game = Game.objects.create(
twitch_id="test-game-org-scale-queries",
slug="org-scale-game",
name="Org Scale Game",
display_name="Org Scale Game",
)
game.owners.add(org)
for i in range(50):
_build_campaign(game, i)
url: str = reverse("twitch:organization_campaign_feed", args=[org.twitch_id])
with django_assert_num_queries(15, 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,
@ -591,7 +484,6 @@ URL_NAMES: list[tuple[str, dict[str, str]]] = [
("twitch:game_feed", {}),
("twitch:game_campaign_feed", {"twitch_id": "test-game-123"}),
("twitch:organization_feed", {}),
("twitch:organization_campaign_feed", {"twitch_id": "test-org-123"}),
("twitch:reward_campaign_feed", {}),
]

View file

@ -6,7 +6,6 @@ from twitch import views
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameFeed
from twitch.feeds import OrganizationCampaignFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignFeed
@ -83,18 +82,27 @@ urlpatterns: list[URLPattern] = [
views.export_organizations_json,
name="export_organizations_json",
),
# RSS feeds
# /rss/campaigns/ - all active campaigns
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
# /rss/games/ - newly added games
path("rss/games/", GameFeed(), name="game_feed"),
# /rss/games/<twitch_id>/campaigns/ - active campaigns for a specific game
path(
"rss/games/<str:twitch_id>/campaigns/",
GameCampaignFeed(),
name="game_campaign_feed",
),
path("rss/organizations/", OrganizationRSSFeed(), name="organization_feed"),
# /rss/organizations/ - newly added organizations
path(
"rss/organizations/<str:twitch_id>/campaigns/",
OrganizationCampaignFeed(),
name="organization_campaign_feed",
"rss/organizations/",
OrganizationRSSFeed(),
name="organization_feed",
),
# /rss/reward-campaigns/ - all active reward campaigns
path(
"rss/reward-campaigns/",
RewardCampaignFeed(),
name="reward_campaign_feed",
),
path("rss/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed"),
]

View file

@ -41,7 +41,6 @@ from pygments.lexers.data import JsonLexer
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameFeed
from twitch.feeds import OrganizationCampaignFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignFeed
from twitch.models import Channel
@ -1857,24 +1856,6 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
if sample_game
else "",
},
{
"title": "Campaigns for an Organization",
"description": "Drop campaigns across games owned by one organization.",
"url": (
absolute(
reverse(
"twitch:organization_campaign_feed",
args=[sample_org.twitch_id],
),
)
if sample_org
else absolute("/rss/organizations/<org_id>/campaigns/")
),
"has_sample": bool(sample_org),
"example_xml": render_feed(OrganizationCampaignFeed(), sample_org.twitch_id)
if sample_org
else "",
},
]
seo_context: dict[str, Any] = _build_seo_context(