Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
8b8fb33
refactor search suggestions
jonbulz Jan 23, 2026
3baf289
define search fields in central place and handle foreign key relations
jonbulz Jan 26, 2026
a3c7c74
add search_fields description
jonbulz Jan 27, 2026
acb8718
ruff
jonbulz Jan 28, 2026
d358d6b
remove debugging console logs
jonbulz Jan 29, 2026
b31bf0d
update docstrings
jonbulz Jan 30, 2026
f76dc65
refactor get_model_cls_from_object_type
jonbulz Jan 30, 2026
a65047d
event search fields
jonbulz Jan 30, 2026
d5d823d
add location search fields
jonbulz Jan 30, 2026
3a5ff5b
add page search fields
jonbulz Jan 30, 2026
d69a586
revert changes to existing endpoints, fix linters, update docstrings
jonbulz Feb 3, 2026
cb143c4
update docstring for suggest_tokens_for_model
jonbulz Feb 3, 2026
8ff73e3
add tests for search-suggest
jonbulz Feb 3, 2026
aafd8a9
improve tests for search-suggest to check exact matches
jonbulz Feb 3, 2026
61d8778
min query length
jonbulz Feb 3, 2026
27a2d00
remove base matcher and add tests for TrigramMatcher
jonbulz Feb 3, 2026
a49b135
boost scores of tokens that start with query and add tests for scorer
jonbulz Feb 3, 2026
c86360d
move search_content_ajax to search module
jonbulz Feb 3, 2026
ed9aec3
handle JSON decode error gracefully and add comments
jonbulz Feb 6, 2026
69efca1
search fields for user and organization
jonbulz Feb 6, 2026
6ee4ee0
search fields for feedback, push notification and region
jonbulz Feb 6, 2026
d7eaffc
search fields for language
jonbulz Feb 6, 2026
f250f18
add missing URL path
jonbulz Feb 9, 2026
0de4a2b
fix docstring to work with automatic documentation
jonbulz Feb 9, 2026
17873bf
add region filtering
jonbulz Feb 10, 2026
fa78e7f
fix docstrings for real
jonbulz Feb 10, 2026
3790ffe
move search and suggest logic to mixin
jonbulz Feb 11, 2026
2e1bf9b
Merge remote-tracking branch 'origin/develop' into enhancement/refact…
jonbulz Mar 16, 2026
03f83a5
remove dead search() and suggest() methods from mixin
jonbulz Mar 17, 2026
bfafe0b
add SearchSuggestMixin to Organization, Region, Language and User
jonbulz Mar 18, 2026
3349b2e
exclude archived records from search suggest token extraction
jonbulz Mar 18, 2026
7f76080
Merge remote-tracking branch 'origin/develop' into enhancement/refact…
jonbulz Mar 23, 2026
4bcf5fb
assert that search suggestions exist when comparing lower and upperca…
jonbulz Mar 30, 2026
3a45a17
use more specific query in test_search_suggestions_are_sorted_by_scor…
jonbulz Mar 30, 2026
6975698
use search_suggest endpoint for MediaFile
jonbulz Apr 13, 2026
489348f
add directories to media file search results
jonbulz Apr 13, 2026
c190203
lower default min similarity to 0.1
jonbulz Apr 13, 2026
2282c4a
extract suggested search tokens from latest version only on translati…
jonbulz May 4, 2026
2cde810
extract suggested search tokens from latest version only on pages
jonbulz May 4, 2026
a3c7867
move DIRECTORY_SEARCH_FIELDS to search_fields.py
jonbulz May 27, 2026
45a410c
move classmethods to bottom in media_file.py
jonbulz May 27, 2026
f58f583
pass list of values instead of queryset as filtered relation for djan…
jonbulz May 27, 2026
f9ae2b5
Merge remote-tracking branch 'origin/develop' into enhancement/refact…
jonbulz May 27, 2026
323203e
filter search suggestions by language
jonbulz May 28, 2026
4b92431
force test ordering to avoid unique violation bugs after test db flush
jonbulz May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion integreat_cms/cms/models/abstract_content_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
from copy import deepcopy
from html import escape
from typing import TYPE_CHECKING
from typing import ClassVar, TYPE_CHECKING

