Compare commits
No commits in common. "70298fdd1ed103a7bcf1d113d2e22a6ac33ca92b" and "efed2c2f690b085bbb68e9ca9d4d01c2f4325a8d" have entirely different histories.
70298fdd1e
...
efed2c2f69
33 changed files with 777 additions and 649 deletions
18
core/seo.py
18
core/seo.py
|
|
@ -1,18 +0,0 @@
|
|||
from typing import Any
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class SeoMeta(TypedDict, total=False):
|
||||
"""Shared typed optional SEO metadata for template context generation."""
|
||||
|
||||
page_url: str | None
|
||||
page_image: str | None
|
||||
page_image_width: int | None
|
||||
page_image_height: int | None
|
||||
og_type: str
|
||||
schema_data: dict[str, Any] | None
|
||||
breadcrumb_schema: dict[str, Any] | None
|
||||
pagination_info: list[dict[str, str]] | None
|
||||
published_date: str | None
|
||||
modified_date: str | None
|
||||
robots_directive: str
|
||||
58
core/urls.py
58
core/urls.py
|
|
@ -2,27 +2,9 @@ from typing import TYPE_CHECKING
|
|||
|
||||
from django.urls import path
|
||||
|
||||
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 core import views
|
||||
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
|
||||
|
|
@ -33,21 +15,21 @@ app_name = "core"
|
|||
|
||||
urlpatterns: list[URLPattern | URLResolver] = [
|
||||
# /
|
||||
path("", dashboard, name="dashboard"),
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
# /search/
|
||||
path("search/", search_view, name="search"),
|
||||
path("search/", views.search_view, name="search"),
|
||||
# /debug/
|
||||
path("debug/", debug_view, name="debug"),
|
||||
path("debug/", views.debug_view, name="debug"),
|
||||
# /datasets/
|
||||
path("datasets/", dataset_backups_view, name="dataset_backups"),
|
||||
path("datasets/", views.dataset_backups_view, name="dataset_backups"),
|
||||
# /datasets/download/<relative_path>/
|
||||
path(
|
||||
"datasets/download/<path:relative_path>/",
|
||||
dataset_backup_download_view,
|
||||
views.dataset_backup_download_view,
|
||||
name="dataset_backup_download",
|
||||
),
|
||||
# /docs/rss/
|
||||
path("docs/rss/", docs_rss_view, name="docs_rss"),
|
||||
path("docs/rss/", views.docs_rss_view, name="docs_rss"),
|
||||
# RSS feeds
|
||||
# /rss/campaigns/ - all active campaigns
|
||||
path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
|
||||
|
|
@ -56,59 +38,59 @@ urlpatterns: list[URLPattern | URLResolver] = [
|
|||
# /rss/games/<twitch_id>/campaigns/ - active campaigns for a specific game
|
||||
path(
|
||||
"rss/games/<str:twitch_id>/campaigns/",
|
||||
GameCampaignFeed(),
|
||||
views.GameCampaignFeed(),
|
||||
name="game_campaign_feed",
|
||||
),
|
||||
# /rss/organizations/ - newly added organizations
|
||||
path(
|
||||
"rss/organizations/",
|
||||
OrganizationRSSFeed(),
|
||||
views.OrganizationRSSFeed(),
|
||||
name="organization_feed",
|
||||
),
|
||||
# /rss/reward-campaigns/ - all active reward campaigns
|
||||
path(
|
||||
"rss/reward-campaigns/",
|
||||
RewardCampaignFeed(),
|
||||
views.RewardCampaignFeed(),
|
||||
name="reward_campaign_feed",
|
||||
),
|
||||
# Atom feeds (added alongside RSS to preserve backward compatibility)
|
||||
path("atom/campaigns/", DropCampaignAtomFeed(), name="campaign_feed_atom"),
|
||||
path("atom/games/", GameAtomFeed(), name="game_feed_atom"),
|
||||
path("atom/campaigns/", views.DropCampaignAtomFeed(), name="campaign_feed_atom"),
|
||||
path("atom/games/", views.GameAtomFeed(), name="game_feed_atom"),
|
||||
path(
|
||||
"atom/games/<str:twitch_id>/campaigns/",
|
||||
view=GameCampaignAtomFeed(),
|
||||
views.GameCampaignAtomFeed(),
|
||||
name="game_campaign_feed_atom",
|
||||
),
|
||||
path(
|
||||
"atom/organizations/",
|
||||
OrganizationAtomFeed(),
|
||||
views.OrganizationAtomFeed(),
|
||||
name="organization_feed_atom",
|
||||
),
|
||||
path(
|
||||
"atom/reward-campaigns/",
|
||||
RewardCampaignAtomFeed(),
|
||||
views.RewardCampaignAtomFeed(),
|
||||
name="reward_campaign_feed_atom",
|
||||
),
|
||||
# Discord feeds (Atom feeds with Discord relative timestamps)
|
||||
path(
|
||||
"discord/campaigns/",
|
||||
DropCampaignDiscordFeed(),
|
||||
views.DropCampaignDiscordFeed(),
|
||||
name="campaign_feed_discord",
|
||||
),
|
||||
path("discord/games/", GameDiscordFeed(), name="game_feed_discord"),
|
||||
path("discord/games/", views.GameDiscordFeed(), name="game_feed_discord"),
|
||||
path(
|
||||
"discord/games/<str:twitch_id>/campaigns/",
|
||||
GameCampaignDiscordFeed(),
|
||||
views.GameCampaignDiscordFeed(),
|
||||
name="game_campaign_feed_discord",
|
||||
),
|
||||
path(
|
||||
"discord/organizations/",
|
||||
OrganizationDiscordFeed(),
|
||||
views.OrganizationDiscordFeed(),
|
||||
name="organization_feed_discord",
|
||||
),
|
||||
path(
|
||||
"discord/reward-campaigns/",
|
||||
RewardCampaignDiscordFeed(),
|
||||
views.RewardCampaignDiscordFeed(),
|
||||
name="reward_campaign_feed_discord",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
205
core/views.py
205
core/views.py
|
|
@ -3,6 +3,7 @@ import json
|
|||
import logging
|
||||
import operator
|
||||
from collections import OrderedDict
|
||||
from copy import copy
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -26,6 +27,21 @@ 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
|
||||
|
|
@ -37,11 +53,13 @@ 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")
|
||||
|
|
@ -55,7 +73,6 @@ DEFAULT_SITE_DESCRIPTION = "Archive of Twitch drops, campaigns, rewards, and mor
|
|||
def _build_seo_context( # noqa: PLR0913, PLR0917
|
||||
page_title: str = "ttvdrops",
|
||||
page_description: str | None = None,
|
||||
page_url: str | None = None,
|
||||
page_image: str | None = None,
|
||||
page_image_width: int | None = None,
|
||||
page_image_height: int | None = None,
|
||||
|
|
@ -72,7 +89,6 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
|
|||
Args:
|
||||
page_title: Page title (shown in browser tab, og:title).
|
||||
page_description: Page description (meta description, og:description).
|
||||
page_url: Canonical absolute URL for the current page.
|
||||
page_image: Image URL for og:image meta tag.
|
||||
page_image_width: Width of the image in pixels.
|
||||
page_image_height: Height of the image in pixels.
|
||||
|
|
@ -99,8 +115,6 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
|
|||
"og_type": og_type,
|
||||
"robots_directive": robots_directive,
|
||||
}
|
||||
if page_url:
|
||||
context["page_url"] = page_url
|
||||
if page_image:
|
||||
context["page_image"] = page_image
|
||||
if page_image_width and page_image_height:
|
||||
|
|
@ -261,33 +275,177 @@ 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 feeds and explains how to use them.
|
||||
"""View for /docs/rss that lists all available RSS feeds.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object.
|
||||
|
||||
Returns:
|
||||
HttpResponse: The rendered documentation page.
|
||||
Rendered HTML response with list of RSS feeds.
|
||||
"""
|
||||
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()
|
||||
|
||||
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 ""
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
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")),
|
||||
page_title="Twitch RSS Feeds",
|
||||
page_description="RSS feeds for Twitch drops.",
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"core/docs_rss.html",
|
||||
"twitch/docs_rss.html",
|
||||
{
|
||||
"game": sample_game,
|
||||
"feeds": feeds,
|
||||
"filtered_feeds": filtered_feeds,
|
||||
"sample_game": sample_game,
|
||||
"sample_org": sample_org,
|
||||
**seo_context,
|
||||
},
|
||||
)
|
||||
|
|
@ -432,7 +590,7 @@ def debug_view(request: HttpRequest) -> HttpResponse:
|
|||
)
|
||||
context.update(seo_context)
|
||||
|
||||
return render(request, "core/debug.html", context)
|
||||
return render(request, "twitch/debug.html", context)
|
||||
|
||||
|
||||
# MARK: /datasets/
|
||||
|
|
@ -543,7 +701,7 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse:
|
|||
"dataset_count": len(datasets),
|
||||
**seo_context,
|
||||
}
|
||||
return render(request, "core/dataset_backups.html", context)
|
||||
return render(request, "twitch/dataset_backups.html", context)
|
||||
|
||||
|
||||
def dataset_backup_download_view(
|
||||
|
|
@ -678,11 +836,10 @@ def search_view(request: HttpRequest) -> HttpResponse:
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title=page_title,
|
||||
page_description=page_description,
|
||||
page_url=request.build_absolute_uri(reverse("core:search")),
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"core/search_results.html",
|
||||
"twitch/search_results.html",
|
||||
{"query": query, "results": results, **seo_context},
|
||||
)
|
||||
|
||||
|
|
@ -794,9 +951,7 @@ def dashboard(request: HttpRequest) -> HttpResponse:
|
|||
|
||||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title="Twitch/Kick Drops",
|
||||
page_description=(
|
||||
"RSS feeds, historical data, and information about Twitch and Kick drops, campaigns, rewards, and more."
|
||||
),
|
||||
page_description=("Twitch and Kick drops."),
|
||||
og_type="website",
|
||||
schema_data=website_schema,
|
||||
)
|
||||
|
|
|
|||
129
kick/views.py
129
kick/views.py
|
|
@ -1,9 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
||||
from django.core.paginator import EmptyPage
|
||||
|
|
@ -27,7 +24,6 @@ if TYPE_CHECKING:
|
|||
from django.http import HttpRequest
|
||||
from django.http import HttpResponse
|
||||
|
||||
from core.seo import SeoMeta
|
||||
from kick.models import KickChannel
|
||||
from kick.models import KickReward
|
||||
|
||||
|
|
@ -37,38 +33,26 @@ logger: logging.Logger = logging.getLogger("ttvdrops.kick.views")
|
|||
def _build_seo_context(
|
||||
page_title: str = "Kick Drops",
|
||||
page_description: str | None = None,
|
||||
seo_meta: SeoMeta | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build SEO context for template rendering.
|
||||
og_type: str = "website",
|
||||
robots_directive: str = "index, follow",
|
||||
) -> dict[str, str]:
|
||||
"""Build minimal SEO context for template rendering.
|
||||
|
||||
Args:
|
||||
page_title: The title of the page for <title> and OG tags.
|
||||
page_description: Optional description for meta and OG tags.
|
||||
seo_meta: Optional typed SEO metadata.
|
||||
og_type: Open Graph type (default "website").
|
||||
robots_directive: Value for meta robots tag (default "index, follow").
|
||||
|
||||
Returns:
|
||||
A dictionary with SEO-related context variables.
|
||||
"""
|
||||
context: dict[str, Any] = {
|
||||
return {
|
||||
"page_title": page_title,
|
||||
"page_description": page_description or "Archive of Kick drops.",
|
||||
"og_type": "website",
|
||||
"robots_directive": "index, follow",
|
||||
"og_type": og_type,
|
||||
"robots_directive": robots_directive,
|
||||
}
|
||||
if seo_meta:
|
||||
if seo_meta.get("page_url"):
|
||||
context["page_url"] = seo_meta["page_url"]
|
||||
if seo_meta.get("og_type"):
|
||||
context["og_type"] = seo_meta["og_type"]
|
||||
if seo_meta.get("robots_directive"):
|
||||
context["robots_directive"] = seo_meta["robots_directive"]
|
||||
if seo_meta.get("schema_data"):
|
||||
context["schema_data"] = json.dumps(seo_meta["schema_data"])
|
||||
if seo_meta.get("published_date"):
|
||||
context["published_date"] = seo_meta["published_date"]
|
||||
if seo_meta.get("modified_date"):
|
||||
context["modified_date"] = seo_meta["modified_date"]
|
||||
return context
|
||||
|
||||
|
||||
def _build_breadcrumb_schema(items: list[dict[str, str]]) -> str:
|
||||
|
|
@ -227,7 +211,6 @@ def campaign_list_view(request: HttpRequest) -> HttpResponse:
|
|||
seo_context: dict[str, str] = _build_seo_context(
|
||||
page_title=title,
|
||||
page_description="Browse Kick drop campaigns.",
|
||||
seo_meta={"page_url": request.build_absolute_uri(base_url)},
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
|
|
@ -295,51 +278,9 @@ def campaign_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse:
|
|||
},
|
||||
])
|
||||
|
||||
campaign_url: str = request.build_absolute_uri(
|
||||
reverse("kick:campaign_detail", args=[campaign.kick_id]),
|
||||
)
|
||||
campaign_event: dict[str, Any] = {
|
||||
"@type": "Event",
|
||||
"name": campaign.name,
|
||||
"description": f"Kick drop campaign: {campaign.name}.",
|
||||
"url": campaign_url,
|
||||
"eventStatus": "https://schema.org/EventScheduled",
|
||||
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
|
||||
"location": {"@type": "VirtualLocation", "url": "https://kick.com"},
|
||||
}
|
||||
if campaign.starts_at:
|
||||
campaign_event["startDate"] = campaign.starts_at.isoformat()
|
||||
if campaign.ends_at:
|
||||
campaign_event["endDate"] = campaign.ends_at.isoformat()
|
||||
if campaign.organization:
|
||||
campaign_event["organizer"] = {
|
||||
"@type": "Organization",
|
||||
"name": campaign.organization.name,
|
||||
}
|
||||
|
||||
webpage_node: dict[str, Any] = {
|
||||
"@type": "WebPage",
|
||||
"url": campaign_url,
|
||||
"datePublished": campaign.added_at.isoformat(),
|
||||
"dateModified": campaign.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
campaign_schema: dict[str, Any] = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
campaign_event,
|
||||
webpage_node,
|
||||
],
|
||||
}
|
||||
|
||||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
seo_context: dict[str, str] = _build_seo_context(
|
||||
page_title=campaign.name,
|
||||
page_description=f"Kick drop campaign: {campaign.name}.",
|
||||
seo_meta={
|
||||
"schema_data": campaign_schema,
|
||||
"published_date": campaign.added_at.isoformat(),
|
||||
"modified_date": campaign.updated_at.isoformat(),
|
||||
},
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
|
|
@ -438,27 +379,9 @@ def category_detail_view(request: HttpRequest, kick_id: int) -> HttpResponse:
|
|||
},
|
||||
])
|
||||
|
||||
category_url: str = request.build_absolute_uri(
|
||||
reverse("kick:game_detail", args=[category.kick_id]),
|
||||
)
|
||||
category_schema: dict[str, Any] = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
"name": category.name,
|
||||
"description": f"Kick drop campaigns for {category.name}.",
|
||||
"url": category_url,
|
||||
"datePublished": category.added_at.isoformat(),
|
||||
"dateModified": category.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
seo_context: dict[str, str] = _build_seo_context(
|
||||
page_title=category.name,
|
||||
page_description=f"Kick drop campaigns for {category.name}.",
|
||||
seo_meta={
|
||||
"schema_data": category_schema,
|
||||
"published_date": category.added_at.isoformat(),
|
||||
"modified_date": category.updated_at.isoformat(),
|
||||
},
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
|
|
@ -543,37 +466,9 @@ def organization_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse
|
|||
},
|
||||
])
|
||||
|
||||
org_url: str = request.build_absolute_uri(
|
||||
reverse("kick:organization_detail", args=[org.kick_id]),
|
||||
)
|
||||
organization_node: dict[str, Any] = {
|
||||
"@type": "Organization",
|
||||
"name": org.name,
|
||||
"url": org_url,
|
||||
"description": f"Kick drop campaigns by {org.name}.",
|
||||
}
|
||||
webpage_node: dict[str, Any] = {
|
||||
"@type": "WebPage",
|
||||
"url": org_url,
|
||||
"datePublished": org.added_at.isoformat(),
|
||||
"dateModified": org.updated_at.isoformat(),
|
||||
}
|
||||
org_schema: dict[str, Any] = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
organization_node,
|
||||
webpage_node,
|
||||
],
|
||||
}
|
||||
|
||||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
seo_context: dict[str, str] = _build_seo_context(
|
||||
page_title=org.name,
|
||||
page_description=f"Kick drop campaigns by {org.name}.",
|
||||
seo_meta={
|
||||
"schema_data": org_schema,
|
||||
"published_date": org.added_at.isoformat(),
|
||||
"modified_date": org.updated_at.isoformat(),
|
||||
},
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@
|
|||
title="Atom feed for Twitch campaigns">[atom]</a>
|
||||
<a href="{% url 'core:campaign_feed_discord' %}"
|
||||
title="Discord feed for Twitch campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
</header>
|
||||
{% if campaigns_by_game %}
|
||||
|
|
@ -224,7 +223,6 @@
|
|||
title="Atom feed for all Kick campaigns">[atom]</a>
|
||||
<a href="{% url 'kick:campaign_feed_discord' %}"
|
||||
title="Discord feed for all Kick campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
</header>
|
||||
{% if kick_campaigns_by_game %}
|
||||
|
|
|
|||
|
|
@ -1,155 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}
|
||||
RSS Feeds Documentation
|
||||
{% endblock title %}
|
||||
{% block content %}
|
||||
<main>
|
||||
<h1>RSS Feeds Documentation</h1>
|
||||
<p>
|
||||
You have three types of feeds available for Twitch drops data: RSS, Atom, and Discord.
|
||||
RSS and Atom feeds are similar and can be used in any RSS reader application.
|
||||
The main difference is that Atom feeds include additional metadata and support for more complex content, while RSS feeds are more widely supported by older applications.
|
||||
</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. Future enhancements may include Discord-specific formatting or content.
|
||||
</p>
|
||||
<section>
|
||||
<h2>Global RSS Feeds</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>RSS</th>
|
||||
<th>Atom</th>
|
||||
<th>Discord</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>New Twitch games</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/rss/games/">https://ttvdrops.lovinator.space/rss/games/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/atom/games/">https://ttvdrops.lovinator.space/atom/games/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/discord/games/">https://ttvdrops.lovinator.space/discord/games/</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Latest Twitch drop campaigns</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/rss/campaigns/">https://ttvdrops.lovinator.space/rss/campaigns/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/atom/campaigns/">https://ttvdrops.lovinator.space/atom/campaigns/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/discord/campaigns/">https://ttvdrops.lovinator.space/discord/campaigns/</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Latest Twitch organizations</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/rss/organizations/">https://ttvdrops.lovinator.space/rss/organizations/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/atom/organizations/">https://ttvdrops.lovinator.space/atom/organizations/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/discord/organizations/">https://ttvdrops.lovinator.space/discord/organizations/</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Latest Twitch reward campaigns</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/rss/reward-campaigns/">https://ttvdrops.lovinator.space/rss/reward-campaigns/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/atom/reward-campaigns/">https://ttvdrops.lovinator.space/atom/reward-campaigns/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/discord/reward-campaigns/">https://ttvdrops.lovinator.space/discord/reward-campaigns/</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Latest Kick campaigns</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/kick/rss/campaigns/">https://ttvdrops.lovinator.space/kick/rss/campaigns/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/kick/atom/campaigns/">https://ttvdrops.lovinator.space/kick/atom/campaigns/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/discord/campaigns/">https://ttvdrops.lovinator.space/discord/campaigns/</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Latest Kick games</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/kick/rss/games/">https://ttvdrops.lovinator.space/kick/rss/games/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/kick/atom/games/">https://ttvdrops.lovinator.space/kick/atom/games/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/discord/games/">https://ttvdrops.lovinator.space/discord/games/</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Latest Kick organizations</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/kick/rss/organizations/">https://ttvdrops.lovinator.space/kick/rss/organizations/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/kick/atom/organizations/">https://ttvdrops.lovinator.space/kick/atom/organizations/</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://ttvdrops.lovinator.space/discord/organizations/">https://ttvdrops.lovinator.space/discord/organizations/</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% if game %}
|
||||
<section>
|
||||
<h2>Filtered RSS Feeds</h2>
|
||||
<p>You can subscribe to RSS feeds scoped to a specific game.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Game</th>
|
||||
<th>RSS</th>
|
||||
<th>Atom</th>
|
||||
<th>Discord</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ game.display_name }}</td>
|
||||
<td>
|
||||
<a href="{% url 'core:game_campaign_feed' game.twitch_id %}">
|
||||
https://ttvdrops.lovinator.space/rss/games/{{ game.twitch_id }}/
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'core:game_campaign_feed_atom' game.twitch_id %}">
|
||||
https://ttvdrops.lovinator.space/atom/games/{{ game.twitch_id }}/
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}">
|
||||
https://ttvdrops.lovinator.space/discord/games/{{ game.twitch_id }}/
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
{% endblock content %}
|
||||
|
|
@ -16,6 +16,9 @@
|
|||
{# - robots_directive: str - robots meta content (default: "index, follow") #}
|
||||
{# #}
|
||||
{% load static %}
|
||||
{# Preconnect to external resources for performance #}
|
||||
<link rel="preconnect" href="https://static-cdn.jtvnw.net" />
|
||||
<link rel="dns-prefetch" href="https://static-cdn.jtvnw.net" />
|
||||
{# Description meta tag #}
|
||||
<meta name="description"
|
||||
content="{% firstof page_description 'ttvdrops - Track Twitch drops.' %}" />
|
||||
|
|
@ -23,7 +26,7 @@
|
|||
<meta name="robots"
|
||||
content="{% firstof robots_directive 'index, follow' %}" />
|
||||
{# Author and Copyright #}
|
||||
<meta name="author" content="TheLovinator" />
|
||||
<meta name="author" content="TheLovinator1" />
|
||||
<meta name="copyright"
|
||||
content="This work is dedicated to the public domain under CC0 1.0 Universal." />
|
||||
{# Open Graph tags for social sharing #}
|
||||
|
|
@ -42,14 +45,10 @@
|
|||
{% endif %}
|
||||
{# Twitter Card tags for rich previews #}
|
||||
<meta name="twitter:card"
|
||||
content="{% if page_image %}
|
||||
summary_large_image
|
||||
{% else %}
|
||||
summary
|
||||
{% endif %}" />
|
||||
content="{% if page_image %}summary_large_image{% else %}summary{% endif %}" />
|
||||
<meta name="twitter:title" content="{% firstof page_title 'ttvdrops' %}" />
|
||||
<meta name="twitter:description"
|
||||
content="{% firstof page_description 'ttvdrops - Twitch and Kick drops.' %}" />
|
||||
content="{% firstof page_description 'ttvdrops - Track Twitch drops.' %}" />
|
||||
{% if page_image %}<meta name="twitter:image" content="{{ page_image }}" />{% endif %}
|
||||
{# Article dates for content pages #}
|
||||
{% if published_date %}<meta property="article:published_time" content="{{ published_date }}" />{% endif %}
|
||||
|
|
@ -62,22 +61,6 @@
|
|||
{% for link in pagination_info %}<link rel="{{ link.rel }}" href="{{ link.url }}" />{% endfor %}
|
||||
{% endif %}
|
||||
{# Schema.org JSON-LD structured data #}
|
||||
{% if schema_data %}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
{
|
||||
schema_data | safe
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if schema_data %}<script type="application/ld+json">{{ schema_data|safe }}</script>{% endif %}
|
||||
{# Breadcrumb schema #}
|
||||
{% if breadcrumb_schema %}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
{
|
||||
breadcrumb_schema | safe
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if breadcrumb_schema %}<script type="application/ld+json">{{ breadcrumb_schema|safe }}</script>{% endif %}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@
|
|||
title="Atom feed for {{ campaign.category.name }} campaigns">[atom]</a>
|
||||
<a href="{% url 'kick:game_campaign_feed_discord' campaign.category.kick_id %}"
|
||||
title="Discord feed for {{ campaign.category.name }} campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p style="margin: 0.25rem 0; color: #666;">
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@
|
|||
title="Atom feed for all campaigns">[atom]</a>
|
||||
<a href="{% url 'kick:campaign_feed_discord' %}"
|
||||
title="Discord feed for all campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
<form method="get" action="{% url 'kick:campaign_list' %}">
|
||||
<div style="display: flex;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@
|
|||
title="Atom feed for {{ category.name }} campaigns">[atom]</a>
|
||||
<a href="{% url 'kick:game_campaign_feed_discord' category.kick_id %}"
|
||||
title="Discord feed for {{ category.name }} campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
{% if category.kick_url %}
|
||||
<p style="margin: 0.25rem 0;">
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@
|
|||
title="Atom feed for all games">[atom]</a>
|
||||
<a href="{% url 'kick:game_feed_discord' %}"
|
||||
title="Discord feed for all games">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
{% if categories %}
|
||||
<ul>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@
|
|||
title="Atom feed for all campaigns">[atom]</a>
|
||||
<a href="{% url 'kick:campaign_feed_discord' %}"
|
||||
title="Discord feed for all campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
<hr />
|
||||
{% if active_campaigns %}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@
|
|||
title="Atom feed for all organizations">[atom]</a>
|
||||
<a href="{% url 'kick:organization_feed_discord' %}"
|
||||
title="Discord feed for all organizations">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
{% if orgs %}
|
||||
<ul>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@
|
|||
<h1>{{ badge_sets.count }} Twitch Chat Badges</h1>
|
||||
{% if badge_sets %}
|
||||
{% for data in badge_data %}
|
||||
<!-- {{ data.set.set_id }} - {{ data.badges|length }} version{% if data.badges|length > 1 %}s{% endif %} -->
|
||||
<h2>
|
||||
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">{{ data.set.set_id }}</a>
|
||||
</h2>
|
||||
<table>
|
||||
{% for badge in data.badges %}
|
||||
<!-- {{ badge.title }} {% if badge.description != badge.title %}- {{ badge.description }}{% else %}{% endif %} -->
|
||||
<tr>
|
||||
<td style="width: 40px;">
|
||||
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<table>
|
||||
<tbody>
|
||||
{% for badge in badges %}
|
||||
<!-- {{ badge.title }} {% if badge.description != badge.title %}- {{ badge.description }}{% else %}{% endif %} -->
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ badge.badge_id }}</code>
|
||||
|
|
@ -47,4 +48,5 @@
|
|||
{% else %}
|
||||
<p>No badges found in this set.</p>
|
||||
{% endif %}
|
||||
{{ set_data|safe }}
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -45,14 +45,6 @@
|
|||
</div>
|
||||
<!-- Campaign description -->
|
||||
<p>{{ campaign.description|linebreaksbr }}</p>
|
||||
<small>
|
||||
Published:
|
||||
<time datetime="{{ campaign.added_at|date:'c' }}"
|
||||
title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}">{{ campaign.added_at|date:"M d, Y H:i" }}</time>
|
||||
· Last updated:
|
||||
<time datetime="{{ campaign.updated_at|date:'c' }}"
|
||||
title="{{ campaign.updated_at|date:'DATETIME_FORMAT' }}">{{ campaign.updated_at|date:"M d, Y H:i" }}</time>
|
||||
</small>
|
||||
<!-- Campaign end times -->
|
||||
<div>
|
||||
{% if campaign.end_at < now %}
|
||||
|
|
@ -104,7 +96,6 @@
|
|||
title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a>
|
||||
<a href="{% url 'core:game_campaign_feed_discord' campaign.game.twitch_id %}"
|
||||
title="Discord feed for {{ campaign.game.display_name }} campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -115,6 +106,7 @@
|
|||
<table style="border-collapse: collapse; width: 100%;">
|
||||
<tbody>
|
||||
{% for drop in drops %}
|
||||
<!-- {{ drop.drop.name }} - {{ drop.drop.benefits.all|join:", " }} -->
|
||||
<tr>
|
||||
<td>
|
||||
{% for benefit in drop.drop.benefits.all %}
|
||||
|
|
@ -177,6 +169,7 @@
|
|||
<h5>Allowed Channels</h5>
|
||||
<div>
|
||||
{% for channel in allowed_channels %}
|
||||
<!-- {{ channel.display_name }} https://www.twitch.tv/{{ channel.display_name }} -->
|
||||
<a href="{% url 'twitch:channel_detail' channel.twitch_id %}">{{ channel.display_name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -188,4 +181,6 @@
|
|||
Go to a participating live channel
|
||||
</a>
|
||||
{% endif %}
|
||||
<!-- Campaign JSON -->
|
||||
{{ campaign_data|safe }}
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load image_tags %}
|
||||
{% load image_tags %}
|
||||
{% block title %}
|
||||
Drop Campaigns
|
||||
{% endblock title %}
|
||||
|
|
@ -30,7 +31,6 @@
|
|||
title="Atom feed for all campaigns">[atom]</a>
|
||||
<a href="{% url 'core:campaign_feed_discord' %}"
|
||||
title="Discord feed for all campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</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 %}"
|
||||
|
|
@ -47,6 +47,7 @@
|
|||
<select name="game">
|
||||
<option value="">All Games</option>
|
||||
{% for game in games %}
|
||||
<!-- Game option with Twitch ID {{ game.twitch_id }} and display name "{{ game.display_name }}" -->
|
||||
<option value="{{ game.twitch_id }}"
|
||||
{% if selected_game == game.twitch_id %}selected{% endif %}>
|
||||
{{ game.display_name|default:game.name|default:game.slug|default:game.twitch_id }}
|
||||
|
|
@ -57,6 +58,7 @@
|
|||
<select id="status" name="status">
|
||||
<option value="">All Statuses</option>
|
||||
{% for status in status_options %}
|
||||
<!-- Status option "{{ status }}" -->
|
||||
<option value="{{ status }}"
|
||||
{% if selected_status == status %}selected{% endif %}>{{ status|title }}</option>
|
||||
{% endfor %}
|
||||
|
|
@ -69,6 +71,7 @@
|
|||
{% if campaigns %}
|
||||
{% regroup campaigns by game as campaigns_by_game %}
|
||||
{% for game_group in campaigns_by_game %}
|
||||
<!-- Game group for game "{{ game_group.grouper.display_name }}" with {{ game_group.list|length }} campaigns -->
|
||||
<section>
|
||||
<div style="display: flex; gap: 1rem;">
|
||||
<div>
|
||||
|
|
@ -105,6 +108,9 @@
|
|||
<div style="overflow-x: auto;">
|
||||
<div style="display: flex; gap: 1rem; min-width: max-content;">
|
||||
{% for campaign in game_group.list %}
|
||||
<!-- Campaign "{{ campaign.clean_name }}" with Twitch ID {{ campaign.twitch_id }} -->
|
||||
<!-- https://ttvdrops.lovinator.space{% url 'twitch:campaign_detail' campaign.twitch_id %} -->
|
||||
<!-- https://ttvdrops.lovinator.space{{ campaign.image_best_url }} -->
|
||||
<article style="display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
|
@ -159,6 +165,7 @@
|
|||
{% endif %}
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<!-- {{ page_obj.paginator.count }} total campaigns, showing {{ page_obj.start_index }} to {{ page_obj.end_index }} on page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} -->
|
||||
<nav style="text-align: center;">
|
||||
<div>
|
||||
{% if page_obj.has_previous %}
|
||||
|
|
|
|||
|
|
@ -20,26 +20,22 @@
|
|||
</iframe>
|
||||
<!-- Channel Info -->
|
||||
<p>Channel ID: {{ channel.twitch_id }}</p>
|
||||
<p>
|
||||
Published:
|
||||
<time datetime="{{ channel.added_at|date:'c' }}"
|
||||
title="{{ channel.added_at|date:'DATETIME_FORMAT' }}">{{ channel.added_at|date:"M d, Y H:i" }}</time>
|
||||
· Last updated:
|
||||
<time datetime="{{ channel.updated_at|date:'c' }}"
|
||||
title="{{ channel.updated_at|date:'DATETIME_FORMAT' }}">{{ channel.updated_at|date:"M d, Y H:i" }}</time>
|
||||
</p>
|
||||
{% if active_campaigns %}
|
||||
<h5>Active Campaigns</h5>
|
||||
<table>
|
||||
<tbody>
|
||||
{% for campaign in active_campaigns %}
|
||||
<!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) -->
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a>
|
||||
{% if campaign.time_based_drops.all %}
|
||||
<!-- If the campaign has time-based drops, show the benefits in a nested div -->
|
||||
<div>
|
||||
<!-- swag swag swag {{campaign.sorted_benefits}} -->
|
||||
{% for benefit in campaign.sorted_benefits %}
|
||||
<!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) -->
|
||||
<!-- {{ benefit.image_best_url }} -->
|
||||
<span title="{{ benefit.name }}">
|
||||
{% if benefit.image_best_url or benefit.image_asset_url %}
|
||||
<!-- Show the benefit image if available -->
|
||||
|
|
@ -81,6 +77,7 @@
|
|||
<table>
|
||||
<tbody>
|
||||
{% for campaign in upcoming_campaigns %}
|
||||
<!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) -->
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a>
|
||||
|
|
@ -88,6 +85,7 @@
|
|||
<!-- If the campaign has time-based drops, show the benefits in a nested div -->
|
||||
<div>
|
||||
{% for benefit in campaign.sorted_benefits %}
|
||||
<!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) -->
|
||||
<span title="{{ benefit.name }}">
|
||||
{% if benefit.image_best_url or benefit.image_asset_url %}
|
||||
<!-- Show the benefit image if available -->
|
||||
|
|
@ -129,6 +127,7 @@
|
|||
<table>
|
||||
<tbody>
|
||||
{% for campaign in expired_campaigns %}
|
||||
<!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) -->
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a>
|
||||
|
|
@ -136,6 +135,7 @@
|
|||
<!-- If the campaign has time-based drops, show the benefits in a nested div -->
|
||||
<div>
|
||||
{% for benefit in campaign.sorted_benefits %}
|
||||
<!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) -->
|
||||
<span title="{{ benefit.name }}">
|
||||
{% if benefit.image_best_url or benefit.image_asset_url %}
|
||||
<img src="{{ benefit.image_best_url|default:benefit.image_asset_url }}"
|
||||
|
|
@ -173,4 +173,5 @@
|
|||
{% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %}
|
||||
<p>No campaigns found for this channel.</p>
|
||||
{% endif %}
|
||||
{{ channel_data|safe }}
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@
|
|||
title="Atom feed for campaigns">[atom]</a>
|
||||
<a href="{% url 'core:campaign_feed_discord' %}"
|
||||
title="Discord feed for campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
<hr />
|
||||
{% if campaigns_by_game %}
|
||||
|
|
@ -64,6 +63,7 @@
|
|||
<div style="flex: 1; overflow-x: auto;">
|
||||
<div style="display: flex; gap: 1rem; min-width: max-content;">
|
||||
{% for campaign_data in game_data.campaigns %}
|
||||
<!-- {{ campaign_data.campaign.name }} -->
|
||||
<article style="display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
|
@ -106,6 +106,7 @@
|
|||
{% if campaign_data.campaign.allow_is_enabled %}
|
||||
{% if campaign_data.allowed_channels %}
|
||||
{% for channel in campaign_data.allowed_channels|slice:":5" %}
|
||||
<!-- {{ channel.name }} -->
|
||||
<li style="margin-bottom: 0.1rem;">
|
||||
<a href="https://twitch.tv/{{ channel.name }}"
|
||||
rel="nofollow ugc"
|
||||
|
|
@ -131,12 +132,14 @@
|
|||
{% endif %}
|
||||
{% endif %}
|
||||
{% if campaign_data.allowed_channels|length > 5 %}
|
||||
<!-- {{ campaign_data.allowed_channels|length }} allowed channels -->
|
||||
<li style="margin-bottom: 0.1rem; color: #666; font-style: italic;">
|
||||
... and {{ campaign_data.allowed_channels|length|add:"-5" }} more
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if campaign_data.campaign.game.twitch_directory_url %}
|
||||
<!--{{ campaign_data.campaign.game.display_name }} Twitch directory URL: {{ campaign_data.campaign.game.twitch_directory_url }} -->
|
||||
<li>
|
||||
<a href="{{ campaign_data.campaign.game.twitch_directory_url }}"
|
||||
rel="nofollow ugc"
|
||||
|
|
@ -145,6 +148,7 @@
|
|||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<!-- {{ campaign_data.campaign.game.display_name }} Twitch directory URL not available -->
|
||||
<li>Failed to get Twitch directory URL :(</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
|
|||
104
templates/twitch/docs_rss.html
Normal file
104
templates/twitch/docs_rss.html
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}
|
||||
RSS Feeds Documentation
|
||||
{% endblock title %}
|
||||
{% block content %}
|
||||
<main>
|
||||
<h1>RSS Feeds Documentation</h1>
|
||||
<p>This page lists all available RSS feeds for TTVDrops.</p>
|
||||
<p>
|
||||
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>
|
||||
<p>These feeds contain all items across the entire site:</p>
|
||||
<ul>
|
||||
{% for feed in feeds %}
|
||||
<li>
|
||||
<h3>{{ feed.title }}</h3>
|
||||
<p>{{ feed.description }}</p>
|
||||
<p>
|
||||
<a href="{{ feed.url }}">Subscribe to {{ feed.title }} RSS Feed</a>
|
||||
{% if feed.atom_url %}
|
||||
|
|
||||
<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>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Filtered RSS Feeds</h2>
|
||||
<p>
|
||||
You can subscribe to RSS feeds scoped to a specific game or organization. When available, links below point to live examples; otherwise use the endpoint template.
|
||||
</p>
|
||||
<ul>
|
||||
{% for feed in filtered_feeds %}
|
||||
<li>
|
||||
<h3>{{ feed.title }}</h3>
|
||||
<p>{{ feed.description }}</p>
|
||||
<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>
|
||||
<a href="{{ feed.url }}">View a live example</a>
|
||||
{% if feed.atom_url %}
|
||||
|
|
||||
<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>
|
||||
{% 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>
|
||||
</section>
|
||||
<section>
|
||||
<h2>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>
|
||||
{% endblock content %}
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
{% block content %}
|
||||
<h1>Emotes</h1>
|
||||
{% for emote in emotes %}
|
||||
<!-- Emote from campaign {{ emote.campaign.name }} -->
|
||||
<!-- https://ttvdrops.lovinator.space{{ emote.image_url }} -->
|
||||
<a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}"
|
||||
title="{{ emote.campaign.name }}"
|
||||
style="display: inline-block">
|
||||
|
|
|
|||
|
|
@ -47,14 +47,6 @@
|
|||
Twitch ID: <a href="https://www.twitch.tv/directory/category/{{ game.slug|urlencode }}">{{ game.twitch_id }}</a>
|
||||
</div>
|
||||
<div>Twitch slug: {{ game.slug }}</div>
|
||||
<small>
|
||||
Published:
|
||||
<time datetime="{{ game.added_at|date:'c' }}"
|
||||
title="{{ game.added_at|date:'DATETIME_FORMAT' }}">{{ game.added_at|date:"M d, Y H:i" }}</time>
|
||||
· Last updated:
|
||||
<time datetime="{{ game.updated_at|date:'c' }}"
|
||||
title="{{ game.updated_at|date:'DATETIME_FORMAT' }}">{{ game.updated_at|date:"M d, Y H:i" }}</time>
|
||||
</small>
|
||||
<!-- RSS Feeds -->
|
||||
<div>
|
||||
<a href="{% url 'core:game_campaign_feed' game.twitch_id %}"
|
||||
|
|
@ -63,7 +55,6 @@
|
|||
title="Atom feed for {{ game.display_name }} campaigns">[atom]</a>
|
||||
<a href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}"
|
||||
title="Discord feed for {{ game.display_name }} campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -159,4 +150,5 @@
|
|||
{% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %}
|
||||
<p id="no-campaigns-message">No campaigns found for this game.</p>
|
||||
{% endif %}
|
||||
{{ game_data|safe }}
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@
|
|||
title="Atom feed for all games">[atom]</a>
|
||||
<a href="{% url 'core:game_feed_discord' %}"
|
||||
title="Discord feed for all games">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
<a href="{% url 'twitch:export_games_csv' %}"
|
||||
title="Export all games as CSV">[csv]</a>
|
||||
<a href="{% url 'twitch:export_games_json' %}"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@
|
|||
title="Atom feed for all games">[atom]</a>
|
||||
<a href="{% url 'core:game_feed_discord' %}"
|
||||
title="Discord feed for all games">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
<a href="{% url 'twitch:export_games_csv' %}"
|
||||
title="Export all games as CSV">[csv]</a>
|
||||
<a href="{% url 'twitch:export_games_json' %}"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
title="Atom feed for all organizations">[atom]</a>
|
||||
<a href="{% url 'core:organization_feed_discord' %}"
|
||||
title="Discord feed for all organizations">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
<a href="{% url 'twitch:export_organizations_csv' %}"
|
||||
title="Export all organizations as CSV">[csv]</a>
|
||||
<a href="{% url 'twitch:export_organizations_json' %}"
|
||||
|
|
@ -28,4 +27,5 @@
|
|||
{% else %}
|
||||
<p>No organizations found.</p>
|
||||
{% endif %}
|
||||
{{ orgs_data|safe }}
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -21,22 +21,14 @@
|
|||
{% endif %}
|
||||
{% endblock extra_head %}
|
||||
{% block content %}
|
||||
<h1>{{ organization.name }}</h1>
|
||||
<p>
|
||||
Published:
|
||||
<time datetime="{{ organization.added_at|date:'c' }}"
|
||||
title="{{ organization.added_at|date:'DATETIME_FORMAT' }}">{{ organization.added_at|date:"M d, Y H:i" }}</time>
|
||||
· Last updated:
|
||||
<time datetime="{{ organization.updated_at|date:'c' }}"
|
||||
title="{{ organization.updated_at|date:'DATETIME_FORMAT' }}">{{ organization.updated_at|date:"M d, Y H:i" }}</time>
|
||||
</p>
|
||||
<header>
|
||||
<h2>Games by {{ organization.name }}</h2>
|
||||
</header>
|
||||
<table>
|
||||
<h1 id="org-name">{{ organization.name }}</h1>
|
||||
<theader>
|
||||
<h2 id="games-header">Games by {{ organization.name }}</h2>
|
||||
</theader>
|
||||
<table id="games-table">
|
||||
<tbody>
|
||||
{% for game in games %}
|
||||
<tr>
|
||||
<tr id="game-row-{{ game.twitch_id }}">
|
||||
<td>
|
||||
<a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game }}</a>
|
||||
</td>
|
||||
|
|
@ -44,4 +36,6 @@
|
|||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
{{ org_data|safe }}
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -37,12 +37,11 @@
|
|||
<div style="margin-bottom: 1rem;">
|
||||
<a href="{% url 'core:reward_campaign_feed' %}"
|
||||
style="margin-right: 1rem"
|
||||
title="RSS feed for all reward campaigns">[rss]</a>
|
||||
title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a>
|
||||
<a href="{% url 'core:reward_campaign_feed_atom' %}"
|
||||
title="Atom feed for all reward campaigns">[atom]</a>
|
||||
<a href="{% url 'core:reward_campaign_feed_discord' %}"
|
||||
title="Discord feed for all reward campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
<!-- Campaign Summary -->
|
||||
{% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %}
|
||||
|
|
@ -165,4 +164,6 @@
|
|||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
{{ campaign_data|safe }}
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@
|
|||
title="Atom feed for all reward campaigns">[atom]</a>
|
||||
<a href="{% url 'core:reward_campaign_feed_discord' %}"
|
||||
title="Discord feed for all reward campaigns">[discord]</a>
|
||||
<a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a>
|
||||
</div>
|
||||
<p>This is an archive of old Twitch reward campaigns because we do not monitor them.</p>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -958,7 +958,7 @@ class TestChannelListView:
|
|||
client: Client,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Game detail view should no longer expose debug JSON payload in context."""
|
||||
"""Game detail JSON payload should use `owners` (M2M), not stale `owner`."""
|
||||
org: Organization = Organization.objects.create(
|
||||
twitch_id="org-game-detail",
|
||||
name="Org Game Detail",
|
||||
|
|
@ -970,10 +970,17 @@ class TestChannelListView:
|
|||
)
|
||||
game.owners.add(org)
|
||||
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
|
||||
url: str = reverse("twitch:game_detail", args=[game.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert "game_data" not in response.context
|
||||
|
||||
game_data: dict[str, Any] = response.context["game_data"]
|
||||
fields: dict[str, Any] = game_data["fields"]
|
||||
assert "owners" in fields
|
||||
assert fields["owners"] == [org.pk]
|
||||
assert "owner" not in fields
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_org_list_view(self, client: Client) -> None:
|
||||
|
|
@ -1006,29 +1013,14 @@ class TestChannelListView:
|
|||
|
||||
@pytest.mark.django_db
|
||||
def test_docs_rss_view(self, client: Client) -> None:
|
||||
"""Test docs RSS view returns 200."""
|
||||
"""Test docs RSS view returns 200 and has feeds in context."""
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(reverse("core:docs_rss"))
|
||||
assert response.status_code == 200
|
||||
|
||||
# Add Game with running campaign to ensure it's included in the RSS feed
|
||||
game: Game = Game.objects.create(
|
||||
twitch_id="g-rss",
|
||||
name="Game RSS",
|
||||
display_name="Game RSS",
|
||||
)
|
||||
|
||||
DropCampaign.objects.create(
|
||||
twitch_id="c-rss",
|
||||
name="Campaign RSS",
|
||||
game=game,
|
||||
start_at=timezone.now() - timedelta(days=1),
|
||||
end_at=timezone.now() + timedelta(days=1),
|
||||
operation_names=["DropCampaignDetails"],
|
||||
)
|
||||
|
||||
response = client.get(reverse("core:docs_rss"))
|
||||
assert response.status_code == 200
|
||||
assert "g-rss" in response.content.decode("utf-8")
|
||||
assert "feeds" in response.context
|
||||
assert "filtered_feeds" in response.context
|
||||
assert response.context["feeds"][0]["example_xml"]
|
||||
html: str = response.content.decode()
|
||||
assert '<code class="language-xml">' in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -1059,11 +1051,9 @@ class TestSEOHelperFunctions:
|
|||
context: dict[str, Any] = _build_seo_context(
|
||||
page_title="Test Title",
|
||||
page_description="Test Description",
|
||||
seo_meta={
|
||||
"page_image": "https://example.com/image.jpg",
|
||||
"og_type": "article",
|
||||
"schema_data": {"@context": "https://schema.org"},
|
||||
},
|
||||
page_image="https://example.com/image.jpg",
|
||||
og_type="article",
|
||||
schema_data={"@context": "https://schema.org"},
|
||||
)
|
||||
|
||||
assert context["page_title"] == "Test Title"
|
||||
|
|
@ -1093,16 +1083,14 @@ class TestSEOHelperFunctions:
|
|||
context: dict[str, Any] = _build_seo_context(
|
||||
page_title="Test",
|
||||
page_description="Desc",
|
||||
seo_meta={
|
||||
"page_image": "https://example.com/img.jpg",
|
||||
"og_type": "article",
|
||||
"schema_data": {},
|
||||
"breadcrumb_schema": breadcrumb,
|
||||
"pagination_info": [{"rel": "next", "url": "/page/2/"}],
|
||||
"published_date": now.isoformat(),
|
||||
"modified_date": now.isoformat(),
|
||||
"robots_directive": "noindex, follow",
|
||||
},
|
||||
page_image="https://example.com/img.jpg",
|
||||
og_type="article",
|
||||
schema_data={},
|
||||
breadcrumb_schema=breadcrumb,
|
||||
pagination_info=[{"rel": "next", "url": "/page/2/"}],
|
||||
published_date=now.isoformat(),
|
||||
modified_date=now.isoformat(),
|
||||
robots_directive="noindex, follow",
|
||||
)
|
||||
|
||||
# breadcrumb_schema is JSON-dumped, so parse it back
|
||||
|
|
@ -1711,6 +1699,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""VideoGame schema image should be an ImageObject, not a plain URL."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
url: str = reverse("twitch:game_detail", args=[game.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
|
@ -1731,6 +1720,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""VideoGame ImageObject should carry creditText and copyrightNotice."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
url: str = reverse("twitch:game_detail", args=[game.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
|
||||
|
|
@ -1745,6 +1735,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""VideoGame schema should omit image key when box_art is empty."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
game_no_art: Game = Game.objects.create(
|
||||
twitch_id="game-no-art",
|
||||
name="no_art_game",
|
||||
|
|
@ -1765,6 +1756,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""VideoGame schema publisher name should match the owning organization."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
url: str = reverse("twitch:game_detail", args=[game.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
|
||||
|
|
@ -1779,6 +1771,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""publisher.name and image.creditText should be the same value."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
url: str = reverse("twitch:game_detail", args=[game.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
|
||||
|
|
@ -1791,6 +1784,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When owner.name is empty, twitch_id is used as credit fallback."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
nameless_org: Organization = Organization.objects.create(
|
||||
twitch_id="org-nameless",
|
||||
name="",
|
||||
|
|
@ -1818,6 +1812,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Event schema image should be an ImageObject, not a plain URL string."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
|
@ -1838,6 +1833,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Event ImageObject should carry creditText and copyrightNotice."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
|
||||
|
|
@ -1853,6 +1849,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Event schema should omit image key when campaign has no image."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
campaign_no_img: DropCampaign = DropCampaign.objects.create(
|
||||
twitch_id="camp-no-img",
|
||||
name="No Image Campaign",
|
||||
|
|
@ -1874,6 +1871,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Event schema organizer name should match the owning organization."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
|
||||
|
|
@ -1887,6 +1885,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""organizer.name and image.creditText should be the same value."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
|
||||
response: _MonkeyPatchedWSGIResponse = client.get(url)
|
||||
|
||||
|
|
@ -1899,6 +1898,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When campaign has no owning org, creditText falls back to 'Twitch'."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
game_no_owner: Game = Game.objects.create(
|
||||
twitch_id="game-no-owner",
|
||||
name="no_owner_game",
|
||||
|
|
@ -1926,6 +1926,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When one owner is 'Twitch Gaming' and another is not, the non-generic one is used."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
twitch_gaming: Organization = Organization.objects.create(
|
||||
twitch_id="twitch-gaming",
|
||||
name="Twitch Gaming",
|
||||
|
|
@ -1955,6 +1956,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When the only owner is 'Twitch Gaming', it is still used (no other choice)."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
twitch_gaming: Organization = Organization.objects.create(
|
||||
twitch_id="twitch-gaming-solo",
|
||||
name="Twitch Gaming",
|
||||
|
|
@ -1979,6 +1981,7 @@ class TestImageObjectStructuredData:
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Campaign schema prefers a non-generic publisher over 'Twitch Gaming'."""
|
||||
monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data)
|
||||
twitch_gaming: Organization = Organization.objects.create(
|
||||
twitch_id="twitch-gaming-camp",
|
||||
name="Twitch Gaming",
|
||||
|
|
|
|||
538
twitch/views.py
538
twitch/views.py
|
|
@ -1,5 +1,3 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
|
|
@ -14,6 +12,7 @@ from django.core.paginator import EmptyPage
|
|||
from django.core.paginator import Page
|
||||
from django.core.paginator import PageNotAnInteger
|
||||
from django.core.paginator import Paginator
|
||||
from django.core.serializers import serialize
|
||||
from django.db.models import Case
|
||||
from django.db.models import Count
|
||||
from django.db.models import Prefetch
|
||||
|
|
@ -27,6 +26,9 @@ from django.urls import reverse
|
|||
from django.utils import timezone
|
||||
from django.views.generic import DetailView
|
||||
from django.views.generic import ListView
|
||||
from pygments import highlight
|
||||
from pygments.formatters import HtmlFormatter
|
||||
from pygments.lexers.data import JsonLexer
|
||||
|
||||
from twitch.models import Channel
|
||||
from twitch.models import ChatBadge
|
||||
|
|
@ -42,8 +44,6 @@ if TYPE_CHECKING:
|
|||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
|
||||
from core.seo import SeoMeta
|
||||
|
||||
logger: logging.Logger = logging.getLogger("ttvdrops.views")
|
||||
|
||||
MIN_QUERY_LENGTH_FOR_FTS = 3
|
||||
|
|
@ -89,50 +89,66 @@ def _truncate_description(text: str, max_length: int = 160) -> str:
|
|||
return text[:max_length].rsplit(" ", 1)[0] + "…"
|
||||
|
||||
|
||||
def _build_seo_context(
|
||||
def _build_seo_context( # noqa: PLR0913, PLR0917
|
||||
page_title: str = "ttvdrops",
|
||||
page_description: str | None = None,
|
||||
seo_meta: SeoMeta | None = None,
|
||||
page_image: str | None = None,
|
||||
page_image_width: int | None = None,
|
||||
page_image_height: int | None = None,
|
||||
og_type: str = "website",
|
||||
schema_data: dict[str, Any] | None = None,
|
||||
breadcrumb_schema: dict[str, Any] | None = None,
|
||||
pagination_info: list[dict[str, str]] | None = None,
|
||||
published_date: str | None = None,
|
||||
modified_date: str | None = None,
|
||||
robots_directive: str = "index, follow",
|
||||
) -> dict[str, Any]:
|
||||
"""Build SEO context for template rendering.
|
||||
|
||||
Args:
|
||||
page_title: Page title (shown in browser tab, og:title).
|
||||
page_description: Page description (meta description, og:description).
|
||||
seo_meta: Optional typed SEO metadata with image, schema, breadcrumb,
|
||||
pagination, OpenGraph, and date fields.
|
||||
page_image: Image URL for og:image meta tag.
|
||||
page_image_width: Width of the image in pixels.
|
||||
page_image_height: Height of the image in pixels.
|
||||
og_type: OpenGraph type (e.g., "website", "article").
|
||||
schema_data: Dict representation of Schema.org JSON-LD data.
|
||||
breadcrumb_schema: Breadcrumb schema dict for navigation hierarchy.
|
||||
pagination_info: List of dicts with "rel" (prev|next|first|last) and "url".
|
||||
published_date: ISO 8601 published date (e.g., "2025-01-01T00:00:00Z").
|
||||
modified_date: ISO 8601 modified date.
|
||||
robots_directive: Robots meta content (e.g., "index, follow" or "noindex").
|
||||
|
||||
Returns:
|
||||
Dict with SEO context variables to pass to render().
|
||||
"""
|
||||
# TODO(TheLovinator): Instead of having so many parameters, # noqa: TD003
|
||||
# consider having a single "seo_info" parameter that
|
||||
# can contain all of these optional fields. This would make
|
||||
# it easier to extend in the future without changing the
|
||||
# function signature.
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"page_title": page_title,
|
||||
"page_description": page_description or DEFAULT_SITE_DESCRIPTION,
|
||||
"og_type": "website",
|
||||
"robots_directive": "index, follow",
|
||||
"og_type": og_type,
|
||||
"robots_directive": robots_directive,
|
||||
}
|
||||
if seo_meta:
|
||||
if seo_meta.get("page_url"):
|
||||
context["page_url"] = seo_meta["page_url"]
|
||||
if seo_meta.get("og_type"):
|
||||
context["og_type"] = seo_meta["og_type"]
|
||||
if seo_meta.get("robots_directive"):
|
||||
context["robots_directive"] = seo_meta["robots_directive"]
|
||||
if seo_meta.get("page_image"):
|
||||
context["page_image"] = seo_meta["page_image"]
|
||||
if seo_meta.get("page_image_width") and seo_meta.get("page_image_height"):
|
||||
context["page_image_width"] = seo_meta["page_image_width"]
|
||||
context["page_image_height"] = seo_meta["page_image_height"]
|
||||
if seo_meta.get("schema_data"):
|
||||
context["schema_data"] = json.dumps(seo_meta["schema_data"])
|
||||
if seo_meta.get("breadcrumb_schema"):
|
||||
context["breadcrumb_schema"] = json.dumps(seo_meta["breadcrumb_schema"])
|
||||
if seo_meta.get("pagination_info"):
|
||||
context["pagination_info"] = seo_meta["pagination_info"]
|
||||
if seo_meta.get("published_date"):
|
||||
context["published_date"] = seo_meta["published_date"]
|
||||
if seo_meta.get("modified_date"):
|
||||
context["modified_date"] = seo_meta["modified_date"]
|
||||
if page_image:
|
||||
context["page_image"] = page_image
|
||||
if page_image_width and page_image_height:
|
||||
context["page_image_width"] = page_image_width
|
||||
context["page_image_height"] = page_image_height
|
||||
if schema_data:
|
||||
context["schema_data"] = json.dumps(schema_data)
|
||||
if breadcrumb_schema:
|
||||
context["breadcrumb_schema"] = json.dumps(breadcrumb_schema)
|
||||
if pagination_info:
|
||||
context["pagination_info"] = pagination_info
|
||||
if published_date:
|
||||
context["published_date"] = published_date
|
||||
if modified_date:
|
||||
context["modified_date"] = modified_date
|
||||
return context
|
||||
|
||||
|
||||
|
|
@ -260,6 +276,14 @@ def org_list_view(request: HttpRequest) -> HttpResponse:
|
|||
"""
|
||||
orgs: QuerySet[Organization] = Organization.objects.all().order_by("name")
|
||||
|
||||
# Serialize all organizations
|
||||
serialized_orgs: str = serialize(
|
||||
"json",
|
||||
orgs,
|
||||
fields=("twitch_id", "name", "added_at", "updated_at"),
|
||||
)
|
||||
orgs_data: list[dict] = json.loads(serialized_orgs)
|
||||
|
||||
# CollectionPage schema for organizations list
|
||||
collection_schema: dict[str, str] = {
|
||||
"@context": "https://schema.org",
|
||||
|
|
@ -272,10 +296,11 @@ def org_list_view(request: HttpRequest) -> HttpResponse:
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title="Twitch Organizations",
|
||||
page_description="List of Twitch organizations.",
|
||||
seo_meta={"schema_data": collection_schema},
|
||||
schema_data=collection_schema,
|
||||
)
|
||||
context: dict[str, Any] = {
|
||||
"orgs": orgs,
|
||||
"orgs_data": format_and_color_json(orgs_data),
|
||||
**seo_context,
|
||||
}
|
||||
|
||||
|
|
@ -283,7 +308,7 @@ def org_list_view(request: HttpRequest) -> HttpResponse:
|
|||
|
||||
|
||||
# MARK: /organizations/<twitch_id>/
|
||||
def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse:
|
||||
def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914
|
||||
"""Function-based view for organization detail.
|
||||
|
||||
Args:
|
||||
|
|
@ -304,6 +329,30 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
|
|||
|
||||
games: QuerySet[Game] = organization.games.all() # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
serialized_org: str = serialize(
|
||||
"json",
|
||||
[organization],
|
||||
fields=("twitch_id", "name", "added_at", "updated_at"),
|
||||
)
|
||||
org_data: list[dict] = json.loads(serialized_org)
|
||||
|
||||
if games.exists():
|
||||
serialized_games: str = serialize(
|
||||
"json",
|
||||
games,
|
||||
fields=(
|
||||
"twitch_id",
|
||||
"slug",
|
||||
"name",
|
||||
"display_name",
|
||||
"box_art",
|
||||
"added_at",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
games_data: list[dict] = json.loads(serialized_games)
|
||||
org_data[0]["fields"]["games"] = games_data
|
||||
|
||||
org_name: str = organization.name or organization.twitch_id
|
||||
games_count: int = games.count()
|
||||
s: Literal["", "s"] = "" if games_count == 1 else "s"
|
||||
|
|
@ -312,25 +361,13 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
|
|||
url: str = request.build_absolute_uri(
|
||||
reverse("twitch:organization_detail", args=[organization.twitch_id]),
|
||||
)
|
||||
organization_node: dict[str, Any] = {
|
||||
org_schema: dict[str, str | dict[str, str]] = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": org_name,
|
||||
"url": url,
|
||||
"description": org_description,
|
||||
}
|
||||
webpage_node: dict[str, Any] = {
|
||||
"@type": "WebPage",
|
||||
"url": url,
|
||||
"datePublished": organization.added_at.isoformat(),
|
||||
"dateModified": organization.updated_at.isoformat(),
|
||||
}
|
||||
org_schema: dict[str, Any] = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
organization_node,
|
||||
webpage_node,
|
||||
],
|
||||
}
|
||||
|
||||
# Breadcrumb schema
|
||||
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
|
||||
|
|
@ -347,16 +384,14 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title=org_name,
|
||||
page_description=org_description,
|
||||
seo_meta={
|
||||
"schema_data": org_schema,
|
||||
"breadcrumb_schema": breadcrumb_schema,
|
||||
"published_date": organization.added_at.isoformat(),
|
||||
"modified_date": organization.updated_at.isoformat(),
|
||||
},
|
||||
schema_data=org_schema,
|
||||
breadcrumb_schema=breadcrumb_schema,
|
||||
modified_date=organization.updated_at.isoformat(),
|
||||
)
|
||||
context: dict[str, Any] = {
|
||||
"organization": organization,
|
||||
"games": games,
|
||||
"org_data": format_and_color_json(org_data[0]),
|
||||
**seo_context,
|
||||
}
|
||||
|
||||
|
|
@ -446,11 +481,8 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title=title,
|
||||
page_description=description,
|
||||
seo_meta={
|
||||
"page_url": request.build_absolute_uri(base_url),
|
||||
"pagination_info": pagination_info,
|
||||
"schema_data": collection_schema,
|
||||
},
|
||||
pagination_info=pagination_info,
|
||||
schema_data=collection_schema,
|
||||
)
|
||||
context: dict[str, Any] = {
|
||||
"campaigns": campaigns,
|
||||
|
|
@ -467,6 +499,22 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
|
|||
return render(request, "twitch/campaign_list.html", context)
|
||||
|
||||
|
||||
def format_and_color_json(data: dict[str, Any] | list[dict] | str) -> str:
|
||||
"""Format and color a JSON string for HTML display.
|
||||
|
||||
Args:
|
||||
data: Either a dictionary, list of dictionaries, or a JSON string to format.
|
||||
|
||||
Returns:
|
||||
str: The formatted code with HTML styles.
|
||||
"""
|
||||
if isinstance(data, (dict, list)):
|
||||
formatted_code: str = json.dumps(data, indent=4)
|
||||
else:
|
||||
formatted_code = data
|
||||
return highlight(formatted_code, JsonLexer(), HtmlFormatter())
|
||||
|
||||
|
||||
def _enhance_drops_with_context(
|
||||
drops: QuerySet[TimeBasedDrop],
|
||||
now: datetime.datetime,
|
||||
|
|
@ -510,7 +558,7 @@ def _enhance_drops_with_context(
|
|||
|
||||
|
||||
# MARK: /campaigns/<twitch_id>/
|
||||
def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914
|
||||
def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914, PLR0915
|
||||
"""Function-based view for a drop campaign detail.
|
||||
|
||||
Args:
|
||||
|
|
@ -544,6 +592,96 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
|||
.order_by("required_minutes_watched")
|
||||
)
|
||||
|
||||
serialized_campaign: str = serialize(
|
||||
"json",
|
||||
[campaign],
|
||||
fields=(
|
||||
"twitch_id",
|
||||
"name",
|
||||
"description",
|
||||
"details_url",
|
||||
"account_link_url",
|
||||
"image_url",
|
||||
"start_at",
|
||||
"end_at",
|
||||
"allow_is_enabled",
|
||||
"operation_names",
|
||||
"game",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
campaign_data: list[dict[str, Any]] = json.loads(serialized_campaign)
|
||||
|
||||
if drops.exists():
|
||||
badge_benefit_names: set[str] = {
|
||||
benefit.name
|
||||
for drop in drops
|
||||
for benefit in drop.benefits.all()
|
||||
if benefit.distribution_type == "BADGE" and benefit.name
|
||||
}
|
||||
badge_descriptions_by_title: dict[str, str] = dict(
|
||||
ChatBadge.objects.filter(title__in=badge_benefit_names).values_list(
|
||||
"title",
|
||||
"description",
|
||||
),
|
||||
)
|
||||
|
||||
serialized_drops = serialize(
|
||||
"json",
|
||||
drops,
|
||||
fields=(
|
||||
"twitch_id",
|
||||
"name",
|
||||
"required_minutes_watched",
|
||||
"required_subs",
|
||||
"start_at",
|
||||
"end_at",
|
||||
"added_at",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
drops_data: list[dict[str, Any]] = json.loads(serialized_drops)
|
||||
|
||||
for i, drop in enumerate(drops):
|
||||
drop_benefits: list[DropBenefit] = list(drop.benefits.all())
|
||||
if drop_benefits:
|
||||
serialized_benefits: str = serialize(
|
||||
"json",
|
||||
drop_benefits,
|
||||
fields=(
|
||||
"twitch_id",
|
||||
"name",
|
||||
"image_asset_url",
|
||||
"added_at",
|
||||
"updated_at",
|
||||
"created_at",
|
||||
"entitlement_limit",
|
||||
"is_ios_available",
|
||||
"distribution_type",
|
||||
),
|
||||
)
|
||||
benefits_data: list[dict[str, Any]] = json.loads(serialized_benefits)
|
||||
|
||||
for benefit_data in benefits_data:
|
||||
fields: dict[str, Any] = benefit_data.get("fields", {})
|
||||
if fields.get("distribution_type") != "BADGE":
|
||||
continue
|
||||
|
||||
# DropBenefit doesn't have a description field; fetch it from ChatBadge when possible.
|
||||
if fields.get("description"):
|
||||
continue
|
||||
|
||||
badge_description: str | None = badge_descriptions_by_title.get(
|
||||
fields.get("name", ""),
|
||||
)
|
||||
if badge_description:
|
||||
fields["description"] = badge_description
|
||||
|
||||
drops_data[i]["fields"]["benefits"] = benefits_data
|
||||
|
||||
campaign_data[0]["fields"]["drops"] = drops_data
|
||||
|
||||
now: datetime.datetime = timezone.now()
|
||||
enhanced_drops: list[dict[str, Any]] = _enhance_drops_with_context(drops, now)
|
||||
# Attach awarded_badge to each drop in enhanced_drops
|
||||
|
|
@ -562,6 +700,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
|||
"campaign": campaign,
|
||||
"now": now,
|
||||
"drops": enhanced_drops,
|
||||
"campaign_data": format_and_color_json(campaign_data[0]),
|
||||
"owners": list(campaign.game.owners.all()),
|
||||
"allowed_channels": getattr(campaign, "channels_ordered", []),
|
||||
}
|
||||
|
|
@ -585,7 +724,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
|||
)
|
||||
|
||||
# TODO(TheLovinator): If the campaign has specific allowed channels, we could list those as potential locations instead of just linking to Twitch homepage. # noqa: TD003
|
||||
campaign_event: dict[str, Any] = {
|
||||
campaign_schema: dict[str, str | dict[str, str]] = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Event",
|
||||
"name": campaign_name,
|
||||
|
|
@ -599,9 +738,9 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
|||
},
|
||||
}
|
||||
if campaign.start_at:
|
||||
campaign_event["startDate"] = campaign.start_at.isoformat()
|
||||
campaign_schema["startDate"] = campaign.start_at.isoformat()
|
||||
if campaign.end_at:
|
||||
campaign_event["endDate"] = campaign.end_at.isoformat()
|
||||
campaign_schema["endDate"] = campaign.end_at.isoformat()
|
||||
campaign_owner: Organization | None = (
|
||||
_pick_owner(list(campaign.game.owners.all())) if campaign.game else None
|
||||
)
|
||||
|
|
@ -611,25 +750,17 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
|||
else "Twitch"
|
||||
)
|
||||
if campaign_image:
|
||||
campaign_event["image"] = {
|
||||
campaign_schema["image"] = {
|
||||
"@type": "ImageObject",
|
||||
"contentUrl": request.build_absolute_uri(campaign_image),
|
||||
"creditText": campaign_owner_name,
|
||||
"copyrightNotice": campaign_owner_name,
|
||||
}
|
||||
if campaign_owner:
|
||||
campaign_event["organizer"] = {
|
||||
campaign_schema["organizer"] = {
|
||||
"@type": "Organization",
|
||||
"name": campaign_owner_name,
|
||||
}
|
||||
webpage_node: dict[str, Any] = {
|
||||
"@type": "WebPage",
|
||||
"url": url,
|
||||
"datePublished": campaign.added_at.isoformat(),
|
||||
"dateModified": campaign.updated_at.isoformat(),
|
||||
}
|
||||
campaign_event["mainEntityOfPage"] = webpage_node
|
||||
campaign_schema: dict[str, Any] = campaign_event
|
||||
|
||||
# Breadcrumb schema for navigation
|
||||
# TODO(TheLovinator): We should have a game.get_display_name() method that encapsulates the logic of choosing between display_name, name, and twitch_id. # noqa: TD003
|
||||
|
|
@ -656,19 +787,12 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title=campaign_name,
|
||||
page_description=campaign_description,
|
||||
seo_meta={
|
||||
"page_image": campaign_image,
|
||||
"page_image_width": campaign_image_width,
|
||||
"page_image_height": campaign_image_height,
|
||||
"schema_data": campaign_schema,
|
||||
"breadcrumb_schema": breadcrumb_schema,
|
||||
"published_date": campaign.added_at.isoformat()
|
||||
if campaign.added_at
|
||||
else None,
|
||||
"modified_date": campaign.updated_at.isoformat()
|
||||
if campaign.updated_at
|
||||
else None,
|
||||
},
|
||||
page_image=campaign_image,
|
||||
page_image_width=campaign_image_width,
|
||||
page_image_height=campaign_image_height,
|
||||
schema_data=campaign_schema,
|
||||
breadcrumb_schema=breadcrumb_schema,
|
||||
modified_date=campaign.updated_at.isoformat() if campaign.updated_at else None,
|
||||
)
|
||||
context.update(seo_context)
|
||||
|
||||
|
|
@ -763,7 +887,7 @@ class GamesGridView(ListView):
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title="Twitch Games",
|
||||
page_description="Twitch games that had or have Twitch drops.",
|
||||
seo_meta={"schema_data": collection_schema},
|
||||
schema_data=collection_schema,
|
||||
)
|
||||
context.update(seo_context)
|
||||
|
||||
|
|
@ -898,6 +1022,45 @@ class GameDetailView(DetailView):
|
|||
if campaign.end_at is not None and campaign.end_at < now
|
||||
]
|
||||
|
||||
serialized_game: str = serialize(
|
||||
"json",
|
||||
[game],
|
||||
fields=(
|
||||
"twitch_id",
|
||||
"slug",
|
||||
"name",
|
||||
"display_name",
|
||||
"box_art",
|
||||
"owners",
|
||||
"added_at",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
game_data: list[dict[str, Any]] = json.loads(serialized_game)
|
||||
|
||||
if campaigns_list:
|
||||
serialized_campaigns = serialize(
|
||||
"json",
|
||||
campaigns_list,
|
||||
fields=(
|
||||
"twitch_id",
|
||||
"name",
|
||||
"description",
|
||||
"details_url",
|
||||
"account_link_url",
|
||||
"image_url",
|
||||
"start_at",
|
||||
"end_at",
|
||||
"allow_is_enabled",
|
||||
"game",
|
||||
"operation_names",
|
||||
"added_at",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
campaigns_data: list[dict[str, Any]] = json.loads(serialized_campaigns)
|
||||
game_data[0]["fields"]["campaigns"] = campaigns_data
|
||||
|
||||
owners: list[Organization] = list(game.owners.all())
|
||||
|
||||
game_name: str = game.display_name or game.name or game.twitch_id
|
||||
|
|
@ -917,10 +1080,6 @@ class GameDetailView(DetailView):
|
|||
reverse("twitch:game_detail", args=[game.twitch_id]),
|
||||
),
|
||||
}
|
||||
if game.added_at:
|
||||
game_schema["datePublished"] = game.added_at.isoformat()
|
||||
if game.updated_at:
|
||||
game_schema["dateModified"] = game.updated_at.isoformat()
|
||||
preferred_owner: Organization | None = _pick_owner(owners)
|
||||
owner_name: str = (
|
||||
(preferred_owner.name or preferred_owner.twitch_id)
|
||||
|
|
@ -955,17 +1114,12 @@ class GameDetailView(DetailView):
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title=game_name,
|
||||
page_description=game_description,
|
||||
seo_meta={
|
||||
"page_image": game_image,
|
||||
"page_image_width": game_image_width,
|
||||
"page_image_height": game_image_height,
|
||||
"schema_data": game_schema,
|
||||
"breadcrumb_schema": breadcrumb_schema,
|
||||
"published_date": game.added_at.isoformat() if game.added_at else None,
|
||||
"modified_date": game.updated_at.isoformat()
|
||||
if game.updated_at
|
||||
else None,
|
||||
},
|
||||
page_image=game_image,
|
||||
page_image_width=game_image_width,
|
||||
page_image_height=game_image_height,
|
||||
schema_data=game_schema,
|
||||
breadcrumb_schema=breadcrumb_schema,
|
||||
modified_date=game.updated_at.isoformat() if game.updated_at else None,
|
||||
)
|
||||
context.update({
|
||||
"active_campaigns": active_campaigns,
|
||||
|
|
@ -975,6 +1129,7 @@ class GameDetailView(DetailView):
|
|||
"owners": owners,
|
||||
"drop_awarded_badges": drop_awarded_badges,
|
||||
"now": now,
|
||||
"game_data": format_and_color_json(game_data[0]),
|
||||
**seo_context,
|
||||
})
|
||||
|
||||
|
|
@ -1058,10 +1213,8 @@ def dashboard(request: HttpRequest) -> HttpResponse:
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title="Twitch Drops",
|
||||
page_description="Overview of active Twitch drop campaigns and rewards.",
|
||||
seo_meta={
|
||||
"og_type": "website",
|
||||
"schema_data": website_schema,
|
||||
},
|
||||
og_type="website",
|
||||
schema_data=website_schema,
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
|
|
@ -1153,11 +1306,8 @@ def reward_campaign_list_view(request: HttpRequest) -> HttpResponse:
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title=title,
|
||||
page_description=description,
|
||||
seo_meta={
|
||||
"page_url": request.build_absolute_uri(base_url),
|
||||
"pagination_info": pagination_info,
|
||||
"schema_data": collection_schema,
|
||||
},
|
||||
pagination_info=pagination_info,
|
||||
schema_data=collection_schema,
|
||||
)
|
||||
context: dict[str, Any] = {
|
||||
"reward_campaigns": reward_campaigns,
|
||||
|
|
@ -1194,6 +1344,28 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
|
|||
msg = "No reward campaign found matching the query"
|
||||
raise Http404(msg) from exc
|
||||
|
||||
serialized_campaign: str = serialize(
|
||||
"json",
|
||||
[reward_campaign],
|
||||
fields=(
|
||||
"twitch_id",
|
||||
"name",
|
||||
"brand",
|
||||
"summary",
|
||||
"instructions",
|
||||
"external_url",
|
||||
"about_url",
|
||||
"reward_value_url_param",
|
||||
"starts_at",
|
||||
"ends_at",
|
||||
"is_sitewide",
|
||||
"game",
|
||||
"added_at",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
campaign_data: list[dict[str, Any]] = json.loads(serialized_campaign)
|
||||
|
||||
now: datetime.datetime = timezone.now()
|
||||
|
||||
campaign_name: str = reward_campaign.name or reward_campaign.twitch_id
|
||||
|
|
@ -1203,45 +1375,29 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
|
|||
else f"{campaign_name}"
|
||||
)
|
||||
|
||||
reward_url: str = request.build_absolute_uri(
|
||||
reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id]),
|
||||
)
|
||||
|
||||
campaign_event: dict[str, Any] = {
|
||||
campaign_schema: dict[str, str | dict[str, str]] = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Event",
|
||||
"name": campaign_name,
|
||||
"description": campaign_description,
|
||||
"url": reward_url,
|
||||
"url": request.build_absolute_uri(
|
||||
reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id]),
|
||||
),
|
||||
"eventStatus": "https://schema.org/EventScheduled",
|
||||
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
|
||||
"location": {"@type": "VirtualLocation", "url": "https://www.twitch.tv"},
|
||||
}
|
||||
if reward_campaign.starts_at:
|
||||
campaign_event["startDate"] = reward_campaign.starts_at.isoformat()
|
||||
campaign_schema["startDate"] = reward_campaign.starts_at.isoformat()
|
||||
if reward_campaign.ends_at:
|
||||
campaign_event["endDate"] = reward_campaign.ends_at.isoformat()
|
||||
campaign_schema["endDate"] = reward_campaign.ends_at.isoformat()
|
||||
if reward_campaign.game and reward_campaign.game.owners.exists():
|
||||
owner = reward_campaign.game.owners.first()
|
||||
campaign_event["organizer"] = {
|
||||
campaign_schema["organizer"] = {
|
||||
"@type": "Organization",
|
||||
"name": owner.name or owner.twitch_id,
|
||||
}
|
||||
|
||||
webpage_node: dict[str, Any] = {
|
||||
"@type": "WebPage",
|
||||
"url": reward_url,
|
||||
"datePublished": reward_campaign.added_at.isoformat(),
|
||||
"dateModified": reward_campaign.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
campaign_schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
campaign_event,
|
||||
webpage_node,
|
||||
],
|
||||
}
|
||||
|
||||
# Breadcrumb schema
|
||||
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
|
||||
{"name": "Home", "url": request.build_absolute_uri("/")},
|
||||
|
|
@ -1263,16 +1419,14 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title=campaign_name,
|
||||
page_description=campaign_description,
|
||||
seo_meta={
|
||||
"schema_data": campaign_schema,
|
||||
"breadcrumb_schema": breadcrumb_schema,
|
||||
"published_date": reward_campaign.added_at.isoformat(),
|
||||
"modified_date": reward_campaign.updated_at.isoformat(),
|
||||
},
|
||||
schema_data=campaign_schema,
|
||||
breadcrumb_schema=breadcrumb_schema,
|
||||
modified_date=reward_campaign.updated_at.isoformat(),
|
||||
)
|
||||
context: dict[str, Any] = {
|
||||
"reward_campaign": reward_campaign,
|
||||
"now": now,
|
||||
"campaign_data": format_and_color_json(campaign_data[0]),
|
||||
"is_active": reward_campaign.is_active,
|
||||
**seo_context,
|
||||
}
|
||||
|
|
@ -1352,11 +1506,8 @@ class ChannelListView(ListView):
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title="Twitch Channels",
|
||||
page_description="List of Twitch channels participating in drop campaigns.",
|
||||
seo_meta={
|
||||
"page_url": self.request.build_absolute_uri(base_url),
|
||||
"pagination_info": pagination_info,
|
||||
"schema_data": collection_schema,
|
||||
},
|
||||
pagination_info=pagination_info,
|
||||
schema_data=collection_schema,
|
||||
)
|
||||
context.update(seo_context)
|
||||
context["search_query"] = search_query
|
||||
|
|
@ -1464,36 +1615,50 @@ class ChannelDetailView(DetailView):
|
|||
if campaign.end_at is not None and campaign.end_at < now
|
||||
]
|
||||
|
||||
serialized_channel: str = serialize(
|
||||
"json",
|
||||
[channel],
|
||||
fields=("twitch_id", "name", "display_name", "added_at", "updated_at"),
|
||||
)
|
||||
channel_data: list[dict[str, Any]] = json.loads(serialized_channel)
|
||||
|
||||
if campaigns_list:
|
||||
serialized_campaigns: str = serialize(
|
||||
"json",
|
||||
campaigns_list,
|
||||
fields=(
|
||||
"twitch_id",
|
||||
"name",
|
||||
"description",
|
||||
"details_url",
|
||||
"account_link_url",
|
||||
"image_url",
|
||||
"start_at",
|
||||
"end_at",
|
||||
"added_at",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
campaigns_data: list[dict[str, Any]] = json.loads(serialized_campaigns)
|
||||
channel_data[0]["fields"]["campaigns"] = campaigns_data
|
||||
|
||||
name: str = channel.display_name or channel.name or channel.twitch_id
|
||||
total_campaigns: int = len(campaigns_list)
|
||||
description: str = f"{name} participates in {total_campaigns} drop campaign"
|
||||
if total_campaigns > 1:
|
||||
description += "s"
|
||||
|
||||
channel_url: str = self.request.build_absolute_uri(
|
||||
reverse("twitch:channel_detail", args=[channel.twitch_id]),
|
||||
)
|
||||
channel_node: dict[str, Any] = {
|
||||
channel_schema: dict[str, Any] = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BroadcastChannel",
|
||||
"name": name,
|
||||
"description": description,
|
||||
"url": channel_url,
|
||||
"url": self.request.build_absolute_uri(
|
||||
reverse("twitch:channel_detail", args=[channel.twitch_id]),
|
||||
),
|
||||
"broadcastChannelId": channel.twitch_id,
|
||||
"providerName": "Twitch",
|
||||
}
|
||||
webpage_node: dict[str, Any] = {
|
||||
"@type": "WebPage",
|
||||
"url": channel_url,
|
||||
"datePublished": channel.added_at.isoformat(),
|
||||
"dateModified": channel.updated_at.isoformat(),
|
||||
}
|
||||
channel_schema: dict[str, Any] = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
channel_node,
|
||||
webpage_node,
|
||||
],
|
||||
}
|
||||
|
||||
# Breadcrumb schema
|
||||
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
|
||||
|
|
@ -1510,22 +1675,18 @@ class ChannelDetailView(DetailView):
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title=name,
|
||||
page_description=description,
|
||||
seo_meta={
|
||||
"schema_data": channel_schema,
|
||||
"breadcrumb_schema": breadcrumb_schema,
|
||||
"published_date": channel.added_at.isoformat()
|
||||
if channel.added_at
|
||||
else None,
|
||||
"modified_date": channel.updated_at.isoformat()
|
||||
schema_data=channel_schema,
|
||||
breadcrumb_schema=breadcrumb_schema,
|
||||
modified_date=channel.updated_at.isoformat()
|
||||
if channel.updated_at
|
||||
else None,
|
||||
},
|
||||
)
|
||||
context.update({
|
||||
"active_campaigns": active_campaigns,
|
||||
"upcoming_campaigns": upcoming_campaigns,
|
||||
"expired_campaigns": expired_campaigns,
|
||||
"now": now,
|
||||
"channel_data": format_and_color_json(channel_data[0]),
|
||||
**seo_context,
|
||||
})
|
||||
|
||||
|
|
@ -1572,7 +1733,7 @@ def badge_list_view(request: HttpRequest) -> HttpResponse:
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title="Twitch Chat Badges",
|
||||
page_description="List of Twitch chat badges awarded through drop campaigns.",
|
||||
seo_meta={"schema_data": collection_schema},
|
||||
schema_data=collection_schema,
|
||||
)
|
||||
context: dict[str, Any] = {
|
||||
"badge_sets": badge_sets,
|
||||
|
|
@ -1642,6 +1803,34 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse:
|
|||
).distinct()
|
||||
badge.award_campaigns = list(campaigns) # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
# Serialize for JSON display
|
||||
serialized_set: str = serialize(
|
||||
"json",
|
||||
[badge_set],
|
||||
fields=("set_id", "added_at", "updated_at"),
|
||||
)
|
||||
set_data: list[dict[str, Any]] = json.loads(serialized_set)
|
||||
|
||||
if badges:
|
||||
serialized_badges: str = serialize(
|
||||
"json",
|
||||
badges,
|
||||
fields=(
|
||||
"badge_id",
|
||||
"image_url_1x",
|
||||
"image_url_2x",
|
||||
"image_url_4x",
|
||||
"title",
|
||||
"description",
|
||||
"click_action",
|
||||
"click_url",
|
||||
"added_at",
|
||||
"updated_at",
|
||||
),
|
||||
)
|
||||
badges_data: list[dict[str, Any]] = json.loads(serialized_badges)
|
||||
set_data[0]["fields"]["badges"] = badges_data
|
||||
|
||||
badge_set_name: str = badge_set.set_id
|
||||
badge_set_description: str = f"Twitch chat badge set {badge_set_name} with {len(badges)} badge{'s' if len(badges) != 1 else ''} awarded through drop campaigns."
|
||||
|
||||
|
|
@ -1658,11 +1847,12 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse:
|
|||
seo_context: dict[str, Any] = _build_seo_context(
|
||||
page_title=f"Badge Set: {badge_set_name}",
|
||||
page_description=badge_set_description,
|
||||
seo_meta={"schema_data": badge_schema},
|
||||
schema_data=badge_schema,
|
||||
)
|
||||
context: dict[str, Any] = {
|
||||
"badge_set": badge_set,
|
||||
"badges": badges,
|
||||
"set_data": format_and_color_json(set_data[0]),
|
||||
**seo_context,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue