Remove /rss/organizations/<org_id>/campaigns/
This commit is contained in:
parent
44cd440a17
commit
6b936f4cf7
6 changed files with 17 additions and 302 deletions
160
twitch/feeds.py
160
twitch/feeds.py
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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", {}),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue