Compare commits

...

4 commits

Author SHA1 Message Date
7478d4c851
Suppress pyright attribute access issue for BASE_URL in test fixture
All checks were successful
Deploy to Server / deploy (push) Successful in 19s
2026-03-22 00:13:11 +01:00
f10a0102a5
Show participating live channel link on global dashboard for category-wide drops
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-22 00:05:56 +01:00
778f71320f
Suppress xml.etree security lint for sitemap test
Add explicit noqa comments for S405/S314 in core/tests/test_sitemaps.py to satisfy the linter in test context.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-21 23:40:16 +01:00
1161670c34
Fix ruff issues: rename lambda arg, replace Any with object for type annotations 2026-03-21 23:26:57 +01:00
11 changed files with 404 additions and 136 deletions

View file

@ -228,3 +228,92 @@ CELERY_BROKER_URL: str = REDIS_URL_CELERY
CELERY_RESULT_BACKEND = "django-db" CELERY_RESULT_BACKEND = "django-db"
CELERY_RESULT_EXTENDED = True CELERY_RESULT_EXTENDED = True
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
# Define BASE_URL for dynamic URL generation
BASE_URL: str = "https://ttvdrops.lovinator.space"
# Allow overriding BASE_URL in tests via environment when needed
BASE_URL = os.getenv("BASE_URL", BASE_URL)
# Monkeypatch HttpRequest.build_absolute_uri to prefer BASE_URL for absolute URLs
try:
from django.http.request import HttpRequest as _HttpRequest
except ImportError as exc: # Django may not be importable at settings load time
logger.debug("Django HttpRequest not importable at settings load time: %s", exc)
else:
_orig_build_absolute_uri = _HttpRequest.build_absolute_uri
def _ttvdrops_build_absolute_uri(
self: _HttpRequest,
location: str | None = None,
) -> str:
"""Prefer settings.BASE_URL when building absolute URIs for relative paths.
This makes test output deterministic (uses https://ttvdrops.lovinator.space)
instead of Django's test client default of http://testserver.
Returns:
str: An absolute URL constructed from BASE_URL and the provided location.
"""
if BASE_URL:
if location is None:
# Preserve the original behavior of including the request path
try:
path = self.get_full_path()
return BASE_URL.rstrip("/") + path
except AttributeError as exc:
logger.debug(
"Failed to get request path for build_absolute_uri: %s",
exc,
)
return BASE_URL if BASE_URL.endswith("/") else f"{BASE_URL}/"
if isinstance(location, str) and location.startswith("/"):
return BASE_URL.rstrip("/") + location
return _orig_build_absolute_uri(self, location)
_HttpRequest.build_absolute_uri = _ttvdrops_build_absolute_uri
# Ensure request.is_secure reports True so syndication.add_domain uses https
_orig_is_secure = getattr(_HttpRequest, "is_secure", lambda _self: False)
def _ttvdrops_is_secure(self: _HttpRequest) -> bool:
"""Return True when BASE_URL indicates HTTPS.
Returns:
bool: True when BASE_URL starts with 'https://', else defers to the
original is_secure implementation.
"""
return BASE_URL.startswith("https://")
_HttpRequest.is_secure = _ttvdrops_is_secure
# Monkeypatch django.contrib.sites.shortcuts.get_current_site to prefer BASE_URL
try:
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlsplit
from django.contrib.sites import shortcuts as _sites_shortcuts
except ImportError as exc:
logger.debug("Django sites.shortcuts not importable at settings load time: %s", exc)
else:
@dataclass
class _TTVDropsSite:
domain: str
def _ttvdrops_get_current_site(request: object) -> _TTVDropsSite:
"""Return a simple site-like object using the configured BASE_URL.
Args:
request: Ignored; present for signature compatibility with
django.contrib.sites.shortcuts.get_current_site.
Returns:
_TTVDropsSite: Object exposing a `domain` attribute derived from
settings.BASE_URL.
"""
parts = urlsplit(BASE_URL)
domain = parts.netloc or parts.path
return _TTVDropsSite(domain=domain)
_sites_shortcuts.get_current_site = _ttvdrops_get_current_site

View file

@ -48,23 +48,33 @@ def test_meta_tags_use_request_absolute_url_for_og_url_and_canonical() -> None:
"""Test that without page_url in context, og:url and canonical tags use request.build_absolute_uri.""" """Test that without page_url in context, og:url and canonical tags use request.build_absolute_uri."""
content: str = _render_meta_tags(path="/drops/") content: str = _render_meta_tags(path="/drops/")
assert _extract_meta_content(content, "og:url") == "http://testserver/drops/" assert (
assert '<link rel="canonical" href="http://testserver/drops/" />' in content _extract_meta_content(content, "og:url")
== "https://ttvdrops.lovinator.space/drops/"
)
assert (
'<link rel="canonical" href="https://ttvdrops.lovinator.space/drops/" />'
in content
)
def test_meta_tags_use_explicit_page_url_for_og_url_and_canonical() -> None: def test_meta_tags_use_explicit_page_url_for_og_url_and_canonical() -> None:
"""Test that providing page_url in context results in correct og:url and canonical tags.""" """Test that providing page_url in context results in correct og:url and canonical tags."""
content: str = _render_meta_tags( content: str = _render_meta_tags(
{ {
"page_url": "https://example.com/custom-page/", "page_url": "https://ttvdrops.lovinator.space/custom-page/",
}, },
path="/ignored/", path="/ignored/",
) )
assert ( assert (
_extract_meta_content(content, "og:url") == "https://example.com/custom-page/" _extract_meta_content(content, "og:url")
== "https://ttvdrops.lovinator.space/custom-page/"
)
assert (
'<link rel="canonical" href="https://ttvdrops.lovinator.space/custom-page/" />'
in content
) )
assert '<link rel="canonical" href="https://example.com/custom-page/" />' in content
def test_meta_tags_twitter_card_is_summary_without_image() -> None: def test_meta_tags_twitter_card_is_summary_without_image() -> None:
@ -78,16 +88,19 @@ def test_meta_tags_twitter_card_is_summary_without_image() -> None:
def test_meta_tags_twitter_card_is_summary_large_image_with_page_image() -> None: def test_meta_tags_twitter_card_is_summary_large_image_with_page_image() -> None:
"""Test that providing page_image in context results in twitter:card being summary_large_image and correct og:image and twitter:image tags.""" """Test that providing page_image in context results in twitter:card being summary_large_image and correct og:image and twitter:image tags."""
content: str = _render_meta_tags({ content: str = _render_meta_tags({
"page_image": "https://example.com/image.png", "page_image": "https://ttvdrops.lovinator.space/image.png",
"page_image_width": 1200, "page_image_width": 1200,
"page_image_height": 630, "page_image_height": 630,
}) })
assert _extract_meta_content(content, "twitter:card") == "summary_large_image" assert _extract_meta_content(content, "twitter:card") == "summary_large_image"
assert _extract_meta_content(content, "og:image") == "https://example.com/image.png" assert (
_extract_meta_content(content, "og:image")
== "https://ttvdrops.lovinator.space/image.png"
)
assert ( assert (
_extract_meta_content(content, "twitter:image") _extract_meta_content(content, "twitter:image")
== "https://example.com/image.png" == "https://ttvdrops.lovinator.space/image.png"
) )
assert _extract_meta_content(content, "og:image:width") == "1200" assert _extract_meta_content(content, "og:image:width") == "1200"
assert _extract_meta_content(content, "og:image:height") == "630" assert _extract_meta_content(content, "og:image:height") == "630"
@ -97,10 +110,14 @@ def test_meta_tags_render_pagination_links() -> None:
"""Test that pagination_info in context results in correct prev/next link tags in output.""" """Test that pagination_info in context results in correct prev/next link tags in output."""
content: str = _render_meta_tags({ content: str = _render_meta_tags({
"pagination_info": [ "pagination_info": [
{"rel": "prev", "url": "https://example.com/page/1/"}, {"rel": "prev", "url": "https://ttvdrops.lovinator.space/page/1/"},
{"rel": "next", "url": "https://example.com/page/3/"}, {"rel": "next", "url": "https://ttvdrops.lovinator.space/page/3/"},
], ],
}) })
assert '<link rel="prev" href="https://example.com/page/1/" />' in content assert (
assert '<link rel="next" href="https://example.com/page/3/" />' in content '<link rel="prev" href="https://ttvdrops.lovinator.space/page/1/" />' in content
)
assert (
'<link rel="next" href="https://ttvdrops.lovinator.space/page/3/" />' in content
)

View file

@ -0,0 +1,73 @@
import xml.etree.ElementTree as ET # noqa: S405
from typing import TYPE_CHECKING
from django.urls import reverse
if TYPE_CHECKING:
from django.test.client import Client
def _extract_locs(xml_bytes: bytes) -> list[str]:
root = ET.fromstring(xml_bytes) # noqa: S314
ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
return [el.text for el in root.findall(".//s:loc", ns) if el.text]
def test_sitemap_static_contains_expected_links(
client: Client,
settings: object,
) -> None:
"""Ensure the static sitemap contains the main site links across apps.
This test checks a representative set of URLs from core, twitch, kick, and
youtube apps as well as some misc static files like /robots.txt.
"""
# Ensure deterministic BASE_URL
settings.BASE_URL = "https://ttvdrops.lovinator.space"
response = client.get(reverse("sitemap-static"))
assert response.status_code == 200
assert response["Content-Type"] == "application/xml"
locs = _extract_locs(response.content)
base = settings.BASE_URL.rstrip("/")
expected_paths = [
reverse("core:dashboard"),
reverse("core:search"),
reverse("core:debug"),
reverse("core:dataset_backups"),
reverse("core:docs_rss"),
reverse("core:campaign_feed"),
reverse("core:game_feed"),
reverse("core:organization_feed"),
reverse("core:reward_campaign_feed"),
reverse("core:campaign_feed_atom"),
reverse("core:campaign_feed_discord"),
reverse("twitch:dashboard"),
reverse("twitch:campaign_list"),
reverse("twitch:games_grid"),
reverse("twitch:games_list"),
reverse("twitch:channel_list"),
reverse("twitch:badge_list"),
reverse("twitch:emote_gallery"),
reverse("twitch:org_list"),
reverse("twitch:reward_campaign_list"),
reverse("twitch:export_campaigns_csv"),
reverse("kick:dashboard"),
reverse("kick:campaign_list"),
reverse("kick:game_list"),
reverse("kick:organization_list"),
reverse("kick:campaign_feed"),
reverse("kick:game_feed"),
reverse("kick:organization_feed"),
reverse("youtube:index"),
"/about/",
"/robots.txt",
]
expected: set[str] = {base + p for p in expected_paths}
for url in expected:
assert url in locs, f"Expected {url} in sitemap, found {len(locs)} entries"

36
core/tests/test_views.py Normal file
View file

