Add RSS feed links in templates

This commit is contained in:
Joakim Hellsén 2026-01-08 00:35:55 +01:00
commit 6a62eaa885
No known key found for this signature in database
13 changed files with 349 additions and 39 deletions

View file

@ -17,6 +17,19 @@
<a href="{% url 'twitch:organization_detail' owner.twitch_id %}">{{ owner.name }}</a> <a href="{% url 'twitch:organization_detail' owner.twitch_id %}">{{ owner.name }}</a>
</p> </p>
{% endif %} {% endif %}
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
{% if campaign.game %}
<a href="{% url 'twitch:game_campaign_feed' campaign.game.twitch_id %}"
style="margin-right: 1rem"
title="RSS feed for {{ campaign.game.display_name }} campaigns">RSS feed for {{ campaign.game.display_name }} campaigns</a>
{% endif %}
{% if owner %}
<a href="{% url 'twitch:organization_campaign_feed' owner.twitch_id %}"
style="margin-right: 1rem"
title="RSS feed for {{ owner.name }} campaigns">RSS feed for {{ owner.name }} campaigns</a>
{% endif %}
</div>
<!-- Campaign image --> <!-- Campaign image -->
{% if campaign.image_best_url or campaign.image_url %} {% if campaign.image_best_url or campaign.image_url %}
<img id="campaign-image" <img id="campaign-image"

View file

@ -8,6 +8,12 @@
<header> <header>
<h1 id="page-title">Drop Campaigns</h1> <h1 id="page-title">Drop Campaigns</h1>
<p>Browse all available drop campaigns</p> <p>Browse all available drop campaigns</p>
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
<a href="{% url 'twitch:campaign_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all campaigns">RSS feed for all campaigns</a>
</div>
</header> </header>
<form id="filter-form" <form id="filter-form"
method="get" method="get"

View file

@ -10,6 +10,12 @@
Drops are sorted alphabetically by organization and game. Click on a campaign or game title to see more details. Drops are sorted alphabetically by organization and game. Click on a campaign or game title to see more details.
Hover over the end time to see the exact date and time. Hover over the end time to see the exact date and time.
</pre> </pre>
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
<a href="{% url 'twitch:campaign_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all campaigns">RSS feed for campaigns</a>
</div>
{% if campaigns_by_org_game %} {% if campaigns_by_org_game %}
{% for org_id, org_data in campaigns_by_org_game.items %} {% for org_id, org_data in campaigns_by_org_game.items %}
<section id="org-section-{{ org_id }}"> <section id="org-section-{{ org_id }}">

View file

@ -8,7 +8,8 @@
<h1 id="page-title">RSS Feeds Documentation</h1> <h1 id="page-title">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">Available RSS Feeds</h2> <h2 id="available-feeds-header">Global RSS Feeds</h2>
<p>These feeds contain all items across the entire site:</p>
<ul id="feeds-list"> <ul id="feeds-list">
{% for feed in feeds %} {% for feed in feeds %}
<li id="feed-{{ forloop.counter }}"> <li id="feed-{{ forloop.counter }}">
@ -21,5 +22,40 @@
{% endfor %} {% endfor %}
</ul> </ul>
</section> </section>
<section style="margin-top: 2rem;">
<h2 id="filtered-feeds-header">Filtered RSS Feeds</h2>
<p>
You can also subscribe to RSS feeds for specific games or organizations. These feeds are available on each game or organization detail page.
</p>
<h3>Game-Specific Campaign Feeds</h3>
<p>
Subscribe to campaigns for a specific game using: <code>/rss/games/&lt;game_id&gt;/campaigns/</code>
</p>
{% if sample_game %}
<p>
Example: <a href="{% url 'twitch:game_campaign_feed' sample_game.twitch_id %}">{{ sample_game.display_name }} Campaigns RSS Feed</a>
</p>
{% endif %}
<h3>Organization-Specific Campaign Feeds</h3>
<p>
Subscribe to campaigns for a specific organization using: <code>/rss/organizations/&lt;org_id&gt;/campaigns/</code>
</p>
{% if sample_org %}
<p>
Example: <a href="{% url 'twitch:organization_campaign_feed' sample_org.twitch_id %}">{{ sample_org.name }} Campaigns RSS Feed</a>
</p>
{% endif %}
</section>
<section style="margin-top: 2rem;">
<h2 id="usage-header">How to Use RSS Feeds</h2>
<p>
RSS feeds allow you to stay updated with new content. You can use any RSS reader application to subscribe to these feeds.
</p>
<ul>
<li>Copy the feed URL</li>
<li>Paste it into your favorite RSS reader (Feedly, Inoreader, NetNewsWire, etc.)</li>
<li>Get automatic updates when new content is added</li>
</ul>
</section>
</main> </main>
{% endblock content %} {% endblock content %}

