Improve feed explenation; add link to templates

This commit is contained in:
Joakim Hellsén 2026-03-17 05:36:31 +01:00
commit 768e6f2111
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
19 changed files with 200 additions and 279 deletions

View file

@ -2,9 +2,27 @@ from typing import TYPE_CHECKING
from django.urls import path
from core import views
from core.views import dashboard
from core.views import dataset_backup_download_view
from core.views import dataset_backups_view
from core.views import debug_view
from core.views import docs_rss_view
from core.views import search_view
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignDiscordFeed
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignDiscordFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameDiscordFeed
from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationDiscordFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignDiscordFeed
from twitch.feeds import RewardCampaignFeed
if TYPE_CHECKING:
from django.urls.resolvers import URLPattern
@ -15,21 +33,21 @@ app_name = "core"
urlpatterns: list[URLPattern | URLResolver] = [
# /
path("", views.dashboard, name="dashboard"),
path("", dashboard, name="dashboard"),
# /search/
path("search/", views.search_view, name="search"),
path("search/", search_view, name="search"),
# /debug/
path("debug/", views.debug_view, name="debug"),
path("debug/", debug_view, name="debug"),
# /datasets/
path("datasets/", views.dataset_backups_view, name="dataset_backups"),
path("datasets/", dataset_backups_view, name="dataset_backups"),
# /datasets/download/<relative_path>/
path(
"datasets/download/<path:relative_path>/",
views.dataset_backup_download_view,
dataset_backup_download_view,
name="dataset_backup_download",
),
# /docs/rss/
path("docs/rss/", views.docs_rss_view, name="docs_rss"),
path("docs/rss/", docs_rss_view, name="docs_rss"),
# RSS feeds
# /rss/campaigns/ - all active campaigns
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
@ -38,59 +56,59 @@ urlpatterns: list[URLPattern | URLResolver] = [
# /rss/games/<twitch_id>/campaigns/ - active campaigns for a specific game
path(
"rss/games/<str:twitch_id>/campaigns/",
views.GameCampaignFeed(),
GameCampaignFeed(),
name="game_campaign_feed",
),
# /rss/organizations/ - newly added organizations
path(
"rss/organizations/",
views.OrganizationRSSFeed(),
OrganizationRSSFeed(),
name="organization_feed",
),
# /rss/reward-campaigns/ - all active reward campaigns
path(
"rss/reward-campaigns/",
views.RewardCampaignFeed(),
RewardCampaignFeed(),
name="reward_campaign_feed",
),
# Atom feeds (added alongside RSS to preserve backward compatibility)
path("atom/campaigns/", views.DropCampaignAtomFeed(), name="campaign_feed_atom"),
path("atom/games/", views.GameAtomFeed(), name="game_feed_atom"),
path("atom/campaigns/", DropCampaignAtomFeed(), name="campaign_feed_atom"),
path("atom/games/", GameAtomFeed(), name="game_feed_atom"),
path(
"atom/games/<str:twitch_id>/campaigns/",
views.GameCampaignAtomFeed(),
view=GameCampaignAtomFeed(),
name="game_campaign_feed_atom",
),
path(
"atom/organizations/",
views.OrganizationAtomFeed(),
OrganizationAtomFeed(),
name="organization_feed_atom",
),
path(
"atom/reward-campaigns/",
views.RewardCampaignAtomFeed(),
RewardCampaignAtomFeed(),
name="reward_campaign_feed_atom",
),
# Discord feeds (Atom feeds with Discord relative timestamps)
path(
"discord/campaigns/",
views.DropCampaignDiscordFeed(),
DropCampaignDiscordFeed(),
name="campaign_feed_discord",
),
path("discord/games/", views.GameDiscordFeed(), name="game_feed_discord"),
path("discord/games/", GameDiscordFeed(), name="game_feed_discord"),
path(
"discord/games/<str:twitch_id>/campaigns/",
views.GameCampaignDiscordFeed(),
GameCampaignDiscordFeed(),
name="game_campaign_feed_discord",
),
path(
"discord/organizations/",
views.OrganizationDiscordFeed(),
OrganizationDiscordFeed(),
name="organization_feed_discord",
),
path(
"discord/reward-campaigns/",
views.RewardCampaignDiscordFeed(),
RewardCampaignDiscordFeed(),
name="reward_campaign_feed_discord",
),
]

View file

@ -3,7 +3,6 @@ import json
import logging
import operator
from collections import OrderedDict
from copy import copy
from typing import TYPE_CHECKING
from typing import Any
@ -27,21 +26,6 @@ from django.utils import timezone
from kick.models import KickChannel
from kick.models import KickDropCampaign
from twitch.feeds import DropCampaignAtomFeed
from twitch.feeds import DropCampaignDiscordFeed
from twitch.feeds import DropCampaignFeed
from twitch.feeds import GameAtomFeed
from twitch.feeds import GameCampaignAtomFeed
from twitch.feeds import GameCampaignDiscordFeed
from twitch.feeds import GameCampaignFeed
from twitch.feeds import GameDiscordFeed
from twitch.feeds import GameFeed
from twitch.feeds import OrganizationAtomFeed
from twitch.feeds import OrganizationDiscordFeed
from twitch.feeds import OrganizationRSSFeed
from twitch.feeds import RewardCampaignAtomFeed
from twitch.feeds import RewardCampaignDiscordFeed
from twitch.feeds import RewardCampaignFeed
from twitch.models import Channel
from twitch.models import ChatBadge
from twitch.models import ChatBadgeSet
@ -53,13 +37,11 @@ from twitch.models import RewardCampaign
from twitch.models import TimeBasedDrop
if TYPE_CHECKING:
from collections.abc import Callable
from os import stat_result
from pathlib import Path
from django.db.models import QuerySet
from django.http import HttpRequest
from django.http.request import QueryDict
logger: logging.Logger = logging.getLogger("ttvdrops.views")
@ -279,178 +261,33 @@ def sitemap_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0915
# MARK: /docs/rss/
def docs_rss_view(request: HttpRequest) -> HttpResponse:
"""View for /docs/rss that lists all available RSS feeds.
"""View for /docs/rss that lists all available feeds and explains how to use them.
Args:
request: The HTTP request object.
Returns:
Rendered HTML response with list of RSS feeds.
HttpResponse: The rendered documentation page.
"""
def absolute(path: str) -> str:
try:
return request.build_absolute_uri(path)
except Exception:
logger.exception("Failed to build absolute URL for %s", path)
return path
def _pretty_example(xml_str: str, max_items: int = 1) -> str:
try:
trimmed: str = xml_str.strip()
first_item: int = trimmed.find("<item")
if first_item != -1 and max_items == 1:
second_item: int = trimmed.find("<item", first_item + 5)
if second_item != -1:
end_channel: int = trimmed.find("</channel>", second_item)
if end_channel != -1:
trimmed = trimmed[:second_item] + trimmed[end_channel:]
formatted: str = trimmed.replace("><", ">\n<")
return "\n".join(line for line in formatted.splitlines() if line.strip())
except Exception:
logger.exception("Failed to pretty-print RSS example")
return xml_str
def render_feed(feed_view: Callable[..., HttpResponse], *args: object) -> str:
try:
limited_request: HttpRequest = copy(request)
# Add limit=1 to GET parameters
get_data: QueryDict = request.GET.copy()
get_data["limit"] = "1"
limited_request.GET = get_data
response: HttpResponse = feed_view(limited_request, *args)
return _pretty_example(response.content.decode("utf-8"))
except Exception:
logger.exception(
"Failed to render %s for RSS docs",
feed_view.__class__.__name__,
)
return ""
show_atom: bool = bool(request.GET.get("show_atom"))
feeds: list[dict[str, str]] = [
{
"title": "All Organizations",
"description": "Latest organizations added to TTVDrops",
"url": absolute(reverse("core:organization_feed")),
"atom_url": absolute(reverse("core:organization_feed_atom")),
"discord_url": absolute(reverse("core:organization_feed_discord")),
"example_xml": render_feed(OrganizationRSSFeed()),
"example_xml_atom": render_feed(OrganizationAtomFeed())
if show_atom
else "",
"example_xml_discord": render_feed(OrganizationDiscordFeed())
if show_atom
else "",
},
{
"title": "All Games",
"description": "Latest games added to TTVDrops",
"url": absolute(reverse("core:game_feed")),
"atom_url": absolute(reverse("core:game_feed_atom")),
"discord_url": absolute(reverse("core:game_feed_discord")),
"example_xml": render_feed(GameFeed()),
"example_xml_atom": render_feed(GameAtomFeed()) if show_atom else "",
"example_xml_discord": render_feed(GameDiscordFeed()) if show_atom else "",
},
{
"title": "All Drop Campaigns",
"description": "Latest drop campaigns across all games",
"url": absolute(reverse("core:campaign_feed")),
"atom_url": absolute(reverse("core:campaign_feed_atom")),
"discord_url": absolute(reverse("core:campaign_feed_discord")),
"example_xml": render_feed(DropCampaignFeed()),
"example_xml_atom": render_feed(DropCampaignAtomFeed())
if show_atom
else "",
"example_xml_discord": render_feed(DropCampaignDiscordFeed())
if show_atom
else "",
},
{
"title": "All Reward Campaigns",
"description": "Latest reward campaigns (Quest rewards) on Twitch",
"url": absolute(reverse("core:reward_campaign_feed")),
"atom_url": absolute(reverse("core:reward_campaign_feed_atom")),
"discord_url": absolute(reverse("core:reward_campaign_feed_discord")),
"example_xml": render_feed(RewardCampaignFeed()),
"example_xml_atom": render_feed(RewardCampaignAtomFeed())
if show_atom
else "",
"example_xml_discord": render_feed(RewardCampaignDiscordFeed())
if show_atom
else "",
},
]
sample_game: Game | None = Game.objects.order_by("-added_at").first()
sample_org: Organization | None = Organization.objects.order_by("-added_at").first()
if sample_org is None and sample_game is not None:
sample_org = sample_game.owners.order_by("-pk").first()
filtered_feeds: list[dict[str, str | bool]] = [
{
"title": "Campaigns for a Single Game",
"description": "Latest drop campaigns for one game.",
"url": (
absolute(
reverse("core:game_campaign_feed", args=[sample_game.twitch_id]),
)
if sample_game
else absolute("/rss/games/<game_id>/campaigns/")
),
"atom_url": (
absolute(
reverse(
"core:game_campaign_feed_atom",
args=[sample_game.twitch_id],
),
)
if sample_game
else absolute("/atom/games/<game_id>/campaigns/")
),
"discord_url": (
absolute(
reverse(
"core:game_campaign_feed_discord",
args=[sample_game.twitch_id],
),
)
if sample_game
else absolute("/discord/games/<game_id>/campaigns/")
),
"has_sample": bool(sample_game),
"example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id)
if sample_game
else "",
"example_xml_atom": (
render_feed(GameCampaignAtomFeed(), sample_game.twitch_id)
if sample_game and show_atom
else ""
),
"example_xml_discord": (
render_feed(GameCampaignDiscordFeed(), sample_game.twitch_id)
if sample_game and show_atom
else ""
),
},
]
now: datetime.datetime = timezone.now()
sample_game: Game | None = (
Game.objects
.filter(drop_campaigns__start_at__lte=now, drop_campaigns__end_at__gte=now)
.distinct()
.first()
)
seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch RSS Feeds",
page_description="RSS feeds for Twitch drops.",
page_title="Feed Documentation",
page_description="Documentation for the RSS feeds available on ttvdrops.lovinator.space, including how to use them and what data they contain.",
page_url=request.build_absolute_uri(reverse("core:docs_rss")),
)
return render(
request,
"twitch/docs_rss.html",
{
"feeds": feeds,
"filtered_feeds": filtered_feeds,
"sample_game": sample_game,
"sample_org": sample_org,
"game": sample_game,
**seo_context,
},
)