diff --git a/lunes_cms/api/v2/views/word_viewset.py b/lunes_cms/api/v2/views/word_viewset.py
index 3f4adf09..19301db8 100644
--- a/lunes_cms/api/v2/views/word_viewset.py
+++ b/lunes_cms/api/v2/views/word_viewset.py
@@ -2,19 +2,50 @@
from typing import Any
-from django.db.models import QuerySet
+from django.db.models import Prefetch, QuerySet
+from django.utils.translation import gettext_lazy as _
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework import viewsets
+from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from ....cmsv2.models import Word
+from ....cmsv2.models.unit import UnitWordRelation
from ..matomo_tracking import matomo_tracking
from ..serializers import WordSerializer
+#: The minimum length a ``search`` term has to have
+MIN_SEARCH_LENGTH = 3
+
+@extend_schema(
+ parameters=[
+ OpenApiParameter(
+ name="search",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ required=False,
+ description=(
+ "Case-insensitive substring the returned words have to contain "
+ "(at least 3 characters, otherwise the request is rejected with "
+ "HTTP 400). When given, all public images of a word are returned, "
+ "including the images defined on its released unit relations (not "
+ "only the word's default image)."
+ ),
+ )
+ ]
+)
class WordViewSet(viewsets.ModelViewSet):
"""
- Retrieve the list of all words with their default images, or a single word by id
+ Retrieve the list of all words with their default images, or a single word by id.
+
+ Supports an optional ``search`` query parameter that keeps only the words whose
+ term contains the given string (case-insensitive). The term has to be at least
+ three characters long, otherwise the request is rejected with HTTP 400. When
+ searching, the returned images include the images defined on the word's released
+ unit relations in addition to the word's default image.
"""
serializer_class = WordSerializer
@@ -46,4 +77,39 @@ def get_queryset(self) -> QuerySet[Word]:
audio_check_status="CONFIRMED",
image_check_status="CONFIRMED",
)
+
+ search = self.request.query_params.get("search")
+ if search is not None:
+ search = search.strip()
+ if len(search) < MIN_SEARCH_LENGTH:
+ raise ValidationError(
+ {
+ "search": _(
+ "The search term has to be at least %(min)d characters long."
+ )
+ % {"min": MIN_SEARCH_LENGTH}
+ }
+ )
+ queryset = queryset.filter(word__icontains=search)
+ # When searching we expose all public images of a word, so prefetch the
+ # images of its released unit relations into the attribute that
+ # ``Word.images_for_api`` reads from.
+ public_relations = (
+ UnitWordRelation.objects.filter(
+ unit__released=True,
+ unit__jobs__released=True,
+ word__audio_check_status="CONFIRMED",
+ image_check_status="CONFIRMED",
+ )
+ .exclude(image="")
+ .order_by("unit__title")
+ )
+ queryset = queryset.prefetch_related(
+ Prefetch(
+ "unit_word_relations",
+ public_relations,
+ to_attr="unit_word_relations_of_job",
+ )
+ )
+
return queryset.distinct().order_by("word")
diff --git a/lunes_cms/bildschatz/__init__.py b/lunes_cms/bildschatz/__init__.py
new file mode 100644
index 00000000..c4db3f70
--- /dev/null
+++ b/lunes_cms/bildschatz/__init__.py
@@ -0,0 +1,8 @@
+"""
+This is the app which serves the public "Bildschatz" image database website.
+
+Bildschatz lets anyone search for a word and browse all publicly available
+images that are assigned to the matching words (including the images defined on
+the words' unit relations). It is a thin, static frontend on top of the public
+``/api/v2/words/`` endpoint.
+"""
diff --git a/lunes_cms/bildschatz/apps.py b/lunes_cms/bildschatz/apps.py
new file mode 100644
index 00000000..cb2613ef
--- /dev/null
+++ b/lunes_cms/bildschatz/apps.py
@@ -0,0 +1,13 @@
+from django.apps import AppConfig
+from django.utils.translation import gettext_lazy as _
+
+
+class BildschatzConfig(AppConfig):
+ """
+ Application settings for the `bildschatz` app,
+ which serves the public Bildschatz image database website.
+ Inherits from `AppConfig`.
+ """
+
+ name = "lunes_cms.bildschatz"
+ verbose_name = _("Bildschatz")
diff --git a/lunes_cms/bildschatz/templates/bildschatz.html b/lunes_cms/bildschatz/templates/bildschatz.html
new file mode 100644
index 00000000..6cf1e6d5
--- /dev/null
+++ b/lunes_cms/bildschatz/templates/bildschatz.html
@@ -0,0 +1,571 @@
+
+
+
Die offene Bilddatenbank für Wörter. Gib ein Wort ein und finde passende Bilder — frei nutzbar.
+
+
+
+
+
+
+
Bitte gib mindestens 3 Zeichen ein.
+
+
+
+
+
+
+
+
+
+
+
+
+ Bildschatz
+
+
+
+
+
+
+
+
Bitte gib mindestens 3 Zeichen ein.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% verbatim %}
+
+{% endverbatim %}
+
+
diff --git a/lunes_cms/bildschatz/urls.py b/lunes_cms/bildschatz/urls.py
new file mode 100644
index 00000000..f301e587
--- /dev/null
+++ b/lunes_cms/bildschatz/urls.py
@@ -0,0 +1,15 @@
+"""
+URL patterns for the public Bildschatz website.
+"""
+
+from django.urls import path
+
+from . import views
+
+#: The namespace for this URL config (see :attr:`django.urls.ResolverMatch.app_name`)
+app_name = "bildschatz"
+
+#: The url patterns of this module (see :doc:`django:topics/http/urls`)
+urlpatterns = [
+ path("", views.index, name="index"),
+]
diff --git a/lunes_cms/bildschatz/views/__init__.py b/lunes_cms/bildschatz/views/__init__.py
new file mode 100644
index 00000000..2079d08f
--- /dev/null
+++ b/lunes_cms/bildschatz/views/__init__.py
@@ -0,0 +1 @@
+from .index import index
diff --git a/lunes_cms/bildschatz/views/index.py b/lunes_cms/bildschatz/views/index.py
new file mode 100644
index 00000000..e4e74bb8
--- /dev/null
+++ b/lunes_cms/bildschatz/views/index.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import render
+
+
+def index(request: HttpRequest) -> HttpResponse:
+ """
+ Render the public Bildschatz single-page website.
+
+ The actual search is performed client-side against the public
+ ``/api/v2/words/`` endpoint. The search term is read from the ``q`` query
+ parameter so that result pages are shareable via their URL.
+
+ :param request: current user request
+ :type request: django.http.request
+ :return: rendered response
+ :rtype: HttpResponse
+ """
+ return render(request, "bildschatz.html")
diff --git a/lunes_cms/core/settings.py b/lunes_cms/core/settings.py
index a7eebdb2..889a5819 100644
--- a/lunes_cms/core/settings.py
+++ b/lunes_cms/core/settings.py
@@ -121,6 +121,7 @@
"lunes_cms.cms",
"lunes_cms.cmsv2",
"lunes_cms.help",
+ "lunes_cms.bildschatz",
"lunes_cms.analytics",
# Django jazzmin needs to be installed before Django admin
"jazzmin",
diff --git a/lunes_cms/core/urls.py b/lunes_cms/core/urls.py
index d96deeb2..4826c6cc 100644
--- a/lunes_cms/core/urls.py
+++ b/lunes_cms/core/urls.py
@@ -35,6 +35,7 @@
RedirectView.as_view(url=get_static_url("images/logo.svg")),
),
path("api/", include("lunes_cms.api.urls", namespace="api")),
+ path("bildschatz/", include("lunes_cms.bildschatz.urls", namespace="bildschatz")),
path("", include("lunes_cms.help.urls")),
re_path(r"^i18n/", include("django.conf.urls.i18n")),
path("qr_code/", include("qr_code.urls", namespace="qr_code")),
diff --git a/lunes_cms/locale/de/LC_MESSAGES/django.po b/lunes_cms/locale/de/LC_MESSAGES/django.po
index b13d3c73..27b18dd2 100644
--- a/lunes_cms/locale/de/LC_MESSAGES/django.po
+++ b/lunes_cms/locale/de/LC_MESSAGES/django.po
@@ -65,6 +65,15 @@ msgstr "{} mit der ID {} existiert nicht."
msgid "The content type must be either 'job', 'unit' or 'word'."
msgstr "Der Inhaltstyp muss entweder 'job', 'unit' oder 'word' sein."
+#: api/v2/views/word_viewset.py
+#, python-format
+msgid "The search term has to be at least %(min)d characters long."
+msgstr "Der Suchbegriff muss mindestens %(min)d Zeichen lang sein."
+
+#: bildschatz/apps.py
+msgid "Bildschatz"
+msgstr "Bildschatz"
+
#: cms/admin.py cms/admins/document_admin.py cms/admins/training_set_admin.py
#: cms/forms.py cms/list_filter.py cms/models/discipline.py
msgid "disciplines"
diff --git a/tests/api/v2/__init__.py b/tests/api/v2/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/api/v2/test_word_search.py b/tests/api/v2/test_word_search.py
new file mode 100644
index 00000000..fd44f98d
--- /dev/null
+++ b/tests/api/v2/test_word_search.py
@@ -0,0 +1,93 @@
+"""
+Tests for the ``search`` query parameter of the public ``/api/v2/words/``
+endpoint that backs the Bildschatz website.
+"""
+
+from __future__ import annotations
+
+import pytest
+from django.test import Client
+
+# The load_test_data fixture is injected for its side effect (loading the DB).
+# pylint: disable=unused-argument
+
+#: The public words endpoint of the second API version
+WORDS_ENDPOINT = "/api/v2/words/"
+
+
+@pytest.mark.django_db
+def test_search_filters_words_case_insensitively(load_test_data: None) -> None:
+ """A ``search`` term keeps only the words that contain it (case-insensitive)."""
+ client = Client()
+ response = client.get(WORDS_ENDPOINT, {"search": "schraube"})
+
+ assert response.status_code == 200
+ words = response.json()
+ assert words, "expected at least one word matching 'schraube'"
+ assert all("schraube" in word["word"].lower() for word in words)
+
+
+@pytest.mark.parametrize("term", ["", "a", "ab", " x "])
+@pytest.mark.django_db
+def test_search_shorter_than_three_characters_is_rejected(
+ load_test_data: None, term: str
+) -> None:
+ """A ``search`` term with fewer than three characters returns HTTP 400."""
+ client = Client()
+ response = client.get(WORDS_ENDPOINT, {"search": term})
+
+ assert response.status_code == 400
+ assert "search" in response.json()
+
+
+@pytest.mark.django_db
+def test_search_with_exactly_three_characters_is_accepted(load_test_data: None) -> None:
+ """A ``search`` term with exactly three characters is accepted."""
+ client = Client()
+ response = client.get(WORDS_ENDPOINT, {"search": "sch"})
+
+ assert response.status_code == 200
+ assert all("sch" in word["word"].lower() for word in response.json())
+
+
+@pytest.mark.django_db
+def test_search_is_a_subset_of_the_full_list(load_test_data: None) -> None:
+ """Searching never returns words that are not part of the unfiltered list."""
+ client = Client()
+ all_ids = {word["id"] for word in client.get(WORDS_ENDPOINT).json()}
+ search_ids = {
+ word["id"] for word in client.get(WORDS_ENDPOINT, {"search": "schraube"}).json()
+ }
+
+ assert search_ids
+ assert search_ids <= all_ids
+
+
+@pytest.mark.django_db
+def test_no_search_param_returns_the_full_unfiltered_list(load_test_data: None) -> None:
+ """Without a ``search`` term the endpoint returns every public word."""
+ client = Client()
+ response = client.get(WORDS_ENDPOINT)
+
+ assert response.status_code == 200
+ all_words = response.json()
+ matching = client.get(WORDS_ENDPOINT, {"search": "schraube"}).json()
+ # The unfiltered list is a strict superset of any search result.
+ assert len(all_words) > len(matching) > 0
+
+
+@pytest.mark.django_db
+def test_search_returns_every_public_image_of_a_word(load_test_data: None) -> None:
+ """
+ When searching, a word exposes all of its public images, including the ones
+ defined on its released unit relations, and never fewer than the default
+ listing does.
+ """
+ client = Client()
+ default_images = {
+ word["id"]: word["images"] for word in client.get(WORDS_ENDPOINT).json()
+ }
+
+ for word in client.get(WORDS_ENDPOINT, {"search": "schraube"}).json():
+ assert word["images"], f"word {word['word']} should have at least one image"
+ assert len(word["images"]) >= len(default_images.get(word["id"], []))