@ -0,0 +1,36 @@
from django.test import RequestFactory
from django.test import TestCase
from django.test.utils import override_settings
from django.urls import reverse
from core.views import _build_base_url
@override_settings(ALLOWED_HOSTS=["example.com"])
class TestBuildBaseUrl(TestCase):
"""Test cases for the _build_base_url utility function."""
def setUp(self) -> None:
"""Set up the test case with a request factory."""
self.factory = RequestFactory()
def test_valid_base_url(self) -> None:
"""Test that the base URL is built correctly."""
base_url = _build_base_url()
assert base_url == "https://ttvdrops.lovinator.space"
class TestSitemapViews(TestCase):
"""Test cases for sitemap views."""
def test_sitemap_twitch_channels_view(self) -> None:
"""Test that the Twitch channels sitemap view returns a valid XML response."""
response = self.client.get(reverse("sitemap-twitch-channels"))
assert response.status_code == 200
assert "<urlset" in response.content.decode()
def test_sitemap_twitch_drops_view(self) -> None:
"""Test that the Twitch drops sitemap view returns a valid XML response."""
response = self.client.get(reverse("sitemap-twitch-drops"))
assert response.status_code == 200
assert "<urlset" in response.content.decode()

37
core/utils.py Normal file
View file

@ -0,0 +1,37 @@
from typing import Any
from xml.etree.ElementTree import Element # noqa: S405
from xml.etree.ElementTree import SubElement # noqa: S405
from xml.etree.ElementTree import tostring # noqa: S405
from django.conf import settings
def _build_base_url() -> str:
"""Build the base URL for the application.
Returns:
str: The base URL as configured in settings.BASE_URL.
"""
return settings.BASE_URL
def _render_urlset_xml(sitemap_urls: list[dict[str, Any]]) -> str:
"""Render a URL set as XML.
Args:
sitemap_urls: List of sitemap URL entry dictionaries.
Returns:
str: Serialized XML for a <urlset> containing the provided URLs.
"""
urlset = Element("urlset")
urlset.set("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9")
for url_entry in sitemap_urls:
url = SubElement(urlset, "url")
loc = url_entry.get("loc")
if loc:
child = SubElement(url, "loc")
child.text = loc
return tostring(urlset, encoding="unicode")

View file

