From 768d986556a227a9ab3109dec10b8e5c738d184d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Helle=C5=9Ben?= Date: Tue, 10 Mar 2026 11:27:17 +0100 Subject: [PATCH] Use 404 image when no image is available --- config/settings.py | 2 +- static/404.svg | 8 ++++++++ twitch/apps.py | 32 ++++++++++++++++++++++++++++- twitch/templatetags/image_tags.py | 18 ++++++++++++++-- twitch/tests/test_image_tags.py | 34 +++++++++++++++++++++++++++++-- 5 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 static/404.svg diff --git a/config/settings.py b/config/settings.py index b112427..186fd4d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -103,7 +103,7 @@ MEDIA_URL = "/media/" STATIC_ROOT: Path = DATA_DIR / "staticfiles" STATIC_ROOT.mkdir(exist_ok=True) -STATIC_URL = "static/" +STATIC_URL = "/static/" STATICFILES_DIRS: list[Path] = [BASE_DIR / "static"] TIME_ZONE = "UTC" diff --git a/static/404.svg b/static/404.svg new file mode 100644 index 0000000..1e7b98d --- /dev/null +++ b/static/404.svg @@ -0,0 +1,8 @@ + + + + + :( + 404 + + diff --git a/twitch/apps.py b/twitch/apps.py index 33fadea..8f557e4 100644 --- a/twitch/apps.py +++ b/twitch/apps.py @@ -1,8 +1,38 @@ +import io +import logging +from typing import TYPE_CHECKING + from django.apps import AppConfig +from django.db.models.fields.files import FieldFile + +if TYPE_CHECKING: + from collections.abc import Callable class TwitchConfig(AppConfig): - """AppConfig subclass for the 'twitch' application.""" + """Django app configuration for the Twitch app.""" default_auto_field = "django.db.models.BigAutoField" name = "twitch" + + def ready(self) -> None: # noqa: D102 + logger: logging.Logger = logging.getLogger("ttvdrops.apps") + + # Patch FieldFile.open to swallow FileNotFoundError and provide + # an empty in-memory file-like object so image dimension + # calculations don't crash when the on-disk file was removed. + try: + orig_open: Callable[..., FieldFile] = FieldFile.open + + def _safe_open(self: FieldFile, mode: str = "rb") -> FieldFile: + try: + return orig_open(self, mode) + except FileNotFoundError: + # Provide an empty BytesIO so subsequent dimension checks + # read harmlessly and return (None, None). + self._file = io.BytesIO(b"") # pyright: ignore[reportAttributeAccessIssue] + return self + + FieldFile.open = _safe_open + except (AttributeError, TypeError) as exc: + logger.debug("Failed to patch FieldFile.open: %s", exc) diff --git a/twitch/templatetags/image_tags.py b/twitch/templatetags/image_tags.py index a246f94..70a9c44 100644 --- a/twitch/templatetags/image_tags.py +++ b/twitch/templatetags/image_tags.py @@ -1,9 +1,10 @@ -"""Custom template tags for rendering responsive images with modern formats.""" - +import logging from typing import TYPE_CHECKING from urllib.parse import urlparse from django import template +from django.conf import settings +from django.core.files.storage import default_storage from django.utils.html import format_html from django.utils.safestring import SafeString @@ -11,6 +12,7 @@ if TYPE_CHECKING: from django.utils.safestring import SafeText register = template.Library() +logger = logging.getLogger("ttvdrops.image_tags") def get_format_url(image_url: str, fmt: str) -> str: @@ -70,6 +72,18 @@ def picture( # noqa: PLR0913, PLR0917 if not src: return SafeString("") + # If the src points to a local MEDIA file but the underlying file is + # missing on disk, replace with a small static fallback to avoid + # raising FileNotFoundError during template rendering. + try: + media_url: str = settings.MEDIA_URL or "/media/" + if src.startswith(media_url): + name: str = src[len(media_url) :].lstrip("/") + if not default_storage.exists(name): + src = "/static/404.svg" + except (ValueError, OSError) as exc: + logger.debug("Error while resolving media file %s: %s", src, exc) + # For Twitch CDN URLs, skip format conversion and use simple img tag if "static-cdn.jtvnw.net" in src: return format_html( diff --git a/twitch/tests/test_image_tags.py b/twitch/tests/test_image_tags.py index 702fac2..85d0f82 100644 --- a/twitch/tests/test_image_tags.py +++ b/twitch/tests/test_image_tags.py @@ -1,13 +1,43 @@ -"""Tests for custom image template tags.""" - from django.template import Context from django.template import Template +from django.test import SimpleTestCase +from django.test import override_settings from django.utils.safestring import SafeString +from twitch.models import Game from twitch.templatetags.image_tags import get_format_url from twitch.templatetags.image_tags import picture +@override_settings(MEDIA_URL="/media/", STATIC_URL="/static/") +class ImageTagsTests(SimpleTestCase): + """Tests for image template tags and related functionality.""" + + def test_picture_empty_src_returns_empty(self) -> None: + """Test that picture tag with empty src returns empty string.""" + result = picture("") + assert not str(result) + + def test_picture_keeps_external_url(self) -> None: + """Test that picture tag does not modify external URLs and does not attempt format conversion.""" + src = "https://example.com/images/sample.png" + result: SafeString = picture(src, alt="alt", width=16, height=16) + rendered = str(result) + + # Should still contain the original external URL + assert src in rendered + + def test_model_init_with_missing_image_does_not_raise(self) -> None: + """Test that initializing a model with a missing image file does not raise an error.""" + # Simulate a Game instance with a missing local image file. The + # AppConfig.ready() wrapper should prevent FileNotFoundError during + # model initialization. + g = Game(twitch_id="test-game", box_art_file="campaigns/images/missing.png") + # If initialization reached this point without raising, we consider + # the protection successful. + assert g.twitch_id == "test-game" + + class TestGetFormatUrl: """Tests for the get_format_url helper function."""