from django.conf import settings
from django.db import models, transaction
Expand Down Expand Up @@ -43,6 +43,9 @@ class AbstractContentTranslation(AbstractBaseModel):
Data model representing a translation of some kind of content (e.g. pages or events)
"""

#: Field path for language filtering in search suggestions (see SearchSuggestMixin)
language_filter_field: ClassVar[str | None] = "language__slug"

title = TruncatingCharField(max_length=1024, verbose_name=_("title"))
slug = models.SlugField(
max_length=1024,
Expand Down Expand Up @@ -497,6 +500,35 @@ def translation_state(self) -> str:
# If the translation was edited after the source translation, we consider it up to date
return translation_status.UP_TO_DATE

@classmethod
def get_suggest_queryset(
cls,
region: Region | None = None,
archived: bool = False,
language_slug: str | None = None,
) -> QuerySet:
"""
Restrict suggestion sources to the latest *published* translation per
(object, language) so that token suggestions reflect what list views
actually display, instead of mixing in tokens from outdated revisions or
unpublished drafts.
"""
qs = super().get_suggest_queryset(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tightly couples the SearchSuggestMixin to the abstract_content_translation class. Maybe it would be better to inherit from the SearchSuggestMixing directly here in the AbstractContentTranslation class instead of the concrete child classes?

region=region, archived=archived, language_slug=language_slug
)
# Only published translations are shown in list views, so suggestions
# must not leak titles of drafts / pending revisions.
qs = qs.filter(status=status.PUBLIC)
foreign_id = f"{cls.foreign_field()}_id"
latest_ids = (
qs.order_by(foreign_id, "language_id", "-version")
.distinct(foreign_id, "language_id")
.values("id")
)
# Wrap in id__in so the icontains filter applied later in suggest_tokens
# runs against latest rows only — not all versions.
return cls.objects.filter(id__in=latest_ids)

@classmethod
def search(cls, region: Region, language_slug: str, query: str) -> QuerySet:
"""
Expand Down
8 changes: 6 additions & 2 deletions integreat_cms/cms/models/contact/contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
from django.utils.translation import gettext_lazy as _
from linkcheck.models import Link

from ...search.search_fields import CONTACT_SEARCH_FIELDS
from ..abstract_base_model import AbstractBaseModel
from ..events.event_translation import EventTranslation
from ..fields.truncating_char_field import TruncatingCharField
from ..mixins import SearchSuggestMixin
from ..pages.page_translation import PageTranslation
from ..pois.poi import POI
from ..pois.poi_translation import POITranslation
Expand Down Expand Up @@ -48,12 +50,14 @@ def get_primary_contact(self) -> Contact | None:
return self.filter(area_of_responsibility="").first()


class Contact(AbstractBaseModel):
class Contact(AbstractBaseModel, SearchSuggestMixin):
"""
Data model representing a contact
"""

search_fields = ["name", "location__translations__title", "area_of_responsibility"]
search_fields = CONTACT_SEARCH_FIELDS
region_filter_field = "location__region"
archived_filter_field = "archived"

area_of_responsibility = TruncatingCharField(
max_length=200,
Expand Down
8 changes: 7 additions & 1 deletion integreat_cms/cms/models/events/event_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
from ...models import Event, Region

from ...constants import status
from ...search.search_fields import EVENT_SEARCH_FIELDS
from ..abstract_content_translation import AbstractContentTranslation
from ..decorators import modify_fields
from ..mixins import SearchSuggestMixin
from ..utils import format_object_translation


Expand All @@ -33,11 +35,15 @@
title={"verbose_name": _("title of the event")},
content={"verbose_name": _("description")},
)
class EventTranslation(AbstractContentTranslation):
class EventTranslation(AbstractContentTranslation, SearchSuggestMixin):
"""
Data model representing an event translation
"""

search_fields = EVENT_SEARCH_FIELDS
region_filter_field = "event__region"
archived_filter_field = "event__archived"

event = models.ForeignKey(
"cms.Event",
on_delete=models.CASCADE,
Expand Down
25 changes: 24 additions & 1 deletion integreat_cms/cms/models/feedback/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
from polymorphic.models import PolymorphicModel

from ...constants import feedback_ratings
from ...search.search_fields import FEEDBACK_SEARCH_FIELDS
from ...utils.translation_utils import gettext_many_lazy as __
from ..abstract_base_model import AbstractBaseModel
from ..languages.language import Language
from ..mixins import SearchSuggestMixin
from ..regions.region import Region

if TYPE_CHECKING:
Expand Down Expand Up @@ -52,12 +54,33 @@ class CascadeDeletePolymorphicManager(PolymorphicManager):
queryset_class = CascadeDeletePolymorphicQuerySet


class Feedback(PolymorphicModel, AbstractBaseModel):
class Feedback(PolymorphicModel, AbstractBaseModel, SearchSuggestMixin):
"""
Database model representing feedback from app-users.
Do not directly create instances of this base model, but of the submodels (e.g. PageFeedback) instead.
"""

search_fields = FEEDBACK_SEARCH_FIELDS
region_filter_field = "region"
archived_filter_field = "archived"

@classmethod
def get_suggest_queryset(
cls,
region: Region | None = None,
archived: bool = False,
language_slug: str | None = None,
) -> QuerySet:
"""
Mirror :meth:`Feedback.search`: the admin (global) feedback list shows
only technical feedback, whereas a regional list shows only that region's
non-technical feedback. Suggestions must respect the same split.
"""
qs = super().get_suggest_queryset(
region=region, archived=archived, language_slug=language_slug
)
return qs.filter(is_technical=region is None)

objects = CascadeDeletePolymorphicManager()

region = models.ForeignKey(
Expand Down
6 changes: 4 additions & 2 deletions integreat_cms/cms/models/languages/language.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@
from django.utils.translation import gettext_lazy as _

from ...constants import countries, language_color, text_directions
from ...search.search_fields import LANGUAGE_SEARCH_FIELDS
from ...utils.translation_utils import gettext_many_lazy as __
from ..abstract_base_model import AbstractBaseModel
from ..mixins import SearchSuggestMixin
from ..regions.region import Region


class Language(AbstractBaseModel):
class Language(AbstractBaseModel, SearchSuggestMixin):
"""
Data model representing a content language.
"""

search_fields = ["native_name", "english_name"]
search_fields = LANGUAGE_SEARCH_FIELDS

slug = models.SlugField(
max_length=8,
Expand Down
28 changes: 27 additions & 1 deletion integreat_cms/cms/models/media/directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from django.utils.formats import localize
from django.utils.translation import gettext_lazy as _

from ...search.search_fields import DIRECTORY_SEARCH_FIELDS
from ..abstract_base_model import AbstractBaseModel
from ..mixins import SearchSuggestMixin
from ..regions.region import Region

if TYPE_CHECKING:
Expand All @@ -17,12 +19,36 @@
from django.db.models.query import QuerySet


class Directory(AbstractBaseModel):
class Directory(AbstractBaseModel, SearchSuggestMixin):
"""
Model representing a directory containing documents. This is only a virtual directory and does not necessarily
exist on the actual file system. Each directory is tied to a region.
"""

search_fields = DIRECTORY_SEARCH_FIELDS
region_filter_field = "region"
archived_filter_field = None

@classmethod
def get_suggest_queryset(
cls,
region: Region | None = None,
archived: bool = False, # noqa: ARG003
language_slug: str | None = None,
) -> QuerySet[Any]:
"""
Include both regional and global (non-hidden) directories,
matching the behavior of :meth:`Directory.search`.

:param region: The region to filter by (optional)
:param archived: Whether to include archived records (unused for directories)
:param language_slug: Unused; directories are not language-specific
:return: A filtered queryset
"""
return cls.objects.filter(
Q(region=region) | Q(region__isnull=True, is_hidden=False)
)

name = models.CharField(max_length=255, blank=False, verbose_name=_("name"))
region = models.ForeignKey(
Region,
Expand Down
67 changes: 66 additions & 1 deletion integreat_cms/cms/models/media/media_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
from linkcheck.models import Link, Url

from ...constants import allowed_media
from ...search.search_fields import MEDIA_FILE_SEARCH_FIELDS
from ..abstract_base_model import AbstractBaseModel
from ..mixins import SearchSuggestMixin
from ..regions.region import Region
from .directory import Directory

Expand Down Expand Up @@ -131,13 +133,17 @@ def filter_unused(self) -> MediaFileQuerySet:
)


class MediaFile(AbstractBaseModel):
class MediaFile(AbstractBaseModel, SearchSuggestMixin):
"""
The MediaFile model is used to store basic information about files which are uploaded to the CMS. This is only a
virtual document and does not necessarily exist on the actual file system. Each document is tied to a region via its
directory.
"""

search_fields = MEDIA_FILE_SEARCH_FIELDS
region_filter_field = "region"
archived_filter_field = None

file = models.FileField(
upload_to=upload_path,
validators=[file_size_limit],
Expand Down Expand Up @@ -422,6 +428,65 @@ def suggest(cls, **kwargs: Any) -> list[dict[str, Any]]:
)
return results

@classmethod
def get_suggest_queryset(
cls,
region: Region | None = None,
archived: bool = False, # noqa: ARG003
language_slug: str | None = None,
) -> QuerySet[Any]:
"""
Include both regional and global (non-hidden) media files,
matching the behavior of :meth:`MediaFile.search`.

:param region: The region to filter by (optional)
:param archived: Whether to include archived records (unused for media files)
:param language_slug: Unused; media files are not language-specific
:return: A filtered queryset
"""
return cls.objects.filter(
Q(region=region) | Q(region__isnull=True, is_hidden=False)
)

@classmethod
def suggest_tokens(
cls,
query: str,
region: Region | None = None,
archived: bool = False,
language_slug: str | None = None,
**kwargs: Any,
) -> dict[str, list[dict[str, Any]]]:
"""
Generate search suggestions from both media files and directories.

:param query: The search query string
:param region: The region to filter by (optional)
:param archived: Whether to include archived records (default: False)
:param language_slug: Unused; media files are not language-specific
:param kwargs: Additional arguments (unused, for compatibility)
:return: Dict with "suggestions" key containing list of {suggestion, score} dicts
"""
file_result = super().suggest_tokens(
query, region=region, archived=archived, language_slug=language_slug
)
dir_result = Directory.suggest_tokens(
query, region=region, archived=archived, language_slug=language_slug
)

# Merge suggestions, summing scores for duplicates
scores: dict[str, float] = {}
for item in file_result["suggestions"] + dir_result["suggestions"]:
scores[item["suggestion"]] = (
scores.get(item["suggestion"], 0) + item["score"]
)

return {
"suggestions": [
{"suggestion": token, "score": score} for token, score in scores.items()
]
}

def __str__(self) -> str:
"""
This overwrites the default Django :meth:`~django.db.models.Model.__str__` method which would return ``MediaFile object (id)``.
Expand Down
Loading
Loading