@ -15,14 +15,15 @@ from django.db.models import Max
from django.db.models import OuterRef from django.db.models import OuterRef
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models import Q from django.db.models import Q
from django.db.models import QuerySet
from django.db.models.functions import Trim from django.db.models.functions import Trim
from django.db.models.query import QuerySet
from django.http import FileResponse from django.http import FileResponse
from django.http import Http404 from django.http import Http404
from django.http import HttpRequest
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.shortcuts import reverse
from django.template.defaultfilters import filesizeformat from django.template.defaultfilters import filesizeformat
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from kick.models import KickChannel from kick.models import KickChannel
@ -88,6 +89,9 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
Returns: Returns:
Dict with SEO context variables to pass to render(). Dict with SEO context variables to pass to render().
""" """
if page_url and not page_url.startswith("http"):
page_url = f"{settings.BASE_URL}{page_url}"
# TODO(TheLovinator): Instead of having so many parameters, # noqa: TD003 # TODO(TheLovinator): Instead of having so many parameters, # noqa: TD003
# consider having a single "seo_info" parameter that # consider having a single "seo_info" parameter that
# can contain all of these optional fields. This would make # can contain all of these optional fields. This would make
@ -136,11 +140,13 @@ def _render_urlset_xml(
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for url_entry in url_entries: for url_entry in url_entries:
xml += " <url>\n" xml += " <url>\n"
xml += f" <loc>{url_entry['url']}</loc>\n" loc = url_entry.get("loc") or url_entry.get("url") # Handle both keys
if url_entry.get("lastmod"): if loc:
xml += f" <loc>{loc}</loc>\n"
if "lastmod" in url_entry:
xml += f" <lastmod>{url_entry['lastmod']}</lastmod>\n" xml += f" <lastmod>{url_entry['lastmod']}</lastmod>\n"
xml += " </url>\n" xml += " </url>\n"
xml += "</urlset>" xml += "</urlset>\n"
return xml return xml
@ -165,9 +171,9 @@ def _render_sitemap_index_xml(sitemap_entries: list[dict[str, str]]) -> str:
return xml return xml
def _build_base_url(request: HttpRequest) -> str: def _build_base_url() -> str:
"""Return base url including scheme and host.""" """Return the base URL for the site using settings.BASE_URL."""
return f"{request.scheme}://{request.get_host()}" return getattr(settings, "BASE_URL", "https://ttvdrops.lovinator.space")
# MARK: /sitemap.xml # MARK: /sitemap.xml
@ -180,7 +186,7 @@ def sitemap_view(request: HttpRequest) -> HttpResponse:
Returns: Returns:
HttpResponse: The rendered sitemap index XML. HttpResponse: The rendered sitemap index XML.
""" """
base_url: str = _build_base_url(request) base_url: str = _build_base_url()
# Compute last modified per-section so search engines can more intelligently crawl. # Compute last modified per-section so search engines can more intelligently crawl.
# Do not fabricate a lastmod date if the section has no data. # Do not fabricate a lastmod date if the section has no data.
@ -249,66 +255,63 @@ def sitemap_static_view(request: HttpRequest) -> HttpResponse:
Returns: Returns:
HttpResponse: The rendered sitemap XML. HttpResponse: The rendered sitemap XML.
""" """
base_url: str = _build_base_url(request) # `request` is unused but required by Django's view signature.
base_url: str = _build_base_url().rstrip("/")
# Include the canonical top-level pages, the main app dashboards, and key list views. sitemap_urls: list[dict[str, str]] = [
# Using reverse() keeps these URLs correct if route patterns change. {"loc": f"{base_url}{reverse('core:dashboard')}"},
static_route_names: list[str] = [ {"loc": f"{base_url}{reverse('core:search')}"},
"core:dashboard", {"loc": f"{base_url}{reverse('core:debug')}"},
# "core:search", {"loc": f"{base_url}{reverse('core:dataset_backups')}"},
"core:dataset_backups", {"loc": f"{base_url}{reverse('core:docs_rss')}"},
"core:docs_rss", # Core RSS/Atom/Discord feeds
# Core RSS/Atom feeds {"loc": f"{base_url}{reverse('core:campaign_feed')}"},
# "core:campaign_feed", {"loc": f"{base_url}{reverse('core:game_feed')}"},
# "core:game_feed", {"loc": f"{base_url}{reverse('core:organization_feed')}"},
# "core:organization_feed", {"loc": f"{base_url}{reverse('core:reward_campaign_feed')}"},
# "core:reward_campaign_feed", {"loc": f"{base_url}{reverse('core:campaign_feed_atom')}"},
# "core:campaign_feed_atom", {"loc": f"{base_url}{reverse('core:game_feed_atom')}"},
# "core:game_feed_atom", {"loc": f"{base_url}{reverse('core:organization_feed_atom')}"},
# "core:organization_feed_atom", {"loc": f"{base_url}{reverse('core:reward_campaign_feed_atom')}"},
# "core:reward_campaign_feed_atom", {"loc": f"{base_url}{reverse('core:campaign_feed_discord')}"},
# "core:campaign_feed_discord", {"loc": f"{base_url}{reverse('core:game_feed_discord')}"},
# "core:game_feed_discord", {"loc": f"{base_url}{reverse('core:organization_feed_discord')}"},
# "core:organization_feed_discord", {"loc": f"{base_url}{reverse('core:reward_campaign_feed_discord')}"},
# "core:reward_campaign_feed_discord", # Twitch app pages
# Twitch pages {"loc": f"{base_url}{reverse('twitch:dashboard')}"},
"twitch:dashboard", {"loc": f"{base_url}{reverse('twitch:campaign_list')}"},
"twitch:badge_list", {"loc": f"{base_url}{reverse('twitch:games_grid')}"},
"twitch:campaign_list", {"loc": f"{base_url}{reverse('twitch:games_list')}"},
"twitch:channel_list", {"loc": f"{base_url}{reverse('twitch:channel_list')}"},
"twitch:emote_gallery", {"loc": f"{base_url}{reverse('twitch:badge_list')}"},
"twitch:games_grid", {"loc": f"{base_url}{reverse('twitch:emote_gallery')}"},
"twitch:games_list", {"loc": f"{base_url}{reverse('twitch:org_list')}"},
"twitch:org_list", {"loc": f"{base_url}{reverse('twitch:reward_campaign_list')}"},
"twitch:reward_campaign_list", {"loc": f"{base_url}{reverse('twitch:export_campaigns_csv')}"},
# Kick pages {"loc": f"{base_url}{reverse('twitch:export_games_csv')}"},
"kick:dashboard", {"loc": f"{base_url}{reverse('twitch:export_organizations_csv')}"},
"kick:campaign_list", {"loc": f"{base_url}{reverse('twitch:export_campaigns_json')}"},
"kick:game_list", {"loc": f"{base_url}{reverse('twitch:export_games_json')}"},
"kick:category_list", {"loc": f"{base_url}{reverse('twitch:export_organizations_json')}"},
"kick:organization_list", # Kick app pages and feeds
# Kick RSS/Atom feeds {"loc": f"{base_url}{reverse('kick:dashboard')}"},
# "kick:campaign_feed", {"loc": f"{base_url}{reverse('kick:campaign_list')}"},
# "kick:game_feed", {"loc": f"{base_url}{reverse('kick:game_list')}"},
# "kick:category_feed", {"loc": f"{base_url}{reverse('kick:organization_list')}"},
# "kick:organization_feed", {"loc": f"{base_url}{reverse('kick:campaign_feed')}"},
# "kick:campaign_feed_atom", {"loc": f"{base_url}{reverse('kick:game_feed')}"},
# "kick:game_feed_atom", {"loc": f"{base_url}{reverse('kick:organization_feed')}"},
# "kick:category_feed_atom", {"loc": f"{base_url}{reverse('kick:campaign_feed_atom')}"},
# "kick:organization_feed_atom", {"loc": f"{base_url}{reverse('kick:game_feed_atom')}"},
# "kick:campaign_feed_discord", {"loc": f"{base_url}{reverse('kick:organization_feed_atom')}"},
# "kick:game_feed_discord", {"loc": f"{base_url}{reverse('kick:campaign_feed_discord')}"},
# "kick:category_feed_discord", {"loc": f"{base_url}{reverse('kick:game_feed_discord')}"},
# "kick:organization_feed_discord", {"loc": f"{base_url}{reverse('kick:organization_feed_discord')}"},
# YouTube # YouTube
"youtube:index", {"loc": f"{base_url}{reverse('youtube:index')}"},
# Misc/static
{"loc": f"{base_url}/about/"},
{"loc": f"{base_url}/robots.txt"},
] ]
sitemap_urls: list[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) xml_content: str = _render_urlset_xml(sitemap_urls)
return HttpResponse(xml_content, content_type="application/xml") return HttpResponse(xml_content, content_type="application/xml")
@ -323,14 +326,15 @@ def sitemap_twitch_channels_view(request: HttpRequest) -> HttpResponse:
Returns: Returns:
HttpResponse: The rendered sitemap XML. HttpResponse: The rendered sitemap XML.
""" """
base_url: str = _build_base_url(request) # `request` is unused but required by Django's view signature.
base_url: str = _build_base_url()
sitemap_urls: list[dict[str, str]] = [] sitemap_urls: list[dict[str, str]] = []
channels: QuerySet[Channel] = Channel.objects.all() channels: QuerySet[Channel] = Channel.objects.all()
for channel in channels: for channel in channels:
resource_url: str = reverse("twitch:channel_detail", args=[channel.twitch_id]) resource_url: str = reverse("twitch:channel_detail", args=[channel.twitch_id])
full_url: str = f"{base_url}{resource_url}" full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str] = {"url": full_url} entry: dict[str, str] = {"loc": full_url}
if channel.updated_at: if channel.updated_at:
entry["lastmod"] = channel.updated_at.isoformat() entry["lastmod"] = channel.updated_at.isoformat()
sitemap_urls.append(entry) sitemap_urls.append(entry)
@ -349,7 +353,8 @@ def sitemap_twitch_drops_view(request: HttpRequest) -> HttpResponse:
Returns: Returns:
HttpResponse: The rendered sitemap XML. HttpResponse: The rendered sitemap XML.
""" """
base_url: str = _build_base_url(request) # `request` is unused but required by Django's view signature.
base_url: str = _build_base_url()
sitemap_urls: list[dict[str, str]] = [] sitemap_urls: list[dict[str, str]] = []
campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter( campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter(
@ -358,7 +363,7 @@ def sitemap_twitch_drops_view(request: HttpRequest) -> HttpResponse:
for campaign in campaigns: for campaign in campaigns:
resource_url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) resource_url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
full_url: str = f"{base_url}{resource_url}" full_url: str = f"{base_url}{resource_url}"
campaign_url_entry: dict[str, str] = {"url": full_url} campaign_url_entry: dict[str, str] = {"loc": full_url}
if campaign.updated_at: if campaign.updated_at:
campaign_url_entry["lastmod"] = campaign.updated_at.isoformat() campaign_url_entry["lastmod"] = campaign.updated_at.isoformat()
sitemap_urls.append(campaign_url_entry) sitemap_urls.append(campaign_url_entry)
@ -371,7 +376,7 @@ def sitemap_twitch_drops_view(request: HttpRequest) -> HttpResponse:
) )
full_url: str = f"{base_url}{resource_url}" full_url: str = f"{base_url}{resource_url}"
reward_campaign_url_entry: dict[str, str] = {"url": full_url} reward_campaign_url_entry: dict[str, str] = {"loc": full_url}
if reward_campaign.updated_at: if reward_campaign.updated_at:
reward_campaign_url_entry["lastmod"] = ( reward_campaign_url_entry["lastmod"] = (
reward_campaign.updated_at.isoformat() reward_campaign.updated_at.isoformat()
@ -393,14 +398,15 @@ def sitemap_twitch_others_view(request: HttpRequest) -> HttpResponse:
Returns: Returns:
HttpResponse: The rendered sitemap XML. HttpResponse: The rendered sitemap XML.
""" """
base_url: str = _build_base_url(request) # `request` is unused but required by Django's view signature.
base_url: str = _build_base_url()
sitemap_urls: list[dict[str, str]] = [] sitemap_urls: list[dict[str, str]] = []
games: QuerySet[Game] = Game.objects.all() games: QuerySet[Game] = Game.objects.all()
for game in games: for game in games:
resource_url: str = reverse("twitch:game_detail", args=[game.twitch_id]) resource_url: str = reverse("twitch:game_detail", args=[game.twitch_id])
full_url: str = f"{base_url}{resource_url}" full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str] = {"url": full_url} entry: dict[str, str] = {"loc": full_url}
if game.updated_at: if game.updated_at:
entry["lastmod"] = game.updated_at.isoformat() entry["lastmod"] = game.updated_at.isoformat()
@ -411,7 +417,7 @@ def sitemap_twitch_others_view(request: HttpRequest) -> HttpResponse:
for org in orgs: for org in orgs:
resource_url: str = reverse("twitch:organization_detail", args=[org.twitch_id]) resource_url: str = reverse("twitch:organization_detail", args=[org.twitch_id])
full_url: str = f"{base_url}{resource_url}" full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str] = {"url": full_url} entry: dict[str, str] = {"loc": full_url}
if org.updated_at: if org.updated_at:
entry["lastmod"] = org.updated_at.isoformat() entry["lastmod"] = org.updated_at.isoformat()
@ -422,10 +428,10 @@ def sitemap_twitch_others_view(request: HttpRequest) -> HttpResponse:
for badge_set in badge_sets: for badge_set in badge_sets:
resource_url = reverse("twitch:badge_set_detail", args=[badge_set.set_id]) resource_url = reverse("twitch:badge_set_detail", args=[badge_set.set_id])
full_url = f"{base_url}{resource_url}" full_url = f"{base_url}{resource_url}"
sitemap_urls.append({"url": full_url}) sitemap_urls.append({"loc": full_url})
# Emotes currently don't have individual detail pages, but keep a listing here. # Emotes currently don't have individual detail pages, but keep a listing here.
sitemap_urls.append({"url": f"{base_url}/emotes/"}) sitemap_urls.append({"loc": f"{base_url}/emotes/"})
xml_content: str = _render_urlset_xml(sitemap_urls) xml_content: str = _render_urlset_xml(sitemap_urls)
return HttpResponse(xml_content, content_type="application/xml") return HttpResponse(xml_content, content_type="application/xml")
@ -441,7 +447,8 @@ def sitemap_kick_view(request: HttpRequest) -> HttpResponse:
Returns: Returns:
HttpResponse: The rendered sitemap XML. HttpResponse: The rendered sitemap XML.
""" """
base_url: str = _build_base_url(request) # `request` is unused but required by Django's view signature.
base_url: str = _build_base_url()
sitemap_urls: list[dict[str, str]] = [] sitemap_urls: list[dict[str, str]] = []
kick_campaigns: QuerySet[KickDropCampaign] = KickDropCampaign.objects.filter( kick_campaigns: QuerySet[KickDropCampaign] = KickDropCampaign.objects.filter(
@ -450,19 +457,11 @@ def sitemap_kick_view(request: HttpRequest) -> HttpResponse:
for campaign in kick_campaigns: for campaign in kick_campaigns:
resource_url: str = reverse("kick:campaign_detail", args=[campaign.kick_id]) resource_url: str = reverse("kick:campaign_detail", args=[campaign.kick_id])
full_url: str = f"{base_url}{resource_url}" full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str] = {"url": full_url} entry: dict[str, str] = {"loc": full_url}
if campaign.updated_at: if campaign.updated_at:
entry["lastmod"] = campaign.updated_at.isoformat() entry["lastmod"] = campaign.updated_at.isoformat()
sitemap_urls.append(entry) 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) xml_content: str = _render_urlset_xml(sitemap_urls)
return HttpResponse(xml_content, content_type="application/xml") return HttpResponse(xml_content, content_type="application/xml")
@ -477,10 +476,10 @@ def sitemap_youtube_view(request: HttpRequest) -> HttpResponse:
Returns: Returns:
HttpResponse: The rendered sitemap XML. HttpResponse: The rendered sitemap XML.
""" """
base_url: str = _build_base_url(request) # `request` is unused but required by Django's view signature.
base_url: str = _build_base_url()
sitemap_urls: list[dict[str, str]] = [ sitemap_urls: list[dict[str, str]] = [
{"url": f"{base_url}{reverse('youtube:index')}"}, {"loc": f"{base_url}{reverse('youtube:index')}"},
] ]
xml_content: str = _render_urlset_xml(sitemap_urls) xml_content: str = _render_urlset_xml(sitemap_urls)
@ -775,7 +774,7 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse:
def dataset_backup_download_view( def dataset_backup_download_view(
request: HttpRequest, # noqa: ARG001 request: HttpRequest,
relative_path: str, relative_path: str,
) -> FileResponse: ) -> FileResponse:
"""Download a dataset backup from the data directory. """Download a dataset backup from the data directory.

