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 @@ + + + + + +Bildschatz – Die offene Bilddatenbank für Wörter + + + + +{% verbatim %} + +{% endverbatim %} + + + + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+

Bildschatz

+

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"], []))