View file

@ -8,6 +8,20 @@
{{ game.display_name }} {{ game.display_name }}
{% if game.display_name != game.name and game.name %}<small>({{ game.name }})</small>{% endif %} {% if game.display_name != game.name and game.name %}<small>({{ game.name }})</small>{% endif %}
</h1> </h1>
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
<a href="{% url 'twitch:game_campaign_feed' game.twitch_id %}"
style="margin-right: 1rem"
title="RSS feed for {{ game.display_name }} campaigns">RSS feed for {{ game.display_name }} campaigns</a>
{% if owner %}
<a href="{% url 'twitch:organization_campaign_feed' owner.twitch_id %}"
style="margin-right: 1rem"
title="RSS feed for {{ owner.name }} campaigns">RSS feed for {{ owner.name }} campaigns</a>
{% endif %}
<a href="{% url 'twitch:campaign_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all campaigns">RSS feed for all campaigns</a>
</div>
<!-- Game image --> <!-- Game image -->
{% if game.box_art %} {% if game.box_art %}
<img id="game-image" <img id="game-image"

View file

@ -10,6 +10,12 @@
<p> <p>
<a href="{% url 'twitch:game_list_simple' %}">List View</a> <a href="{% url 'twitch:game_list_simple' %}">List View</a>
</p> </p>
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
<a href="{% url 'twitch:game_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all games">RSS feed for all games</a>
</div>
</header> </header>
{% if games_by_org %} {% if games_by_org %}
<section> <section>

View file

@ -8,6 +8,12 @@
<p> <p>
<a href="{% url 'twitch:game_list' %}">Grid View</a> <a href="{% url 'twitch:game_list' %}">Grid View</a>
</p> </p>
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
<a href="{% url 'twitch:game_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all games">RSS feed for all games</a>
</div>
{% if games_by_org %} {% if games_by_org %}
{% for organization, games in games_by_org.items %} {% for organization, games in games_by_org.items %}
<h2 id="org-{{ organization.twitch_id }}">{{ organization.name }}</h2> <h2 id="org-{{ organization.twitch_id }}">{{ organization.name }}</h2>

View file

@ -4,6 +4,12 @@
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<h1 id="page-title">Organizations</h1> <h1 id="page-title">Organizations</h1>
<!-- RSS Feeds -->
<div style="margin-bottom: 1rem;">
<a href="{% url 'twitch:organization_feed' %}"
style="margin-right: 1rem"
title="RSS feed for all organizations">RSS feed for organizations</a>
</div>
{% if orgs %} {% if orgs %}
<ul id="org-list"> <ul id="org-list">
{% for organization in orgs %} {% for organization in orgs %}

View file