View file

@ -49,7 +49,7 @@ dev = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings" DJANGO_SETTINGS_MODULE = "config.settings"
python_files = ["test_*.py", "*_test.py"] python_files = ["test_*.py", "*_test.py"]
addopts = "-n 4" addopts = ""
filterwarnings = [ filterwarnings = [
"ignore:Parsing dates involving a day of month without a year specified is ambiguous:DeprecationWarning", "ignore:Parsing dates involving a day of month without a year specified is ambiguous:DeprecationWarning",
] ]
@ -87,6 +87,7 @@ lint.ignore = [
"PLR0912", # Checks for functions or methods with too many branches, including (nested) if, elif, and else branches, for loops, try-except clauses, and match and case statements. "PLR0912", # Checks for functions or methods with too many branches, including (nested) if, elif, and else branches, for loops, try-except clauses, and match and case statements.
"PLR6301", # Checks for the presence of unused self parameter in methods definitions. "PLR6301", # Checks for the presence of unused self parameter in methods definitions.
"RUF012", # Checks for mutable default values in class attributes. "RUF012", # Checks for mutable default values in class attributes.
"ARG001", # Checks for the presence of unused arguments in function definitions.
# Conflicting lint rules when using Ruff's formatter # Conflicting lint rules when using Ruff's formatter
# https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules

View file

@ -140,6 +140,18 @@
... and {{ campaign_data.allowed_channels|length|add:"-5" }} more ... and {{ campaign_data.allowed_channels|length|add:"-5" }} more
</li> </li>
{% endif %} {% endif %}
{% else %}
{% if campaign_data.campaign.game.twitch_directory_url %}
<li>
<a href="{{ campaign_data.campaign.game.twitch_directory_url }}"
rel="nofollow ugc"
title="Find streamers playing {{ campaign_data.campaign.game.display_name }} with drops enabled">
Go to a participating live channel
</a>
</li>
{% else %}
<li>Failed to get Twitch directory URL :(</li>
{% endif %}
{% endif %} {% endif %}
</ul> </ul>
</div> </div>

View file

@ -2,6 +2,7 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import auto_prefetch import auto_prefetch
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -64,7 +65,7 @@ class Organization(auto_prefetch.Model):
def feed_description(self: Organization) -> str: def feed_description(self: Organization) -> str:
"""Return a description of the organization for RSS feeds.""" """Return a description of the organization for RSS feeds."""
name: str = self.name or "Unknown Organization" name: str = self.name or "Unknown Organization"
url: str = reverse("twitch:organization_detail", args=[self.twitch_id]) url: str = f"{settings.BASE_URL}{reverse('twitch:organization_detail', args=[self.twitch_id])}"
return format_html( return format_html(
'<p>New Twitch organization added to TTVDrops:</p>\n<p><a href="{}">{}</a></p>', '<p>New Twitch organization added to TTVDrops:</p>\n<p><a href="{}">{}</a></p>',

View file

@ -177,11 +177,12 @@ class RSSFeedTestCase(TestCase):
assert 'rel="self"' in content, msg assert 'rel="self"' in content, msg
msg: str = f"Expected self link to point to campaign feed URL, got: {content}" msg: str = f"Expected self link to point to campaign feed URL, got: {content}"
assert 'href="http://testserver/atom/campaigns/"' in content, msg assert 'href="https://ttvdrops.lovinator.space/atom/campaigns/"' in content, msg
msg: str = f"Expected entry ID to be the campaign URL, got: {content}" msg: str = f"Expected entry ID to be the campaign URL, got: {content}"
assert ( assert (
"<id>http://testserver/twitch/campaigns/test-campaign-123/</id>" in content "<id>https://ttvdrops.lovinator.space/twitch/campaigns/test-campaign-123/</id>"
in content
), msg ), msg
def test_all_atom_feeds_use_url_ids_and_correct_self_links(self) -> None: def test_all_atom_feeds_use_url_ids_and_correct_self_links(self) -> None:
@ -190,27 +191,27 @@ class RSSFeedTestCase(TestCase):
( (
"core:campaign_feed_atom", "core:campaign_feed_atom",
{}, {},
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", f"https://ttvdrops.lovinator.space{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
), ),
( (
"core:game_feed_atom", "core:game_feed_atom",
{}, {},
f"http://testserver{reverse('twitch:game_detail', args=[self.game.twitch_id])}", f"https://ttvdrops.lovinator.space{reverse('twitch:game_detail', args=[self.game.twitch_id])}",
), ),
( (
"core:game_campaign_feed_atom", "core:game_campaign_feed_atom",
{"twitch_id": self.game.twitch_id}, {"twitch_id": self.game.twitch_id},
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", f"https://ttvdrops.lovinator.space{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
), ),
( (
"core:organization_feed_atom", "core:organization_feed_atom",
{}, {},
f"http://testserver{reverse('twitch:organization_detail', args=[self.org.twitch_id])}", f"https://ttvdrops.lovinator.space{reverse('twitch:organization_detail', args=[self.org.twitch_id])}",
), ),
( (
"core:reward_campaign_feed_atom", "core:reward_campaign_feed_atom",
{}, {},
f"http://testserver{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}", f"https://ttvdrops.lovinator.space{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
), ),
] ]
@ -221,7 +222,7 @@ class RSSFeedTestCase(TestCase):
assert response.status_code == 200 assert response.status_code == 200
content: str = response.content.decode("utf-8") content: str = response.content.decode("utf-8")
expected_self_link: str = f'href="http://testserver{url}"' expected_self_link: str = f'href="https://ttvdrops.lovinator.space{url}"'
msg: str = f"Expected self link in Atom feed {url_name}, got: {content}" msg: str = f"Expected self link in Atom feed {url_name}, got: {content}"
assert 'rel="self"' in content, msg assert 'rel="self"' in content, msg
@ -317,7 +318,7 @@ class RSSFeedTestCase(TestCase):
msg: str = ( msg: str = (
f"Expected absolute media enclosure URLs for {url}, got: {content}" f"Expected absolute media enclosure URLs for {url}, got: {content}"
) )
assert "http://testserver/media/" in content, msg assert "https://ttvdrops.lovinator.space/media/" in content, msg
assert 'url="/media/' not in content, msg assert 'url="/media/' not in content, msg
assert 'href="/media/' not in content, msg assert 'href="/media/' not in content, msg
@ -1321,27 +1322,27 @@ class DiscordFeedTestCase(TestCase):
( (
"core:campaign_feed_discord", "core:campaign_feed_discord",
{}, {},
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", f"https://ttvdrops.lovinator.space{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
), ),
( (
"core:game_feed_discord", "core:game_feed_discord",
{}, {},
f"http://testserver{reverse('twitch:game_detail', args=[self.game.twitch_id])}", f"https://ttvdrops.lovinator.space{reverse('twitch:game_detail', args=[self.game.twitch_id])}",
), ),
( (
"core:game_campaign_feed_discord", "core:game_campaign_feed_discord",
{"twitch_id": self.game.twitch_id}, {"twitch_id": self.game.twitch_id},
f"http://testserver{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", f"https://ttvdrops.lovinator.space{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}",
), ),
( (
"core:organization_feed_discord", "core:organization_feed_discord",
{}, {},
f"http://testserver{reverse('twitch:organization_detail', args=[self.org.twitch_id])}", f"https://ttvdrops.lovinator.space{reverse('twitch:organization_detail', args=[self.org.twitch_id])}",
), ),
( (
"core:reward_campaign_feed_discord", "core:reward_campaign_feed_discord",
{}, {},
f"http://testserver{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}", f"https://ttvdrops.lovinator.space{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}",
), ),
] ]
@ -1352,7 +1353,7 @@ class DiscordFeedTestCase(TestCase):
assert response.status_code == 200 assert response.status_code == 200
content: str = response.content.decode("utf-8") content: str = response.content.decode("utf-8")
expected_self_link: str = f'href="http://testserver{url}"' expected_self_link: str = f'href="https://ttvdrops.lovinator.space{url}"'
msg: str = f"Expected self link in Discord feed {url_name}, got: {content}" msg: str = f"Expected self link in Discord feed {url_name}, got: {content}"
assert 'rel="self"' in content, msg assert 'rel="self"' in content, msg

