diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index 626c9d9..bd72f06 100644 --- a/templates/twitch/campaign_detail.html +++ b/templates/twitch/campaign_detail.html @@ -17,6 +17,19 @@ {{ owner.name }}

{% endif %} + +
+ {% if campaign.game %} + RSS feed for {{ campaign.game.display_name }} campaigns + {% endif %} + {% if owner %} + RSS feed for {{ owner.name }} campaigns + {% endif %} +
{% if campaign.image_best_url or campaign.image_url %}

Drop Campaigns

Browse all available drop campaigns

+ +
+ RSS feed for all campaigns +
+ +
+ RSS feed for campaigns +
{% if campaigns_by_org_game %} {% for org_id, org_data in campaigns_by_org_game.items %}
diff --git a/templates/twitch/docs_rss.html b/templates/twitch/docs_rss.html index d6a8ab7..df336ab 100644 --- a/templates/twitch/docs_rss.html +++ b/templates/twitch/docs_rss.html @@ -8,7 +8,8 @@

RSS Feeds Documentation

This page lists all available RSS feeds for TTVDrops.

-

Available RSS Feeds

+

Global RSS Feeds

+

These feeds contain all items across the entire site:

    {% for feed in feeds %}
  • @@ -21,5 +22,40 @@ {% endfor %}
+
+

Filtered RSS Feeds

+

+ You can also subscribe to RSS feeds for specific games or organizations. These feeds are available on each game or organization detail page. +

+

Game-Specific Campaign Feeds

+

+ Subscribe to campaigns for a specific game using: /rss/games/<game_id>/campaigns/ +

+ {% if sample_game %} +

+ Example: {{ sample_game.display_name }} Campaigns RSS Feed +

+ {% endif %} +

Organization-Specific Campaign Feeds

+

+ Subscribe to campaigns for a specific organization using: /rss/organizations/<org_id>/campaigns/ +

+ {% if sample_org %} +

+ Example: {{ sample_org.name }} Campaigns RSS Feed +

+ {% endif %} +
+
+

How to Use RSS Feeds

+

+ RSS feeds allow you to stay updated with new content. You can use any RSS reader application to subscribe to these feeds. +

+
    +
  • Copy the feed URL
  • +
  • Paste it into your favorite RSS reader (Feedly, Inoreader, NetNewsWire, etc.)
  • +
  • Get automatic updates when new content is added
  • +
+
{% endblock content %} diff --git a/templates/twitch/game_detail.html b/templates/twitch/game_detail.html index cb86493..387f8d3 100644 --- a/templates/twitch/game_detail.html +++ b/templates/twitch/game_detail.html @@ -8,6 +8,20 @@ {{ game.display_name }} {% if game.display_name != game.name and game.name %}({{ game.name }}){% endif %} + +
+ RSS feed for {{ game.display_name }} campaigns + {% if owner %} + RSS feed for {{ owner.name }} campaigns + {% endif %} + RSS feed for all campaigns +
{% if game.box_art %} List View

+ +
+ RSS feed for all games +
{% if games_by_org %}
diff --git a/templates/twitch/games_list.html b/templates/twitch/games_list.html index dd261f1..addf3de 100644 --- a/templates/twitch/games_list.html +++ b/templates/twitch/games_list.html @@ -8,6 +8,12 @@

Grid View

+ +
+ RSS feed for all games +
{% if games_by_org %} {% for organization, games in games_by_org.items %}

{{ organization.name }}

diff --git a/templates/twitch/org_list.html b/templates/twitch/org_list.html index e0bf28c..a8be4ed 100644 --- a/templates/twitch/org_list.html +++ b/templates/twitch/org_list.html @@ -4,6 +4,12 @@ {% endblock title %} {% block content %}

Organizations

