diff --git a/config/urls.py b/config/urls.py index 15b37bb..22186a7 100644 --- a/config/urls.py +++ b/config/urls.py @@ -13,36 +13,6 @@ if TYPE_CHECKING: urlpatterns: list[URLPattern | URLResolver] = [ path(route="sitemap.xml", view=core_views.sitemap_view, name="sitemap"), - path( - route="sitemap-static.xml", - view=core_views.sitemap_static_view, - name="sitemap-static", - ), - path( - route="sitemap-twitch-channels.xml", - view=core_views.sitemap_twitch_channels_view, - name="sitemap-twitch-channels", - ), - path( - route="sitemap-twitch-drops.xml", - view=core_views.sitemap_twitch_drops_view, - name="sitemap-twitch-drops", - ), - path( - route="sitemap-twitch-others.xml", - view=core_views.sitemap_twitch_others_view, - name="sitemap-twitch-others", - ), - path( - route="sitemap-kick.xml", - view=core_views.sitemap_kick_view, - name="sitemap-kick", - ), - path( - route="sitemap-youtube.xml", - view=core_views.sitemap_youtube_view, - name="sitemap-youtube", - ), # Core app path(route="", view=include("core.urls", namespace="core")), # Twitch app diff --git a/core/views.py b/core/views.py index c340e08..2b97f8f 100644 --- a/core/views.py +++ b/core/views.py @@ -11,7 +11,6 @@ from django.db import connection from django.db.models import Count from django.db.models import Exists from django.db.models import F -from django.db.models import Max from django.db.models import OuterRef from django.db.models import Prefetch from django.db.models import Q @@ -120,357 +119,143 @@ def _build_seo_context( # noqa: PLR0913, PLR0917 return context -def _render_urlset_xml( - url_entries: list[dict[str, str | dict[str, str]]], -) -> str: - """Render a sitemap XML string from URL entries. - - Args: - url_entries: List of dictionaries containing URL entry data. - - Returns: - A string containing the rendered XML. - - """ - xml = '\n' - xml += '\n' - for url_entry in url_entries: - xml += " \n" - xml += f" {url_entry['url']}\n" - if url_entry.get("lastmod"): - xml += f" {url_entry['lastmod']}\n" - xml += " \n" - xml += "" - return xml - - -def _render_sitemap_index_xml(sitemap_entries: list[dict[str, str]]) -> str: - """Render a XML string listing sitemap URLs. - - Args: - sitemap_entries: List of dictionaries with "loc" and optional "lastmod". - - Returns: - A string containing the rendered XML. - """ - xml = '\n' - xml += '\n' - for entry in sitemap_entries: - xml += " \n" - xml += f" {entry['loc']}\n" - if entry.get("lastmod"): - xml += f" {entry['lastmod']}\n" - xml += " \n" - xml += "" - return xml - - -def _build_base_url(request: HttpRequest) -> str: - """Return base url including scheme and host.""" - return f"{request.scheme}://{request.get_host()}" - - # MARK: /sitemap.xml -def sitemap_view(request: HttpRequest) -> HttpResponse: - """Sitemap index pointing to per-section sitemap files. +def sitemap_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0915 + """Generate a dynamic XML sitemap for search engines. Args: request: The HTTP request. Returns: - HttpResponse: The rendered sitemap index XML. + HttpResponse: XML sitemap. """ - base_url: str = _build_base_url(request) + base_url: str = f"{request.scheme}://{request.get_host()}" - # Compute last modified per-section so search engines can more intelligently crawl. - # Do not fabricate a lastmod date if the section has no data. - twitch_channels_lastmod = Channel.objects.aggregate(max=Max("updated_at"))["max"] - twitch_drops_lastmod = max( - [ - dt - for dt in [ - DropCampaign.objects.aggregate(max=Max("updated_at"))["max"], - RewardCampaign.objects.aggregate(max=Max("updated_at"))["max"], - ] - if dt is not None - ] - or [None], - ) - twitch_others_lastmod = max( - [ - dt - for dt in [ - Game.objects.aggregate(max=Max("updated_at"))["max"], - Organization.objects.aggregate(max=Max("updated_at"))["max"], - ChatBadgeSet.objects.aggregate(max=Max("updated_at"))["max"], - ] - if dt is not None - ] - or [None], - ) - kick_lastmod = KickDropCampaign.objects.aggregate(max=Max("updated_at"))["max"] - - sitemap_entries: list[dict[str, str]] = [ - {"loc": f"{base_url}/sitemap-static.xml"}, - {"loc": f"{base_url}/sitemap-twitch-channels.xml"}, - {"loc": f"{base_url}/sitemap-twitch-drops.xml"}, - {"loc": f"{base_url}/sitemap-twitch-others.xml"}, - {"loc": f"{base_url}/sitemap-kick.xml"}, - {"loc": f"{base_url}/sitemap-youtube.xml"}, - ] - - if twitch_channels_lastmod is not None: - sitemap_entries[1]["lastmod"] = twitch_channels_lastmod.isoformat() - if twitch_drops_lastmod is not None: - sitemap_entries[2]["lastmod"] = twitch_drops_lastmod.isoformat() - if twitch_others_lastmod is not None: - sitemap_entries[3]["lastmod"] = twitch_others_lastmod.isoformat() - if kick_lastmod is not None: - sitemap_entries[4]["lastmod"] = kick_lastmod.isoformat() - - xml_content: str = _render_sitemap_index_xml(sitemap_entries) - return HttpResponse(xml_content, content_type="application/xml") - - -# MARK: /sitemap-static.xml -def sitemap_static_view(request: HttpRequest) -> HttpResponse: - """Sitemap containing the main static pages. - - Args: - request: The HTTP request. - - Returns: - HttpResponse: The rendered sitemap XML. - """ - base_url: str = _build_base_url(request) - - # Include the canonical top-level pages, the main app dashboards, and key list views. - # Using reverse() keeps these URLs correct if route patterns change. - static_route_names: list[str] = [ - "core:dashboard", - # "core:search", - "core:dataset_backups", - "core:docs_rss", - # Core RSS/Atom feeds - # "core:campaign_feed", - # "core:game_feed", - # "core:organization_feed", - # "core:reward_campaign_feed", - # "core:campaign_feed_atom", - # "core:game_feed_atom", - # "core:organization_feed_atom", - # "core:reward_campaign_feed_atom", - # "core:campaign_feed_discord", - # "core:game_feed_discord", - # "core:organization_feed_discord", - # "core:reward_campaign_feed_discord", - # Twitch pages - "twitch:dashboard", - "twitch:badge_list", - "twitch:campaign_list", - "twitch:channel_list", - "twitch:emote_gallery", - "twitch:games_grid", - "twitch:games_list", - "twitch:org_list", - "twitch:reward_campaign_list", - # Kick pages - "kick:dashboard", - "kick:campaign_list", - "kick:game_list", - "kick:category_list", - "kick:organization_list", - # Kick RSS/Atom feeds - # "kick:campaign_feed", - # "kick:game_feed", - # "kick:category_feed", - # "kick:organization_feed", - # "kick:campaign_feed_atom", - # "kick:game_feed_atom", - # "kick:category_feed_atom", - # "kick:organization_feed_atom", - # "kick:campaign_feed_discord", - # "kick:game_feed_discord", - # "kick:category_feed_discord", - # "kick:organization_feed_discord", - # YouTube - "youtube:index", - ] - - sitemap_urls: list[dict[str, str | dict[str, str]]] = [] - for route_name in static_route_names: - url = reverse(route_name) - sitemap_urls.append({"url": f"{base_url}{url}"}) - - xml_content: str = _render_urlset_xml(sitemap_urls) - return HttpResponse(xml_content, content_type="application/xml") - - -# MARK: /sitemap-twitch-channels.xml -def sitemap_twitch_channels_view(request: HttpRequest) -> HttpResponse: - """Sitemap containing Twitch channel pages. - - Args: - request: The HTTP request. - - Returns: - HttpResponse: The rendered sitemap XML. - """ - base_url: str = _build_base_url(request) + # Start building sitemap XML sitemap_urls: list[dict[str, str | dict[str, str]]] = [] - channels: QuerySet[Channel] = Channel.objects.all() - for channel in channels: - resource_url: str = reverse("twitch:channel_detail", args=[channel.twitch_id]) - full_url: str = f"{base_url}{resource_url}" - entry: dict[str, str | dict[str, str]] = {"url": full_url} - if channel.updated_at: - entry["lastmod"] = channel.updated_at.isoformat() + # Static pages + sitemap_urls.extend([ + {"url": f"{base_url}/", "priority": "1.0", "changefreq": "daily"}, + {"url": f"{base_url}/campaigns/", "priority": "0.9", "changefreq": "daily"}, + { + "url": f"{base_url}/reward-campaigns/", + "priority": "0.9", + "changefreq": "daily", + }, + {"url": f"{base_url}/games/", "priority": "0.9", "changefreq": "weekly"}, + { + "url": f"{base_url}/organizations/", + "priority": "0.8", + "changefreq": "weekly", + }, + {"url": f"{base_url}/channels/", "priority": "0.8", "changefreq": "weekly"}, + {"url": f"{base_url}/badges/", "priority": "0.7", "changefreq": "monthly"}, + {"url": f"{base_url}/emotes/", "priority": "0.7", "changefreq": "monthly"}, + {"url": f"{base_url}/search/", "priority": "0.6", "changefreq": "monthly"}, + ]) + + # Dynamic detail pages - Games + games: QuerySet[Game] = Game.objects.all() + for game in games: + entry: dict[str, str | dict[str, str]] = { + "url": f"{base_url}{reverse('twitch:game_detail', args=[game.twitch_id])}", + "priority": "0.8", + "changefreq": "weekly", + } + if game.updated_at: + entry["lastmod"] = game.updated_at.isoformat() sitemap_urls.append(entry) - xml_content: str = _render_urlset_xml(sitemap_urls) - return HttpResponse(xml_content, content_type="application/xml") - - -# MARK: /sitemap-twitch-drops.xml -def sitemap_twitch_drops_view(request: HttpRequest) -> HttpResponse: - """Sitemap containing Twitch drop campaign pages. - - Args: - request: The HTTP request. - - Returns: - HttpResponse: The rendered sitemap XML. - """ - base_url: str = _build_base_url(request) - sitemap_urls: list[dict[str, str | dict[str, str]]] = [] - + # Dynamic detail pages - Campaigns campaigns: QuerySet[DropCampaign] = DropCampaign.objects.all() for campaign in campaigns: resource_url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) full_url: str = f"{base_url}{resource_url}" - entry: dict[str, str] = {"url": full_url} + entry: dict[str, str | dict[str, str]] = { + "url": full_url, + "priority": "0.7", + "changefreq": "weekly", + } if campaign.updated_at: entry["lastmod"] = campaign.updated_at.isoformat() sitemap_urls.append(entry) + # Dynamic detail pages - Organizations + orgs: QuerySet[Organization] = Organization.objects.all() + for org in orgs: + resource_url = reverse("twitch:organization_detail", args=[org.twitch_id]) + full_url: str = f"{base_url}{resource_url}" + entry: dict[str, str | dict[str, str]] = { + "url": full_url, + "priority": "0.7", + "changefreq": "weekly", + } + if org.updated_at: + entry["lastmod"] = org.updated_at.isoformat() + sitemap_urls.append(entry) + + # Dynamic detail pages - Channels + channels: QuerySet[Channel] = Channel.objects.all() + for channel in channels: + resource_url = reverse("twitch:channel_detail", args=[channel.twitch_id]) + full_url: str = f"{base_url}{resource_url}" + entry: dict[str, str | dict[str, str]] = { + "url": full_url, + "priority": "0.6", + "changefreq": "weekly", + } + if channel.updated_at: + entry["lastmod"] = channel.updated_at.isoformat() + sitemap_urls.append(entry) + + # Dynamic detail pages - Badges + badge_sets: QuerySet[ChatBadgeSet] = ChatBadgeSet.objects.all() + for badge_set in badge_sets: + resource_url = reverse("twitch:badge_set_detail", args=[badge_set.set_id]) + full_url: str = f"{base_url}{resource_url}" + sitemap_urls.append({ + "url": full_url, + "priority": "0.5", + "changefreq": "monthly", + }) + + # Dynamic detail pages - Reward Campaigns reward_campaigns: QuerySet[RewardCampaign] = RewardCampaign.objects.all() for reward_campaign in reward_campaigns: resource_url = reverse( "twitch:reward_campaign_detail", - args=[reward_campaign.twitch_id], + args=[ + reward_campaign.twitch_id, + ], ) - full_url: str = f"{base_url}{resource_url}" - entry: dict[str, str | dict[str, str]] = {"url": full_url} + entry: dict[str, str | dict[str, str]] = { + "url": full_url, + "priority": "0.6", + "changefreq": "weekly", + } if reward_campaign.updated_at: entry["lastmod"] = reward_campaign.updated_at.isoformat() - sitemap_urls.append(entry) - xml_content: str = _render_urlset_xml(sitemap_urls) - return HttpResponse(xml_content, content_type="application/xml") + # Build XML + xml_content = '\n' + xml_content += '\n' + for url_entry in sitemap_urls: + xml_content += " \n" + xml_content += f" {url_entry['url']}\n" + if url_entry.get("lastmod"): + xml_content += f" {url_entry['lastmod']}\n" + xml_content += ( + f" {url_entry.get('changefreq', 'monthly')}\n" + ) + xml_content += f" {url_entry.get('priority', '0.5')}\n" + xml_content += " \n" -# MARK: /sitemap-twitch-others.xml -def sitemap_twitch_others_view(request: HttpRequest) -> HttpResponse: - """Sitemap containing other Twitch pages (games, organizations, badges, emotes). + xml_content += "" - Args: - request: The HTTP request. - - Returns: - HttpResponse: The rendered sitemap XML. - """ - base_url: str = _build_base_url(request) - sitemap_urls: list[dict[str, str | dict[str, str]]] = [] - - games: QuerySet[Game] = Game.objects.all() - for game in games: - resource_url: str = reverse("twitch:game_detail", args=[game.twitch_id]) - full_url: str = f"{base_url}{resource_url}" - entry: dict[str, str | dict[str, str]] = {"url": full_url} - - if game.updated_at: - entry["lastmod"] = game.updated_at.isoformat() - - sitemap_urls.append(entry) - - orgs: QuerySet[Organization] = Organization.objects.all() - for org in orgs: - resource_url: str = reverse("twitch:organization_detail", args=[org.twitch_id]) - full_url: str = f"{base_url}{resource_url}" - entry: dict[str, str | dict[str, str]] = {"url": full_url} - - if org.updated_at: - entry["lastmod"] = org.updated_at.isoformat() - - sitemap_urls.append(entry) - - badge_sets: QuerySet[ChatBadgeSet] = ChatBadgeSet.objects.all() - for badge_set in badge_sets: - resource_url = reverse("twitch:badge_set_detail", args=[badge_set.set_id]) - full_url = f"{base_url}{resource_url}" - sitemap_urls.append({"url": full_url}) - - # Emotes currently don't have individual detail pages, but keep a listing here. - sitemap_urls.append({"url": f"{base_url}/emotes/"}) - - xml_content: str = _render_urlset_xml(sitemap_urls) - return HttpResponse(xml_content, content_type="application/xml") - - -# MARK: /sitemap-kick.xml -def sitemap_kick_view(request: HttpRequest) -> HttpResponse: - """Sitemap containing Kick drops and related pages. - - Args: - request: The HTTP request. - - Returns: - HttpResponse: The rendered sitemap XML. - """ - base_url: str = _build_base_url(request) - sitemap_urls: list[dict[str, str | dict[str, str]]] = [] - - kick_campaigns: QuerySet[KickDropCampaign] = KickDropCampaign.objects.all() - for campaign in kick_campaigns: - resource_url: str = reverse("kick:campaign_detail", args=[campaign.kick_id]) - full_url: str = f"{base_url}{resource_url}" - entry: dict[str, str | dict[str, str]] = {"url": full_url} - - if campaign.updated_at: - entry["lastmod"] = campaign.updated_at.isoformat() - - sitemap_urls.append(entry) - - # Include Kick organizations and game list pages - sitemap_urls.extend(( - {"url": f"{base_url}/kick/organizations/"}, - {"url": f"{base_url}/kick/games/"}, - )) - - xml_content: str = _render_urlset_xml(sitemap_urls) - return HttpResponse(xml_content, content_type="application/xml") - - -# MARK: /sitemap-youtube.xml -def sitemap_youtube_view(request: HttpRequest) -> HttpResponse: - """Sitemap containing the YouTube page(s). - - Args: - request: The HTTP request. - - Returns: - HttpResponse: The rendered sitemap XML. - """ - base_url: str = _build_base_url(request) - - sitemap_urls: list[dict[str, str | dict[str, str]]] = [ - {"url": f"{base_url}{reverse('youtube:index')}"}, - ] - - xml_content: str = _render_urlset_xml(sitemap_urls) return HttpResponse(xml_content, content_type="application/xml") diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 8c50e74..64fe90c 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -12,9 +12,6 @@ from django.test import RequestFactory from django.urls import reverse from django.utils import timezone -from kick.models import KickCategory -from kick.models import KickDropCampaign -from kick.models import KickOrganization from twitch.models import Channel from twitch.models import ChatBadge from twitch.models import ChatBadgeSet @@ -1324,61 +1321,19 @@ class TestSitemapView: name="ch1", display_name="Channel 1", ) - now: datetime = timezone.now() campaign: DropCampaign = DropCampaign.objects.create( twitch_id="camp1", name="Test Campaign", description="Desc", game=game, operation_names=["DropCampaignDetails"], - start_at=now - datetime.timedelta(days=1), - end_at=now + datetime.timedelta(days=1), ) - inactive_campaign: DropCampaign = DropCampaign.objects.create( - twitch_id="camp2", - name="Inactive Campaign", - description="Desc", - game=game, - operation_names=["DropCampaignDetails"], - start_at=now - datetime.timedelta(days=10), - end_at=now - datetime.timedelta(days=5), - ) - - kick_org: KickOrganization = KickOrganization.objects.create( - kick_id="org1", - name="Kick Org", - ) - kick_cat: KickCategory = KickCategory.objects.create( - kick_id=1, - name="Kick Game", - slug="kick-game", - ) - kick_active: KickDropCampaign = KickDropCampaign.objects.create( - kick_id="kcamp1", - name="Kick Active Campaign", - organization=kick_org, - category=kick_cat, - starts_at=now - datetime.timedelta(days=1), - ends_at=now + datetime.timedelta(days=1), - ) - kick_inactive: KickDropCampaign = KickDropCampaign.objects.create( - kick_id="kcamp2", - name="Kick Inactive Campaign", - organization=kick_org, - category=kick_cat, - starts_at=now - datetime.timedelta(days=10), - ends_at=now - datetime.timedelta(days=5), - ) - badge: ChatBadgeSet = ChatBadgeSet.objects.create(set_id="badge1") return { "org": org, "game": game, "channel": channel, "campaign": campaign, - "inactive_campaign": inactive_campaign, - "kick_active": kick_active, - "kick_inactive": kick_inactive, "badge": badge, } @@ -1402,24 +1357,16 @@ class TestSitemapView: content = response.content.decode() assert content.startswith('') - def test_sitemap_contains_sitemap_index( + def test_sitemap_contains_urlset( self, client: Client, sample_entities: dict[str, Any], ) -> None: - """Test sitemap index contains sitemap locations.""" + """Test sitemap contains urlset element.""" response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") content: str = response.content.decode() - assert "" in content - assert "/sitemap-static.xml" in content - assert "/sitemap-twitch-channels.xml" in content - assert "/sitemap-twitch-drops.xml" in content - assert "/sitemap-kick.xml" in content - assert "/sitemap-youtube.xml" in content - - # Ensure at least one entry includes a lastmod (there are entities created by the fixture) - assert "" in content + assert "" in content def test_sitemap_contains_static_pages( self, @@ -1427,19 +1374,15 @@ class TestSitemapView: sample_entities: dict[str, Any], ) -> None: """Test sitemap includes static pages.""" - response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-static.xml") + response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") content: str = response.content.decode() - - # Check for the homepage and a few key list views across apps. + # Check for some static pages assert ( "http://testserver/" in content or "http://localhost:8000/" in content ) - assert "http://testserver/twitch/" in content - assert "http://testserver/kick/" in content - assert "http://testserver/youtube/" in content - assert "http://testserver/twitch/campaigns/" in content - assert "http://testserver/twitch/games/" in content + assert "/campaigns/" in content + assert "/games/" in content def test_sitemap_contains_game_detail_pages( self, @@ -1448,7 +1391,7 @@ class TestSitemapView: ) -> None: """Test sitemap includes game detail pages.""" game: Game = sample_entities["game"] - response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-others.xml") + response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") content: str = response.content.decode() assert f"/games/{game.twitch_id}/" in content @@ -1459,62 +1402,10 @@ class TestSitemapView: ) -> None: """Test sitemap includes campaign detail pages.""" campaign: DropCampaign = sample_entities["campaign"] - response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-drops.xml") + response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") content: str = response.content.decode() assert f"/campaigns/{campaign.twitch_id}/" in content - def test_sitemap_prioritizes_active_drops( - self, - client: Client, - sample_entities: dict[str, Any], - ) -> None: - """Test active drops are prioritised and crawled more frequently.""" - active_campaign: DropCampaign = sample_entities["campaign"] - inactive_campaign: DropCampaign = sample_entities["inactive_campaign"] - - response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-drops.xml") - content: str = response.content.decode() - - active_loc: str = f"http://testserver/twitch/campaigns/{active_campaign.twitch_id}/" - active_index: int = content.find(active_loc) - assert active_index != -1 - active_end: int = content.find("", active_index) - assert active_end != -1 - - inactive_loc: str = f"http://testserver/twitch/campaigns/{inactive_campaign.twitch_id}/" - inactive_index: int = content.find(inactive_loc) - assert inactive_index != -1 - inactive_end: int = content.find("", inactive_index) - assert inactive_end != -1 - - def test_sitemap_prioritizes_active_kick_campaigns( - self, - client: Client, - sample_entities: dict[str, Any], - ) -> None: - """Test active Kick campaigns are prioritised and crawled more frequently.""" - active_campaign: KickDropCampaign = sample_entities["kick_active"] - inactive_campaign: KickDropCampaign = sample_entities["kick_inactive"] - - response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-kick.xml") - content: str = response.content.decode() - - active_loc: str = ( - f"http://testserver/kick/campaigns/{active_campaign.kick_id}/" - ) - active_index: int = content.find(active_loc) - assert active_index != -1 - active_end: int = content.find("", active_index) - assert active_end != -1 - - inactive_loc: str = ( - f"http://testserver/kick/campaigns/{inactive_campaign.kick_id}/" - ) - inactive_index: int = content.find(inactive_loc) - assert inactive_index != -1 - inactive_end: int = content.find("", inactive_index) - assert inactive_end != -1 - def test_sitemap_contains_organization_detail_pages( self, client: Client, @@ -1522,7 +1413,7 @@ class TestSitemapView: ) -> None: """Test sitemap includes organization detail pages.""" org: Organization = sample_entities["org"] - response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-others.xml") + response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") content: str = response.content.decode() assert f"/organizations/{org.twitch_id}/" in content @@ -1533,9 +1424,7 @@ class TestSitemapView: ) -> None: """Test sitemap includes channel detail pages.""" channel: Channel = sample_entities["channel"] - response: _MonkeyPatchedWSGIResponse = client.get( - "/sitemap-twitch-channels.xml", - ) + response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") content: str = response.content.decode() assert f"/twitch/channels/{channel.twitch_id}/" in content @@ -1546,19 +1435,31 @@ class TestSitemapView: ) -> None: """Test sitemap includes badge detail pages.""" badge: ChatBadge = sample_entities["badge"] - response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-others.xml") + response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") content: str = response.content.decode() assert f"/badges/{badge.set_id}/" in content # pyright: ignore[reportAttributeAccessIssue] - def test_sitemap_contains_youtube_pages( + def test_sitemap_includes_priority( self, client: Client, sample_entities: dict[str, Any], ) -> None: - """Test sitemap includes YouTube landing page.""" - response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-youtube.xml") + """Test sitemap includes priority values.""" + response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") content: str = response.content.decode() - assert "/youtube/" in content + assert "" in content + assert "" in content + + def test_sitemap_includes_changefreq( + self, + client: Client, + sample_entities: dict[str, Any], + ) -> None: + """Test sitemap includes changefreq values.""" + response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") + content: str = response.content.decode() + assert "" in content + assert "" in content def test_sitemap_includes_lastmod( self, @@ -1566,7 +1467,7 @@ class TestSitemapView: sample_entities: dict[str, Any], ) -> None: """Test sitemap includes lastmod for detail pages.""" - response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-others.xml") + response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") content: str = response.content.decode() # Check for lastmod in game or campaign entries assert "" in content @@ -1829,7 +1730,7 @@ class TestImageObjectStructuredData: org: Organization, monkeypatch: pytest.MonkeyPatch, ) -> None: - """VideoGame ImageObject should carry attribution metadata.""" + """VideoGame ImageObject should carry attribution and license metadata.""" url: str = reverse("twitch:game_detail", args=[game.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1842,6 +1743,14 @@ class TestImageObjectStructuredData: "name": org.name, "url": f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}", } + assert ( + img["license"] + == f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}" + ) + assert ( + img["acquireLicensePage"] + == f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}" + ) def test_game_schema_no_image_when_no_box_art( self, @@ -1941,7 +1850,7 @@ class TestImageObjectStructuredData: org: Organization, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Event ImageObject should carry attribution metadata.""" + """Event ImageObject should carry attribution and license metadata.""" url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1954,6 +1863,14 @@ class TestImageObjectStructuredData: "name": org.name, "url": f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}", } + assert ( + img["license"] + == f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}" + ) + assert ( + img["acquireLicensePage"] + == f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}" + ) def test_campaign_schema_no_image_when_no_image_url( self, @@ -2030,6 +1947,8 @@ class TestImageObjectStructuredData: "name": "Twitch", "url": "https://www.twitch.tv/", } + assert schema["image"]["license"] == "https://www.twitch.tv/" + assert schema["image"]["acquireLicensePage"] == "https://www.twitch.tv/" assert "organizer" not in schema # --- _pick_owner / Twitch Gaming skipping --- diff --git a/twitch/views.py b/twitch/views.py index f13ab43..e5db91f 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -79,7 +79,7 @@ def _build_image_object( *, copyright_notice: str | None = None, ) -> dict[str, Any]: - """Build a Schema.org ImageObject with attribution metadata. + """Build a Schema.org ImageObject with attribution and license metadata. Args: request: The HTTP request used for absolute URL building. @@ -96,13 +96,14 @@ def _build_image_object( "name": creator_name, "url": creator_url, } - return { "@type": "ImageObject", "contentUrl": request.build_absolute_uri(image_url), "creditText": creator_name, "copyrightNotice": copyright_notice or creator_name, "creator": creator, + "license": creator_url, + "acquireLicensePage": creator_url, }