@ -4,36 +4,26 @@
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<h1 id="org-name">{{ organization.name }}</h1> <h1 id="org-name">{{ organization.name }}</h1>
{% if user.is_authenticated %} <!-- RSS Feeds -->
<form id="notification-form" <div style="margin-bottom: 1rem;">
method="post" <a href="{% url 'twitch:organization_campaign_feed' organization.twitch_id %}"
action="{% url 'twitch:subscribe_org_notifications' org_id=organization.twitch_id %}"> style="margin-right: 1rem"
{% csrf_token %} title="RSS feed for {{ organization.name }} campaigns">RSS feed for {{ organization.name }} campaigns</a>
<div>
<input type="checkbox"
id="found"
name="notify_found"
{% if subscription and subscription.notify_found %}checked{% endif %} />
<label for="found">🔔 Get notified as soon as a drop for {{ organization.name }} appears on Twitch.</label>
</div> </div>
<div> <theader>
<input type="checkbox" <h2 id="games-header">Games by {{ organization.name }}</h2>
id="live" </theader>
name="notify_live" <table id="games-table">
{% if subscription and subscription.notify_live %}checked{% endif %} /> <tbody>
<label for="live">🎮 Get notified when the drop is live and ready to be farmed.</label>
</div>
<button id="save-preferences-button" type="submit">Save preferences</button>
</form>
{% else %}
<p id="login-prompt">Login to subscribe!</p>
{% endif %}
<ul id="games-list">
{% for game in games %} {% for game in games %}
<li id="game-{{ game.twitch_id }}"> <tr id="game-row-{{ game.twitch_id }}">
<td>
<a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game }}</a> <a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game }}</a>
</li> </td>
</tr>
{% endfor %} {% endfor %}
</ul> </tbody>
</table>
<hr />
{{ org_data|safe }} {{ org_data|safe }}
{% endblock content %} {% endblock content %}

View file

@ -19,6 +19,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
# MARK: /rss/organizations/ # MARK: /rss/organizations/
@ -239,3 +240,73 @@ class DropCampaignFeed(Feed):
def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002 def item_enclosure_mime_type(self, item: DropCampaign) -> str: # noqa: ARG002
"""Returns the MIME type of the enclosure.""" """Returns the MIME type of the enclosure."""
return "image/jpeg" return "image/jpeg"
# MARK: /rss/games/<twitch_id>/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/<twitch_id>/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],
)

139
twitch/tests/test_feeds.py Normal file
View file

@ -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

View file

@ -6,7 +6,9 @@ from django.urls import path
from twitch import views from twitch import views
from twitch.feeds import DropCampaignFeed from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameFeed from twitch.feeds import GameFeed
from twitch.feeds import OrganizationCampaignFeed
from twitch.feeds import OrganizationFeed from twitch.feeds import OrganizationFeed
if TYPE_CHECKING: if TYPE_CHECKING:
@ -28,7 +30,9 @@ urlpatterns: list[URLPattern] = [
path("channels/", views.ChannelListView.as_view(), name="channel_list"), path("channels/", views.ChannelListView.as_view(), name="channel_list"),
path("channels/<str:twitch_id>/", views.ChannelDetailView.as_view(), name="channel_detail"), path("channels/<str:twitch_id>/", views.ChannelDetailView.as_view(), name="channel_detail"),
path("rss/organizations/", OrganizationFeed(), name="organization_feed"), path("rss/organizations/", OrganizationFeed(), name="organization_feed"),
path("rss/organizations/<str:twitch_id>/campaigns/", OrganizationCampaignFeed(), name="organization_campaign_feed"),
path("rss/games/", GameFeed(), name="game_feed"), path("rss/games/", GameFeed(), name="game_feed"),
path("rss/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"),
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"), path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
path("docs/rss/", views.docs_rss_view, name="docs_rss"), path("docs/rss/", views.docs_rss_view, name="docs_rss"),
] ]

View file

@ -792,22 +792,35 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
""" """
feeds: list[dict[str, str]] = [ feeds: list[dict[str, str]] = [
{ {
"title": "Organizations", "title": "All Organizations",
"description": "Latest organizations", "description": "Latest organizations added to TTVDrops",
"url": "/rss/organizations/", "url": "/rss/organizations/",
}, },
{ {
"title": "Games", "title": "All Games",
"description": "Latest games", "description": "Latest games added to TTVDrops",
"url": "/rss/games/", "url": "/rss/games/",
}, },
{ {
"title": "Drop Campaigns", "title": "All Drop Campaigns",
"description": "Latest drop campaigns", "description": "Latest drop campaigns across all games",
"url": "/rss/campaigns/", "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/ # MARK: /channels/