+ +
+ RSS feed for organizations +
{% if orgs %}
    {% for organization in orgs %} diff --git a/templates/twitch/organization_detail.html b/templates/twitch/organization_detail.html index 7841bc3..6b8a511 100644 --- a/templates/twitch/organization_detail.html +++ b/templates/twitch/organization_detail.html @@ -4,36 +4,26 @@ {% endblock title %} {% block content %}

    {{ organization.name }}

    - {% if user.is_authenticated %} - - {% csrf_token %} -
    - - -
    -
    - - -
    - - - {% else %} -

    Login to subscribe!

    - {% endif %} -
      - {% for game in games %} -
    • - {{ game }} -
    • - {% endfor %} -
    + +
    + RSS feed for {{ organization.name }} campaigns +
    + +

    Games by {{ organization.name }}

    +
    + + + {% for game in games %} + + + + {% endfor %} + +
    + {{ game }} +
    +
    {{ org_data|safe }} {% endblock content %} diff --git a/twitch/feeds.py b/twitch/feeds.py index c69471b..e52fa7e 100644 --- a/twitch/feeds.py +++ b/twitch/feeds.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: from django.db.models import Model from django.db.models import QuerySet + from django.http import HttpRequest # MARK: /rss/organizations/ @@ -239,3 +240,73 @@ class DropCampaignFeed(Feed): def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002 """Returns the MIME type of the enclosure.""" return "image/jpeg" + + +# MARK: /rss/games//campaigns/ +class GameCampaignFeed(DropCampaignFeed): + """RSS feed for campaigns of a specific game.""" + + def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002 + """Get the game object for this feed. + + Args: + request: The HTTP request. + twitch_id: The Twitch ID of the game. + + Returns: + Game: The game object. + """ + return Game.objects.get(twitch_id=twitch_id) + + def title(self, obj: Game) -> str: + """Return the feed title.""" + return f"TTVDrops: {obj.display_name} Campaigns" + + def link(self, obj: Game) -> str: + """Return the link to the game detail.""" + return reverse("twitch:game_detail", args=[obj.twitch_id]) + + def description(self, obj: Game) -> str: + """Return the feed description.""" + return f"Latest drop campaigns for {obj.display_name}" + + def items(self, obj: Game) -> list[DropCampaign]: + """Return the latest 100 campaigns for this game.""" + return list( + DropCampaign.objects.filter(game=obj).select_related("game").order_by("-added_at")[:100], + ) + + +# MARK: /rss/organizations//campaigns/ +class OrganizationCampaignFeed(DropCampaignFeed): + """RSS feed for campaigns of a specific organization.""" + + def get_object(self, request: HttpRequest, twitch_id: str) -> Organization: # noqa: ARG002 + """Get the organization object for this feed. + + Args: + request: The HTTP request. + twitch_id: The Twitch ID of the organization. + + Returns: + Organization: The organization object. + """ + return Organization.objects.get(twitch_id=twitch_id) + + def title(self, obj: Organization) -> str: + """Return the feed title.""" + return f"TTVDrops: {obj.name} Campaigns" + + def link(self, obj: Organization) -> str: + """Return the link to the organization detail.""" + return reverse("twitch:organization_detail", args=[obj.twitch_id]) + + def description(self, obj: Organization) -> str: + """Return the feed description.""" + return f"Latest drop campaigns for {obj.name}" + + def items(self, obj: Organization) -> list[DropCampaign]: + """Return the latest 100 campaigns for this organization.""" + return list( + DropCampaign.objects.filter(game__owner=obj).select_related("game").order_by("-added_at")[:100], + ) diff --git a/twitch/tests/test_feeds.py b/twitch/tests/test_feeds.py new file mode 100644 index 0000000..dc175f1 --- /dev/null +++ b/twitch/tests/test_feeds.py @@ -0,0 +1,139 @@ +"""Test RSS feeds.""" + +from __future__ import annotations + +from datetime import timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from twitch.models import DropCampaign +from twitch.models import Game +from twitch.models import Organization + + +class RSSFeedTestCase(TestCase): + """Test RSS feeds.""" + + def setUp(self) -> None: + """Set up test fixtures.""" + self.org = Organization.objects.create( + twitch_id="test-org-123", + name="Test Organization", + ) + self.game = Game.objects.create( + twitch_id="test-game-123", + slug="test-game", + name="Test Game", + display_name="Test Game", + owner=self.org, + ) + self.campaign = DropCampaign.objects.create( + twitch_id="test-campaign-123", + name="Test Campaign", + game=self.game, + start_at=timezone.now(), + end_at=timezone.now() + timedelta(days=7), + ) + + def test_organization_feed(self) -> None: + """Test organization feed returns 200.""" + url = reverse("twitch:organization_feed") + response = self.client.get(url) + assert response.status_code == 200 + assert response["Content-Type"] == "application/rss+xml; charset=utf-8" + + def test_game_feed(self) -> None: + """Test game feed returns 200.""" + url = reverse("twitch:game_feed") + response = self.client.get(url) + assert response.status_code == 200 + assert response["Content-Type"] == "application/rss+xml; charset=utf-8" + + def test_campaign_feed(self) -> None: + """Test campaign feed returns 200.""" + url = reverse("twitch:campaign_feed") + response = self.client.get(url) + assert response.status_code == 200 + assert response["Content-Type"] == "application/rss+xml; charset=utf-8" + + def test_game_campaign_feed(self) -> None: + """Test game-specific campaign feed returns 200.""" + url = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) + response = self.client.get(url) + assert response.status_code == 200 + assert response["Content-Type"] == "application/rss+xml; charset=utf-8" + # Verify the game name is in the feed + content = 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 = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id]) + response = 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 = 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 + other_game = Game.objects.create( + twitch_id="other-game-123", + slug="other-game", + name="Other Game", + display_name="Other Game", + owner=self.org, + ) + DropCampaign.objects.create( + twitch_id="other-campaign-123", + name="Other Campaign", + game=other_game, + start_at=timezone.now(), + end_at=timezone.now() + timedelta(days=7), + ) + + # Get feed for first game + url = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) + response = self.client.get(url) + content = response.content.decode("utf-8") + + # Should contain first campaign + assert "Test Campaign" in content + # 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", + owner=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), + ) + + # Get feed for first organization + url = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id]) + response = self.client.get(url) + content = 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 diff --git a/twitch/urls.py b/twitch/urls.py index 96e6703..bd80951 100644 --- a/twitch/urls.py +++ b/twitch/urls.py @@ -6,7 +6,9 @@ from django.urls import path 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 OrganizationFeed if TYPE_CHECKING: @@ -28,7 +30,9 @@ urlpatterns: list[URLPattern] = [ path("channels/", views.ChannelListView.as_view(), name="channel_list"), path("channels//", views.ChannelDetailView.as_view(), name="channel_detail"), path("rss/organizations/", OrganizationFeed(), name="organization_feed"), + path("rss/organizations//campaigns/", OrganizationCampaignFeed(), name="organization_campaign_feed"), path("rss/games/", GameFeed(), name="game_feed"), + path("rss/games//campaigns/", GameCampaignFeed(), name="game_campaign_feed"), path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"), path("docs/rss/", views.docs_rss_view, name="docs_rss"), ] diff --git a/twitch/views.py b/twitch/views.py index a943412..b0e4cec 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -792,22 +792,35 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse: """ feeds: list[dict[str, str]] = [ { - "title": "Organizations", - "description": "Latest organizations", + "title": "All Organizations", + "description": "Latest organizations added to TTVDrops", "url": "/rss/organizations/", }, { - "title": "Games", - "description": "Latest games", + "title": "All Games", + "description": "Latest games added to TTVDrops", "url": "/rss/games/", }, { - "title": "Drop Campaigns", - "description": "Latest drop campaigns", + "title": "All Drop Campaigns", + "description": "Latest drop campaigns across all games", "url": "/rss/campaigns/", }, ] - return render(request, "twitch/docs_rss.html", {"feeds": feeds}) + + # Get sample game and organization for examples + sample_game = Game.objects.first() + sample_org = Organization.objects.first() + + return render( + request, + "twitch/docs_rss.html", + { + "feeds": feeds, + "sample_game": sample_game, + "sample_org": sample_org, + }, + ) # MARK: /channels/