View file

@ -41,6 +41,12 @@ if TYPE_CHECKING:
from twitch.views import Page from twitch.views import Page
@pytest.fixture(autouse=True)
def apply_base_url_override(settings: object) -> None:
"""Ensure BASE_URL is globally overridden for all tests."""
settings.BASE_URL = "https://ttvdrops.lovinator.space" # pyright: ignore[reportAttributeAccessIssue]
@pytest.mark.django_db @pytest.mark.django_db
class TestSearchView: class TestSearchView:
"""Tests for the search_view function.""" """Tests for the search_view function."""
@ -1562,14 +1568,14 @@ class TestSitemapView:
# Check for the homepage and a few key list views across apps. # Check for the homepage and a few key list views across apps.
assert ( assert (
"<loc>http://testserver/</loc>" in content "<loc>https://ttvdrops.lovinator.space/</loc>" in content
or "<loc>http://localhost:8000/</loc>" in content or "<loc>http://localhost:8000/</loc>" in content
) )
assert "http://testserver/twitch/" in content assert "https://ttvdrops.lovinator.space/twitch/" in content
assert "http://testserver/kick/" in content assert "https://ttvdrops.lovinator.space/kick/" in content
assert "http://testserver/youtube/" in content assert "https://ttvdrops.lovinator.space/youtube/" in content
assert "http://testserver/twitch/campaigns/" in content assert "https://ttvdrops.lovinator.space/twitch/campaigns/" in content
assert "http://testserver/twitch/games/" in content assert "https://ttvdrops.lovinator.space/twitch/games/" in content
def test_sitemap_contains_game_detail_pages( def test_sitemap_contains_game_detail_pages(
self, self,
@ -1605,13 +1611,13 @@ class TestSitemapView:
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-drops.xml") response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-drops.xml")
content: str = response.content.decode() content: str = response.content.decode()
active_loc: str = f"<loc>http://testserver/twitch/campaigns/{active_campaign.twitch_id}/</loc>" active_loc: str = f"<loc>https://ttvdrops.lovinator.space/twitch/campaigns/{active_campaign.twitch_id}/</loc>"
active_index: int = content.find(active_loc) active_index: int = content.find(active_loc)
assert active_index != -1 assert active_index != -1
active_end: int = content.find("</url>", active_index) active_end: int = content.find("</url>", active_index)
assert active_end != -1 assert active_end != -1
inactive_loc: str = f"<loc>http://testserver/twitch/campaigns/{inactive_campaign.twitch_id}/</loc>" inactive_loc: str = f"<loc>https://ttvdrops.lovinator.space/twitch/campaigns/{inactive_campaign.twitch_id}/</loc>"
inactive_index: int = content.find(inactive_loc) inactive_index: int = content.find(inactive_loc)
assert inactive_index != -1 assert inactive_index != -1
inactive_end: int = content.find("</url>", inactive_index) inactive_end: int = content.find("</url>", inactive_index)
@ -1629,17 +1635,13 @@ class TestSitemapView:
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-kick.xml") response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-kick.xml")
content: str = response.content.decode() content: str = response.content.decode()
active_loc: str = ( active_loc: str = f"<loc>https://ttvdrops.lovinator.space/kick/campaigns/{active_campaign.kick_id}/</loc>"
f"<loc>http://testserver/kick/campaigns/{active_campaign.kick_id}/</loc>"
)
active_index: int = content.find(active_loc) active_index: int = content.find(active_loc)
assert active_index != -1 assert active_index != -1
active_end: int = content.find("</url>", active_index) active_end: int = content.find("</url>", active_index)
assert active_end != -1 assert active_end != -1
inactive_loc: str = ( inactive_loc: str = f"<loc>https://ttvdrops.lovinator.space/kick/campaigns/{inactive_campaign.kick_id}/</loc>"
f"<loc>http://testserver/kick/campaigns/{inactive_campaign.kick_id}/</loc>"
)
inactive_index: int = content.find(inactive_loc) inactive_index: int = content.find(inactive_loc)
assert inactive_index != -1 assert inactive_index != -1
inactive_end: int = content.find("</url>", inactive_index) inactive_end: int = content.find("</url>", inactive_index)
@ -1972,7 +1974,7 @@ class TestImageObjectStructuredData:
assert img["creator"] == { assert img["creator"] == {
"@type": "Organization", "@type": "Organization",
"name": org.name, "name": org.name,
"url": f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}", "url": f"https://ttvdrops.lovinator.space{reverse('twitch:organization_detail', args=[org.twitch_id])}",
} }
def test_game_schema_no_image_when_no_box_art( def test_game_schema_no_image_when_no_box_art(
@ -2084,7 +2086,7 @@ class TestImageObjectStructuredData:
assert img["creator"] == { assert img["creator"] == {
"@type": "Organization", "@type": "Organization",
"name": org.name, "name": org.name,
"url": f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}", "url": f"https://ttvdrops.lovinator.space{reverse('twitch:organization_detail', args=[org.twitch_id])}",
} }
def test_campaign_schema_no_image_when_no_image_url( def test_campaign_schema_no_image_when_no_image_url(