This commit is contained in:
parent
11244c669f
commit
4627d1cea0
16 changed files with 569 additions and 6 deletions
|
|
@ -33,6 +33,10 @@
|
|||
type="application/atom+xml"
|
||||
title="All campaigns (Atom)"
|
||||
href="{% url 'twitch:campaign_feed_atom' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="All campaigns (Discord)"
|
||||
href="{% url 'twitch:campaign_feed_discord' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title="Newly added games (RSS)"
|
||||
|
|
@ -41,6 +45,10 @@
|
|||
type="application/atom+xml"
|
||||
title="Newly added games (Atom)"
|
||||
href="{% url 'twitch:game_feed_atom' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="Newly added games (Discord)"
|
||||
href="{% url 'twitch:game_feed_discord' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title="Newly added organizations (RSS)"
|
||||
|
|
@ -49,6 +57,10 @@
|
|||
type="application/atom+xml"
|
||||
title="Newly added organizations (Atom)"
|
||||
href="{% url 'twitch:organization_feed_atom' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="Newly added organizations (Discord)"
|
||||
href="{% url 'twitch:organization_feed_discord' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title="Newly added reward campaigns (RSS)"
|
||||
|
|
@ -57,6 +69,10 @@
|
|||
type="application/atom+xml"
|
||||
title="Newly added reward campaigns (Atom)"
|
||||
href="{% url 'twitch:reward_campaign_feed_atom' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="Newly added reward campaigns (Discord)"
|
||||
href="{% url 'twitch:reward_campaign_feed_discord' %}" />
|
||||
{# Allow child templates to inject page-specific alternates into the head #}
|
||||
{% block extra_head %}
|
||||
{% endblock extra_head %}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
type="application/atom+xml"
|
||||
title="{{ campaign.game.display_name }} campaigns (Atom)"
|
||||
href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="{{ campaign.game.display_name }} campaigns (Discord)"
|
||||
href="{% url 'twitch:game_campaign_feed_discord' campaign.game.twitch_id %}" />
|
||||
{% endif %}
|
||||
{% endblock extra_head %}
|
||||
{% block content %}
|
||||
|
|
@ -90,6 +94,8 @@
|
|||
title="RSS feed for {{ campaign.game.display_name }} campaigns">[rss]</a>
|
||||
<a href="{% url 'twitch:game_campaign_feed_atom' campaign.game.twitch_id %}"
|
||||
title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a>
|
||||
<a href="{% url 'twitch:game_campaign_feed_discord' campaign.game.twitch_id %}"
|
||||
title="Discord feed for {{ campaign.game.display_name }} campaigns">[discord]</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
type="application/atom+xml"
|
||||
title="All campaigns (Atom)"
|
||||
href="{% url 'twitch:campaign_feed_atom' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="All campaigns (Discord)"
|
||||
href="{% url 'twitch:campaign_feed_discord' %}" />
|
||||
{% endblock extra_head %}
|
||||
{% block content %}
|
||||
<main>
|
||||
|
|
@ -25,6 +29,8 @@
|
|||
title="RSS feed for all campaigns">[rss]</a>
|
||||
<a href="{% url 'twitch:campaign_feed_atom' %}"
|
||||
title="Atom feed for all campaigns">[atom]</a>
|
||||
<a href="{% url 'twitch:campaign_feed_discord' %}"
|
||||
title="Discord feed for all campaigns">[discord]</a>
|
||||
<a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"
|
||||
title="Export campaigns as CSV">[csv]</a>
|
||||
<a href="{% url 'twitch:export_campaigns_json' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@
|
|||
type="application/atom+xml"
|
||||
title="All campaigns (Atom)"
|
||||
href="{% url 'twitch:campaign_feed_atom' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="All campaigns (Discord)"
|
||||
href="{% url 'twitch:campaign_feed_discord' %}" />
|
||||
{% endblock extra_head %}
|
||||
{% block content %}
|
||||
<main>
|
||||
|
|
@ -33,6 +37,8 @@
|
|||
title="RSS feed for all campaigns">[rss]</a>
|
||||
<a href="{% url 'twitch:campaign_feed_atom' %}"
|
||||
title="Atom feed for campaigns">[atom]</a>
|
||||
<a href="{% url 'twitch:campaign_feed_discord' %}"
|
||||
title="Discord feed for campaigns">[discord]</a>
|
||||
</div>
|
||||
<hr />
|
||||
{% if campaigns_by_game %}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@
|
|||
<h1>RSS Feeds Documentation</h1>
|
||||
<p>This page lists all available RSS feeds for TTVDrops.</p>
|
||||
<p>
|
||||
Note: Atom feeds are also available for the same resources under the
|
||||
<code>/atom/</code> endpoints (links labeled "Atom" are shown next to RSS links).
|
||||
Both RSS and Atom formats are supported and served in parallel for backward compatibility.
|
||||
Atom feeds are also available for the same resources under the
|
||||
<code>/atom/</code> endpoints.
|
||||
</p>
|
||||
<p>
|
||||
Discord feeds are available under the <code>/discord/</code> endpoints. These are Atom feeds
|
||||
that include Discord relative timestamps (e.g., <code><t:1773450272:R></code>) for dates,
|
||||
making them ideal for Discord bots and integrations.
|
||||
</p>
|
||||
<section>
|
||||
<h2>Global RSS Feeds</h2>
|
||||
|
|
@ -26,12 +30,20 @@
|
|||
|
|
||||
<a href="{{ feed.atom_url }}">Subscribe to {{ feed.title }} Atom Feed</a>
|
||||
{% endif %}
|
||||
{% if feed.discord_url %}
|
||||
|
|
||||
<a href="{{ feed.discord_url }}">Subscribe to {{ feed.title }} Discord Feed</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre>
|
||||
{% if feed.example_xml_atom %}
|
||||
<h4>Atom example</h4>
|
||||
<pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre>
|
||||
{% endif %}
|
||||
{% if feed.example_xml_discord %}
|
||||
<h4>Discord example</h4>
|
||||
<pre><code class="language-xml">{{ feed.example_xml_discord|escape }}</code></pre>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
@ -49,6 +61,7 @@
|
|||
<p>
|
||||
Endpoint: <code>{{ feed.url }}</code>
|
||||
{% if feed.atom_url %} | Atom: <code>{{ feed.atom_url }}</code>{% endif %}
|
||||
{% if feed.discord_url %} | Discord: <code>{{ feed.discord_url }}</code>{% endif %}
|
||||
</p>
|
||||
{% if feed.has_sample %}
|
||||
<p>
|
||||
|
|
@ -57,6 +70,10 @@
|
|||
|
|
||||
<a href="{{ feed.atom_url }}">View Atom example</a>
|
||||
{% endif %}
|
||||
{% if feed.discord_url %}
|
||||
|
|
||||
<a href="{{ feed.discord_url }}">View Discord example</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre>
|
||||
|
|
@ -64,6 +81,10 @@
|
|||
<h4>Atom example</h4>
|
||||
<pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre>
|
||||
{% endif %}
|
||||
{% if feed.example_xml_discord %}
|
||||
<h4>Discord example</h4>
|
||||
<pre><code class="language-xml">{{ feed.example_xml_discord|escape }}</code></pre>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@
|
|||
type="application/atom+xml"
|
||||
title="{{ game.display_name }} campaigns (Atom)"
|
||||
href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="{{ game.display_name }} campaigns (Discord)"
|
||||
href="{% url 'twitch:game_campaign_feed_discord' game.twitch_id %}" />
|
||||
{% endif %}
|
||||
{% endblock extra_head %}
|
||||
{% block content %}
|
||||
|
|
@ -49,6 +53,8 @@
|
|||
title="RSS feed for {{ game.display_name }} campaigns">[rss]</a>
|
||||
<a href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}"
|
||||
title="Atom feed for {{ game.display_name }} campaigns">[atom]</a>
|
||||
<a href="{% url 'twitch:game_campaign_feed_discord' game.twitch_id %}"
|
||||
title="Discord feed for {{ game.display_name }} campaigns">[discord]</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@
|
|||
type="application/atom+xml"
|
||||
title="Newly added games (Atom)"
|
||||
href="{% url 'twitch:game_feed_atom' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="Newly added games (Discord)"
|
||||
href="{% url 'twitch:game_feed_discord' %}" />
|
||||
{% endblock extra_head %}
|
||||
{% block content %}
|
||||
<main>
|
||||
|
|
@ -22,6 +26,8 @@
|
|||
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a>
|
||||
<a href="{% url 'twitch:game_feed_atom' %}"
|
||||
title="Atom feed for all games">[atom]</a>
|
||||
<a href="{% url 'twitch:game_feed_discord' %}"
|
||||
title="Discord feed for all games">[discord]</a>
|
||||
<a href="{% url 'twitch:export_games_csv' %}"
|
||||
title="Export all games as CSV">[csv]</a>
|
||||
<a href="{% url 'twitch:export_games_json' %}"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
type="application/atom+xml"
|
||||
title="Newly added games (Atom)"
|
||||
href="{% url 'twitch:game_feed_atom' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="Newly added games (Discord)"
|
||||
href="{% url 'twitch:game_feed_discord' %}" />
|
||||
{% endblock extra_head %}
|
||||
{% block content %}
|
||||
<main>
|
||||
|
|
@ -20,6 +24,8 @@
|
|||
<a href="{% url 'twitch:game_feed' %}" title="RSS feed for all games">[rss]</a>
|
||||
<a href="{% url 'twitch:game_feed_atom' %}"
|
||||
title="Atom feed for all games">[atom]</a>
|
||||
<a href="{% url 'twitch:game_feed_discord' %}"
|
||||
title="Discord feed for all games">[discord]</a>
|
||||
<a href="{% url 'twitch:export_games_csv' %}"
|
||||
title="Export all games as CSV">[csv]</a>
|
||||
<a href="{% url 'twitch:export_games_json' %}"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
title="RSS feed for all organizations">[rss]</a>
|
||||
<a href="{% url 'twitch:organization_feed_atom' %}"
|
||||
title="Atom feed for all organizations">[atom]</a>
|
||||
<a href="{% url 'twitch:organization_feed_discord' %}"
|
||||
title="Discord feed for all organizations">[discord]</a>
|
||||
<a href="{% url 'twitch:export_organizations_csv' %}"
|
||||
title="Export all organizations as CSV">[csv]</a>
|
||||
<a href="{% url 'twitch:export_organizations_json' %}"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@
|
|||
type="application/atom+xml"
|
||||
title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (Atom)"
|
||||
href="{% url 'twitch:game_campaign_feed_atom' game.twitch_id %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="{{ game.display_name|default:game.name|default:game.twitch_id }} campaigns (Discord)"
|
||||
href="{% url 'twitch:game_campaign_feed_discord' game.twitch_id %}" />
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock extra_head %}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@
|
|||
type="application/atom+xml"
|
||||
title="Reward campaigns (Atom)"
|
||||
href="{% url 'twitch:reward_campaign_feed_atom' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="Reward campaigns (Discord)"
|
||||
href="{% url 'twitch:reward_campaign_feed_discord' %}" />
|
||||
{% endblock extra_head %}
|
||||
{% block content %}
|
||||
<!-- Campaign Title -->
|
||||
|
|
@ -36,6 +40,8 @@
|
|||
title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a>
|
||||
<a href="{% url 'twitch:reward_campaign_feed_atom' %}"
|
||||
title="Atom feed for all reward campaigns">[atom]</a>
|
||||
<a href="{% url 'twitch:reward_campaign_feed_discord' %}"
|
||||
title="Discord feed for all reward campaigns">[discord]</a>
|
||||
</div>
|
||||
<!-- Campaign Summary -->
|
||||
{% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@
|
|||
type="application/atom+xml"
|
||||
title="Reward campaigns (Atom)"
|
||||
href="{% url 'twitch:reward_campaign_feed_atom' %}" />
|
||||
<link rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="Reward campaigns (Discord)"
|
||||
href="{% url 'twitch:reward_campaign_feed_discord' %}" />
|
||||
{% endblock extra_head %}
|
||||
{% block content %}
|
||||
<h1>Reward Campaigns</h1>
|
||||
|
|
@ -21,6 +25,8 @@
|
|||
title="RSS feed for all reward campaigns">[rss]</a>
|
||||
<a href="{% url 'twitch:reward_campaign_feed_atom' %}"
|
||||
title="Atom feed for all reward campaigns">[atom]</a>
|
||||
<a href="{% url 'twitch:reward_campaign_feed_discord' %}"
|
||||
title="Discord feed for all reward campaigns">[discord]</a>
|
||||
</div>
|
||||
<p>This is an archive of old Twitch reward campaigns because we do not monitor them.</p>
|
||||
<p>
|
||||
|
|
|
|||
194
twitch/feeds.py
194
twitch/feeds.py
|
|
@ -39,6 +39,26 @@ logger: logging.Logger = logging.getLogger("ttvdrops")
|
|||
RSS_STYLESHEETS: list[str] = [static("rss_styles.xslt")]
|
||||
|
||||
|
||||
def discord_timestamp(dt: datetime.datetime | None) -> SafeText:
|
||||
"""Convert a datetime to a Discord relative timestamp format.
|
||||
|
||||
Discord timestamps use the format <t:UNIX_TIMESTAMP:R> where R means relative time.
|
||||
Example: <t:1773450272:R> displays as "2 hours ago" in Discord.
|
||||
|
||||
Args:
|
||||
dt: The datetime to convert. If None, returns an empty string.
|
||||
|
||||
Returns:
|
||||
SafeText: Escaped Discord timestamp token (e.g. <t:1773450272:R>) marked
|
||||
safe for HTML insertion, or empty string if dt is None.
|
||||
"""
|
||||
if dt is None:
|
||||
return SafeText("")
|
||||
unix_timestamp: int = int(dt.timestamp())
|
||||
# Keep this escaped so Atom/RSS HTML renderers don't treat <t:...:R> as an HTML tag.
|
||||
return SafeText(f"<t:{unix_timestamp}:R>")
|
||||
|
||||
|
||||
class BrowserFriendlyRss201rev2Feed(feedgenerator.Rss201rev2Feed):
|
||||
"""RSS 2.0 feed generator with a browser-renderable XML content type."""
|
||||
|
||||
|
|
@ -340,6 +360,49 @@ def generate_date_html(item: Model) -> list[SafeText]:
|
|||
return parts
|
||||
|
||||
|
||||
def generate_discord_date_html(item: Model) -> list[SafeText]:
|
||||
"""Generate HTML snippets for dates using Discord relative timestamp format.
|
||||
|
||||
Args:
|
||||
item (Model): The campaign item containing start_at and end_at.
|
||||
|
||||
Returns:
|
||||
list[SafeText]: A list of SafeText elements with Discord timestamp formatted dates.
|
||||
"""
|
||||
parts: list[SafeText] = []
|
||||
end_at: datetime.datetime | None = getattr(item, "end_at", None)
|
||||
start_at: datetime.datetime | None = getattr(item, "start_at", None)
|
||||
|
||||
if start_at or end_at:
|
||||
start_part: SafeString = (
|
||||
format_html(
|
||||
"Starts: {} ({})",
|
||||
start_at.strftime("%Y-%m-%d %H:%M %Z"),
|
||||
discord_timestamp(start_at),
|
||||
)
|
||||
if start_at
|
||||
else SafeText("")
|
||||
)
|
||||
end_part: SafeString = (
|
||||
format_html(
|
||||
"Ends: {} ({})",
|
||||
end_at.strftime("%Y-%m-%d %H:%M %Z"),
|
||||
discord_timestamp(end_at),
|
||||
)
|
||||
if end_at
|
||||
else SafeText("")
|
||||
)
|
||||
# Start date and end date separated by a line break if both present
|
||||
if start_part and end_part:
|
||||
parts.append(format_html("<p>{}<br />{}</p>", start_part, end_part))
|
||||
elif start_part:
|
||||
parts.append(format_html("<p>{}</p>", start_part))
|
||||
elif end_part:
|
||||
parts.append(format_html("<p>{}</p>", end_part))
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def generate_drops_summary_html(item: DropCampaign) -> list[SafeString]:
|
||||
"""Generate HTML summary for drops and append to parts list.
|
||||
|
||||
|
|
@ -1409,3 +1472,134 @@ class RewardCampaignAtomFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
|
|||
def feed_url(self) -> str:
|
||||
"""Return the URL to the Atom feed itself."""
|
||||
return reverse("twitch:reward_campaign_feed_atom")
|
||||
|
||||
|
||||
# Discord feed variants: Atom feeds with Discord relative timestamps
|
||||
class OrganizationDiscordFeed(TTVDropsAtomBaseFeed, OrganizationRSSFeed):
|
||||
"""Discord feed for latest organizations with Discord relative timestamps."""
|
||||
|
||||
subtitle: str = OrganizationRSSFeed.description
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return the URL to the Discord feed itself."""
|
||||
return reverse("twitch:organization_feed_discord")
|
||||
|
||||
|
||||
class GameDiscordFeed(TTVDropsAtomBaseFeed, GameFeed):
|
||||
"""Discord feed for newly added games with Discord relative timestamps."""
|
||||
|
||||
subtitle: str = GameFeed.description
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return the URL to the Discord feed itself."""
|
||||
return reverse("twitch:game_feed_discord")
|
||||
|
||||
|
||||
class DropCampaignDiscordFeed(TTVDropsAtomBaseFeed, DropCampaignFeed):
|
||||
"""Discord feed for latest drop campaigns with Discord relative timestamps."""
|
||||
|
||||
subtitle: str = DropCampaignFeed.description
|
||||
|
||||
def item_description(self, item: DropCampaign) -> SafeText:
|
||||
"""Return a description of the campaign with Discord timestamps."""
|
||||
parts: list[SafeText] = []
|
||||
|
||||
parts.extend(generate_item_image(item))
|
||||
parts.extend(generate_description_html(item=item))
|
||||
parts.extend(generate_discord_date_html(item=item))
|
||||
parts.extend(generate_drops_summary_html(item=item))
|
||||
parts.extend(generate_channels_html(item))
|
||||
parts.extend(genereate_details_link_html(item))
|
||||
|
||||
return SafeText("".join(str(p) for p in parts))
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return the URL to the Discord feed itself."""
|
||||
return reverse("twitch:campaign_feed_discord")
|
||||
|
||||
|
||||
class GameCampaignDiscordFeed(TTVDropsAtomBaseFeed, GameCampaignFeed):
|
||||
"""Discord feed for latest drop campaigns for a specific game with Discord relative timestamps."""
|
||||
|
||||
def item_description(self, item: DropCampaign) -> SafeText:
|
||||
"""Return a description of the campaign with Discord timestamps."""
|
||||
parts: list[SafeText] = []
|
||||
|
||||
parts.extend(generate_item_image_tag(item))
|
||||
parts.extend(generate_details_link(item))
|
||||
parts.extend(generate_discord_date_html(item))
|
||||
parts.extend(generate_drops_summary_html(item))
|
||||
parts.extend(generate_channels_html(item))
|
||||
|
||||
return SafeText("".join(str(p) for p in parts))
|
||||
|
||||
def feed_url(self, obj: Game) -> str:
|
||||
"""Return the URL to the Discord feed itself."""
|
||||
return reverse("twitch:game_campaign_feed_discord", args=[obj.twitch_id])
|
||||
|
||||
|
||||
class RewardCampaignDiscordFeed(TTVDropsAtomBaseFeed, RewardCampaignFeed):
|
||||
"""Discord feed for latest reward campaigns with Discord relative timestamps."""
|
||||
|
||||
subtitle: str = RewardCampaignFeed.description
|
||||
|
||||
def item_description(self, item: RewardCampaign) -> SafeText:
|
||||
"""Return a description of the reward campaign with Discord timestamps."""
|
||||
parts: list = []
|
||||
|
||||
if item.summary:
|
||||
parts.append(format_html("<p>{}</p>", item.summary))
|
||||
|
||||
if item.starts_at or item.ends_at:
|
||||
start_part = (
|
||||
format_html(
|
||||
"Starts: {} ({})",
|
||||
item.starts_at.strftime("%Y-%m-%d %H:%M %Z"),
|
||||
discord_timestamp(item.starts_at),
|
||||
)
|
||||
if item.starts_at
|
||||
else ""
|
||||
)
|
||||
end_part = (
|
||||
format_html(
|
||||
"Ends: {} ({})",
|
||||
item.ends_at.strftime("%Y-%m-%d %H:%M %Z"),
|
||||
discord_timestamp(item.ends_at),
|
||||
)
|
||||
if item.ends_at
|
||||
else ""
|
||||
)
|
||||
if start_part and end_part:
|
||||
parts.append(format_html("<p>{}<br />{}</p>", start_part, end_part))
|
||||
elif start_part:
|
||||
parts.append(format_html("<p>{}</p>", start_part))
|
||||
elif end_part:
|
||||
parts.append(format_html("<p>{}</p>", end_part))
|
||||
|
||||
if item.is_sitewide:
|
||||
parts.append(
|
||||
SafeText("<p><strong>This is a sitewide reward campaign</strong></p>"),
|
||||
)
|
||||
elif item.game:
|
||||
parts.append(
|
||||
format_html(
|
||||
"<p>Game: {}</p>",
|
||||
item.game.display_name or item.game.name,
|
||||
),
|
||||
)
|
||||
|
||||
if item.about_url:
|
||||
parts.append(
|
||||
format_html('<p><a href="{}">Learn more</a></p>', item.about_url),
|
||||
)
|
||||
|
||||
if item.external_url:
|
||||
parts.append(
|
||||
format_html('<p><a href="{}">Redeem reward</a></p>', item.external_url),
|
||||
)
|
||||
|
||||
return SafeText("".join(str(p) for p in parts))
|
||||
|
||||
def feed_url(self) -> str:
|
||||
"""Return the URL to the Discord feed itself."""
|
||||
return reverse("twitch:reward_campaign_feed_discord")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
"""Test RSS feeds."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractContextManager
|
||||
from datetime import UTC
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
|
@ -21,6 +24,7 @@ from twitch.feeds import GameFeed
|
|||
from twitch.feeds import OrganizationRSSFeed
|
||||
from twitch.feeds import RewardCampaignFeed
|
||||
from twitch.feeds import TTVDropsBaseFeed
|
||||
from twitch.feeds import discord_timestamp
|
||||
from twitch.models import Channel
|
||||
from twitch.models import ChatBadge
|
||||
from twitch.models import ChatBadgeSet
|
||||
|
|
@ -37,8 +41,6 @@ STYLESHEET_PATH: Path = (
|
|||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import datetime
|
||||
|
||||
from django.test.client import _MonkeyPatchedWSGIResponse
|
||||
from django.utils.feedgenerator import Enclosure
|
||||
|
||||
|
|
@ -1183,3 +1185,221 @@ def test_rss_feeds_return_200(
|
|||
url: str = reverse(viewname=url_name, kwargs=kwargs)
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class DiscordFeedTestCase(TestCase):
|
||||
"""Test Discord feeds with relative timestamps."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up test fixtures."""
|
||||
self.org: Organization = Organization.objects.create(
|
||||
twitch_id="test-org-discord",
|
||||
name="Test Organization Discord",
|
||||
)
|
||||
self.org.save()
|
||||
|
||||
self.game: Game = Game.objects.create(
|
||||
twitch_id="test-game-discord",
|
||||
slug="test-game-discord",
|
||||
name="Test Game Discord",
|
||||
display_name="Test Game Discord",
|
||||
)
|
||||
self.game.owners.add(self.org)
|
||||
self.campaign: DropCampaign = DropCampaign.objects.create(
|
||||
twitch_id="test-campaign-discord",
|
||||
name="Test Campaign Discord",
|
||||
game=self.game,
|
||||
start_at=timezone.now(),
|
||||
end_at=timezone.now() + timedelta(days=7),
|
||||
operation_names=["DropCampaignDetails"],
|
||||
)
|
||||
|
||||
self.game.box_art_size_bytes = 42
|
||||
self.game.box_art_mime_type = "image/png"
|
||||
self.game.box_art = "https://example.com/box.png"
|
||||
self.game.save()
|
||||
|
||||
self.campaign.image_size_bytes = 314
|
||||
self.campaign.image_mime_type = "image/gif"
|
||||
self.campaign.image_url = "https://example.com/campaign.png"
|
||||
self.campaign.save()
|
||||
|
||||
self.reward_campaign: RewardCampaign = RewardCampaign.objects.create(
|
||||
twitch_id="test-reward-discord",
|
||||
name="Test Reward Campaign Discord",
|
||||
brand="Test Brand",
|
||||
starts_at=timezone.now() - timedelta(days=1),
|
||||
ends_at=timezone.now() + timedelta(days=7),
|
||||
status="ACTIVE",
|
||||
summary="Test reward summary",
|
||||
instructions="Watch and complete objectives",
|
||||
external_url="https://example.com/reward",
|
||||
about_url="https://example.com/about",
|
||||
is_sitewide=False,
|
||||
game=self.game,
|
||||
)
|
||||
|
||||
def test_discord_timestamp_helper(self) -> None:
|
||||
"""Test discord_timestamp helper function."""
|
||||
dt: datetime.datetime = datetime.datetime(2026, 3, 14, 12, 0, 0, tzinfo=UTC)
|
||||
result: str = str(discord_timestamp(dt))
|
||||
assert result.startswith("<t:")
|
||||
assert result.endswith(":R>")
|
||||
|
||||
# Test None input
|
||||
assert not str(discord_timestamp(None))
|
||||
|
||||
def test_organization_discord_feed(self) -> None:
|
||||
"""Test organization Discord feed returns 200."""
|
||||
url: str = reverse("twitch:organization_feed_discord")
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/xml; charset=utf-8"
|
||||
assert response["Content-Disposition"] == "inline"
|
||||
content: str = response.content.decode("utf-8")
|
||||
assert "<feed" in content
|
||||
assert "http://www.w3.org/2005/Atom" in content
|
||||
|
||||
def test_game_discord_feed(self) -> None:
|
||||
"""Test game Discord feed returns 200."""
|
||||
url: str = reverse("twitch:game_feed_discord")
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/xml; charset=utf-8"
|
||||
content: str = response.content.decode("utf-8")
|
||||
assert "<feed" in content
|
||||
assert "Owned by Test Organization Discord." in content
|
||||
|
||||
def test_campaign_discord_feed(self) -> None:
|
||||
"""Test campaign Discord feed returns 200 with Discord timestamps."""
|
||||
url: str = reverse("twitch:campaign_feed_discord")
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/xml; charset=utf-8"
|
||||
content: str = response.content.decode("utf-8")
|
||||
assert "<feed" in content
|
||||
# Should contain Discord timestamp format (double-escaped in XML payload)
|
||||
assert "&lt;t:" in content
|
||||
assert ":R&gt;" in content
|
||||
assert "()" not in content
|
||||
|
||||
def test_game_campaign_discord_feed(self) -> None:
|
||||
"""Test game-specific campaign Discord feed returns 200."""
|
||||
url: str = reverse(
|
||||
"twitch:game_campaign_feed_discord",
|
||||
args=[self.game.twitch_id],
|
||||
)
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/xml; charset=utf-8"
|
||||
content: str = response.content.decode("utf-8")
|
||||
assert "<feed" in content
|
||||
assert "Test Game Discord" in content
|
||||
|
||||
def test_reward_campaign_discord_feed(self) -> None:
|
||||
"""Test reward campaign Discord feed returns 200."""
|
||||
url: str = reverse("twitch:reward_campaign_feed_discord")
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/xml; charset=utf-8"
|
||||
content: str = response.content.decode("utf-8")
|
||||
assert "<feed" in content
|
||||
# Should contain Discord timestamp format (double-escaped in XML payload)
|
||||
assert "&lt;t:" in content
|
||||
assert ":R&gt;" in content
|
||||
assert "()" not in content
|
||||
|
||||
def test_discord_feeds_use_url_ids_and_correct_self_links(self) -> None:
|
||||
"""All Discord feeds should use absolute URL entry IDs and matching self links."""
|
||||
discord_feed_cases: list[tuple[str, dict[str, str], str]] = [
|
||||
(
|
||||
"twitch:campaign_feed_discord",
|
||||
{},
|
||||
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
|
||||
),
|
||||
(
|
||||
"twitch:game_feed_discord",
|
||||
{},
|
||||
f"http://testserver{reverse('twitch:game_detail', args=[self.game.twitch_id])}",
|
||||
),
|
||||
(
|
||||
"twitch:game_campaign_feed_discord",
|
||||
{"twitch_id": self.game.twitch_id},
|
||||
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
|
||||
),
|
||||
(
|
||||
"twitch:organization_feed_discord",
|
||||
{},
|
||||
f"http://testserver{reverse('twitch:organization_detail', args=[self.org.twitch_id])}",
|
||||
),
|
||||
(
|
||||
"twitch:reward_campaign_feed_discord",
|
||||
{},
|
||||
f"http://testserver{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
|
||||
),
|
||||
]
|
||||
|
||||
for url_name, kwargs, expected_entry_id in discord_feed_cases:
|
||||
url: str = reverse(url_name, kwargs=kwargs)
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
content: str = response.content.decode("utf-8")
|
||||
|
||||
expected_self_link: str = f'href="http://testserver{url}"'
|
||||
msg: str = f"Expected self link in Discord feed {url_name}, got: {content}"
|
||||
assert 'rel="self"' in content, msg
|
||||
|
||||
msg = f"Expected self link to match feed URL for {url_name}, got: {content}"
|
||||
assert expected_self_link in content, msg
|
||||
|
||||
msg = f"Expected entry ID to be absolute URL for {url_name}, got: {content}"
|
||||
assert f"<id>{expected_entry_id}</id>" in content, msg
|
||||
|
||||
def test_discord_feeds_include_stylesheet_processing_instruction(self) -> None:
|
||||
"""Discord feeds should include an xml-stylesheet processing instruction."""
|
||||
feed_urls: list[str] = [
|
||||
reverse("twitch:campaign_feed_discord"),
|
||||
reverse("twitch:game_feed_discord"),
|
||||
reverse("twitch:game_campaign_feed_discord", args=[self.game.twitch_id]),
|
||||
reverse("twitch:organization_feed_discord"),
|
||||
reverse("twitch:reward_campaign_feed_discord"),
|
||||
]
|
||||
|
||||
for url in feed_urls:
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
content: str = response.content.decode("utf-8")
|
||||
assert "<?xml-stylesheet" in content
|
||||
assert "rss_styles.xslt" in content
|
||||
assert 'type="text/xsl"' in content
|
||||
assert 'media="screen"' in content
|
||||
|
||||
def test_discord_campaign_feed_contains_discord_timestamps(self) -> None:
|
||||
"""Discord campaign feed should contain Discord relative timestamps."""
|
||||
url: str = reverse("twitch:campaign_feed_discord")
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
content: str = response.content.decode("utf-8")
|
||||
|
||||
# Should contain Discord timestamp format (double-escaped in XML payload)
|
||||
discord_pattern: re.Pattern[str] = re.compile(r"&lt;t:\d+:R&gt;")
|
||||
assert discord_pattern.search(content), (
|
||||
f"Expected Discord timestamp format &lt;t:UNIX_TIMESTAMP:R&gt; in content, got: {content}"
|
||||
)
|
||||
assert "()" not in content
|
||||
|
||||
def test_discord_reward_campaign_feed_contains_discord_timestamps(self) -> None:
|
||||
"""Discord reward campaign feed should contain Discord relative timestamps."""
|
||||
url: str = reverse("twitch:reward_campaign_feed_discord")
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
content: str = response.content.decode("utf-8")
|
||||
|
||||
# Should contain Discord timestamp format (double-escaped in XML payload)
|
||||
discord_pattern: re.Pattern[str] = re.compile(r"&lt;t:\d+:R&gt;")
|
||||
assert discord_pattern.search(content), (
|
||||
f"Expected Discord timestamp format &lt;t:UNIX_TIMESTAMP:R&gt; in content, got: {content}"
|
||||
)
|
||||
assert "()" not in content
|
||||
|
|
|
|||
|
|
@ -4,23 +4,29 @@ from django.urls import path
|
|||
|
||||
from twitch import views
|
||||
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
|
||||
from django.urls.resolvers import URLResolver
|
||||
|
||||
app_name = "twitch"
|
||||
|
||||
|
||||
urlpatterns: list[URLPattern] = [
|
||||
urlpatterns: list[URLPattern | URLResolver] = [
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
path("badges/", views.badge_list_view, name="badge_list"),
|
||||
path("badges/<str:set_id>/", views.badge_set_detail_view, name="badge_set_detail"),
|
||||
|
|
@ -128,4 +134,22 @@ urlpatterns: list[URLPattern] = [
|
|||
RewardCampaignAtomFeed(),
|
||||
name="reward_campaign_feed_atom",
|
||||
),
|
||||
# Discord feeds (Atom feeds with Discord relative timestamps)
|
||||
path("discord/campaigns/", DropCampaignDiscordFeed(), name="campaign_feed_discord"),
|
||||
path("discord/games/", GameDiscordFeed(), name="game_feed_discord"),
|
||||
path(
|
||||
"discord/games/<str:twitch_id>/campaigns/",
|
||||
GameCampaignDiscordFeed(),
|
||||
name="game_campaign_feed_discord",
|
||||
),
|
||||
path(
|
||||
"discord/organizations/",
|
||||
OrganizationDiscordFeed(),
|
||||
name="organization_feed_discord",
|
||||
),
|
||||
path(
|
||||
"discord/reward-campaigns/",
|
||||
RewardCampaignDiscordFeed(),
|
||||
name="reward_campaign_feed_discord",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -41,14 +41,19 @@ from pygments.formatters import HtmlFormatter
|
|||
from pygments.lexers.data import JsonLexer
|
||||
|
||||
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
|
||||
|
|
@ -1829,38 +1834,52 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
|
|||
"description": "Latest organizations added to TTVDrops",
|
||||
"url": absolute(reverse("twitch:organization_feed")),
|
||||
"atom_url": absolute(reverse("twitch:organization_feed_atom")),
|
||||
"discord_url": absolute(reverse("twitch: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("twitch:game_feed")),
|
||||
"atom_url": absolute(reverse("twitch:game_feed_atom")),
|
||||
"discord_url": absolute(reverse("twitch: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("twitch:campaign_feed")),
|
||||
"atom_url": absolute(reverse("twitch:campaign_feed_atom")),
|
||||
"discord_url": absolute(reverse("twitch: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("twitch:reward_campaign_feed")),
|
||||
"atom_url": absolute(reverse("twitch:reward_campaign_feed_atom")),
|
||||
"discord_url": absolute(reverse("twitch: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 "",
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -1890,6 +1909,16 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
|
|||
if sample_game
|
||||
else absolute("/atom/games/<game_id>/campaigns/")
|
||||
),
|
||||
"discord_url": (
|
||||
absolute(
|
||||
reverse(
|
||||
"twitch: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
|
||||
|
|
@ -1899,6 +1928,11 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse:
|
|||
if sample_game and show_atom
|
||||
else ""
|
||||
),
|
||||
"example_xml_discord": (
|
||||
render_feed(GameCampaignDiscordFeed(), sample_game.twitch_id)
|
||||
if sample_game and show_atom
|
||||
else ""
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue