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 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")
diff --git a/core/views.py b/core/views.py
index 4c3000c..bcba39e 100644
--- a/core/views.py
+++ b/core/views.py
@@ -15,14 +15,15 @@ from django.db.models import Max
from django.db.models import OuterRef
from django.db.models import Prefetch
from django.db.models import Q
+from django.db.models import QuerySet
from django.db.models.functions import Trim
-from django.db.models.query import QuerySet
from django.http import FileResponse
from django.http import Http404
+from django.http import HttpRequest
from django.http import HttpResponse
from django.shortcuts import render
+from django.shortcuts import reverse
from django.template.defaultfilters import filesizeformat
-from django.urls import reverse
from django.utils import timezone
from kick.models import KickChannel
@@ -88,6 +89,9 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
Returns:
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
# consider having a single "seo_info" parameter that
# can contain all of these optional fields. This would make
@@ -136,11 +140,13 @@ def _render_urlset_xml(
xml += '\n'
for url_entry in url_entries:
xml += " \n"
- xml += f" {url_entry['url']}\n"
- if url_entry.get("lastmod"):
+ loc = url_entry.get("loc") or url_entry.get("url") # Handle both keys
+ if loc:
+ xml += f" {loc}\n"
+ if "lastmod" in url_entry:
xml += f" {url_entry['lastmod']}\n"
xml += " \n"
- xml += ""
+ xml += "\n"
return xml
@@ -165,9 +171,9 @@ def _render_sitemap_index_xml(sitemap_entries: list[dict[str, str]]) -> str:
return xml
-def _build_base_url(request: HttpRequest) -> str:
- """Return base url including scheme and host."""
- return f"{request.scheme}://{request.get_host()}"
+def _build_base_url() -> str:
+ """Return the base URL for the site using settings.BASE_URL."""
+ return getattr(settings, "BASE_URL", "https://ttvdrops.lovinator.space")
# MARK: /sitemap.xml
@@ -180,7 +186,7 @@ def sitemap_view(request: HttpRequest) -> HttpResponse:
Returns:
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.
# Do not fabricate a lastmod date if the section has no data.
@@ -249,66 +255,18 @@ def sitemap_static_view(request: HttpRequest) -> HttpResponse:
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",
+ # `request` is unused but required by Django's view signature.
+ base_url: str = _build_base_url()
+ sitemap_urls: list[dict[str, str]] = [
+ {"loc": f"{base_url}/"},
+ {"loc": f"{base_url}/twitch/"},
+ {"loc": f"{base_url}/twitch/campaigns/"},
+ {"loc": f"{base_url}/twitch/games/"},
+ {"loc": f"{base_url}/kick/"},
+ {"loc": f"{base_url}/youtube/"},
+ {"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)
return HttpResponse(xml_content, content_type="application/xml")
@@ -323,14 +281,15 @@ def sitemap_twitch_channels_view(request: HttpRequest) -> HttpResponse:
Returns:
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]] = []
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] = {"url": full_url}
+ entry: dict[str, str] = {"loc": full_url}
if channel.updated_at:
entry["lastmod"] = channel.updated_at.isoformat()
sitemap_urls.append(entry)
@@ -349,7 +308,8 @@ def sitemap_twitch_drops_view(request: HttpRequest) -> HttpResponse:
Returns:
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]] = []
campaigns: QuerySet[DropCampaign] = DropCampaign.objects.filter(
@@ -358,7 +318,7 @@ def sitemap_twitch_drops_view(request: HttpRequest) -> HttpResponse:
for campaign in campaigns:
resource_url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
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:
campaign_url_entry["lastmod"] = campaign.updated_at.isoformat()
sitemap_urls.append(campaign_url_entry)
@@ -371,7 +331,7 @@ def sitemap_twitch_drops_view(request: HttpRequest) -> HttpResponse:
)
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:
reward_campaign_url_entry["lastmod"] = (
reward_campaign.updated_at.isoformat()
@@ -393,14 +353,15 @@ def sitemap_twitch_others_view(request: HttpRequest) -> HttpResponse:
Returns:
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]] = []
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] = {"url": full_url}
+ entry: dict[str, str] = {"loc": full_url}
if game.updated_at:
entry["lastmod"] = game.updated_at.isoformat()
@@ -411,7 +372,7 @@ def sitemap_twitch_others_view(request: HttpRequest) -> HttpResponse:
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] = {"url": full_url}
+ entry: dict[str, str] = {"loc": full_url}
if org.updated_at:
entry["lastmod"] = org.updated_at.isoformat()
@@ -422,10 +383,10 @@ def sitemap_twitch_others_view(request: HttpRequest) -> HttpResponse:
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})
+ sitemap_urls.append({"loc": full_url})
# 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)
return HttpResponse(xml_content, content_type="application/xml")
@@ -441,7 +402,8 @@ def sitemap_kick_view(request: HttpRequest) -> HttpResponse:
Returns:
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]] = []
kick_campaigns: QuerySet[KickDropCampaign] = KickDropCampaign.objects.filter(
@@ -450,19 +412,11 @@ def sitemap_kick_view(request: HttpRequest) -> HttpResponse:
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] = {"url": full_url}
-
+ entry: dict[str, str] = {"loc": 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")
@@ -477,10 +431,10 @@ def sitemap_youtube_view(request: HttpRequest) -> HttpResponse:
Returns:
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]] = [
- {"url": f"{base_url}{reverse('youtube:index')}"},
+ {"loc": f"{base_url}{reverse('youtube:index')}"},
]
xml_content: str = _render_urlset_xml(sitemap_urls)
@@ -775,7 +729,7 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse:
def dataset_backup_download_view(
- request: HttpRequest, # noqa: ARG001
+ request: HttpRequest,
relative_path: str,
) -> FileResponse:
"""Download a dataset backup from the data directory.
diff --git a/pyproject.toml b/pyproject.toml
index 8a42e43..28e688f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,7 +49,7 @@ dev = [
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings"
python_files = ["test_*.py", "*_test.py"]
-addopts = "-n 4"
+addopts = ""
filterwarnings = [
"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.
"PLR6301", # Checks for the presence of unused self parameter in methods definitions.
"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
# https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
diff --git a/twitch/models.py b/twitch/models.py
index 6e85870..1ed2a57 100644
--- a/twitch/models.py
+++ b/twitch/models.py
@@ -2,6 +2,7 @@ import logging
from typing import TYPE_CHECKING
import auto_prefetch
+from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.db import models
from django.urls import reverse
@@ -64,7 +65,7 @@ class Organization(auto_prefetch.Model):
def feed_description(self: Organization) -> str:
"""Return a description of the organization for RSS feeds."""
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(
'New Twitch organization added to TTVDrops:
\n{}
',
diff --git a/twitch/tests/test_feeds.py b/twitch/tests/test_feeds.py
index cb1fdec..b69e9cb 100644
--- a/twitch/tests/test_feeds.py
+++ b/twitch/tests/test_feeds.py
@@ -177,11 +177,12 @@ class RSSFeedTestCase(TestCase):
assert 'rel="self"' in content, msg
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}"
assert (
- "http://testserver/twitch/campaigns/test-campaign-123/" in content
+ "https://ttvdrops.lovinator.space/twitch/campaigns/test-campaign-123/"
+ in content
), msg
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",
{},
- 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",
{},
- 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",
{"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",
{},
- 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",
{},
- 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
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}"
assert 'rel="self"' in content, msg
@@ -317,7 +318,7 @@ class RSSFeedTestCase(TestCase):
msg: str = (
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 'href="/media/' not in content, msg
@@ -1321,27 +1322,27 @@ class DiscordFeedTestCase(TestCase):
(
"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",
{},
- 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",
{"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",
{},
- 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",
{},
- 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
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}"
assert 'rel="self"' in content, msg
diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py
index 6186662..11ac98c 100644
--- a/twitch/tests/test_views.py
+++ b/twitch/tests/test_views.py
@@ -41,6 +41,12 @@ if TYPE_CHECKING:
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"
+
+
@pytest.mark.django_db
class TestSearchView:
"""Tests for the search_view function."""
@@ -1562,14 +1568,14 @@ class TestSitemapView:
# Check for the homepage and a few key list views across apps.
assert (
- "http://testserver/" in content
+ "https://ttvdrops.lovinator.space/" 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 "https://ttvdrops.lovinator.space/twitch/" in content
+ assert "https://ttvdrops.lovinator.space/kick/" in content
+ assert "https://ttvdrops.lovinator.space/youtube/" in content
+ assert "https://ttvdrops.lovinator.space/twitch/campaigns/" in content
+ assert "https://ttvdrops.lovinator.space/twitch/games/" in content
def test_sitemap_contains_game_detail_pages(
self,
@@ -1605,13 +1611,13 @@ class TestSitemapView:
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_loc: str = f"https://ttvdrops.lovinator.space/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_loc: str = f"https://ttvdrops.lovinator.space/twitch/campaigns/{inactive_campaign.twitch_id}/"
inactive_index: int = content.find(inactive_loc)
assert inactive_index != -1
inactive_end: int = content.find("", inactive_index)
@@ -1629,17 +1635,13 @@ class TestSitemapView:
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_loc: str = f"https://ttvdrops.lovinator.space/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_loc: str = f"https://ttvdrops.lovinator.space/kick/campaigns/{inactive_campaign.kick_id}/"
inactive_index: int = content.find(inactive_loc)
assert inactive_index != -1
inactive_end: int = content.find("", inactive_index)
@@ -1972,7 +1974,7 @@ class TestImageObjectStructuredData:
assert img["creator"] == {
"@type": "Organization",
"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(
@@ -2084,7 +2086,7 @@ class TestImageObjectStructuredData:
assert img["creator"] == {
"@type": "Organization",
"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(