Repository: City-of-Helsinki/linkedevents
Pull request #1048: LINK-2249: New full text search
Author: login: mhieta
Created: 2025-03-21T13:47:04Z | Merged: 2025-04-02T12:18:46Z

First version of the new full text search.

Features/improvements:
- Adds a new `XEventFullText`-model with GIN-indexes to hold up new search documents in one place
- Support for generating fi/sv/en - search documents
- Utilizes Voikko for generating the search documents, details:
  - Use finnish morpho-dict for better vocabulary
  - Auto-split compound words into word bases and store them separately into search documents
- Update other packages

Usage example: `/v1/event/?x_full_text=lentokone&x_full_text_language=fi&include=location,keywords`

Also utilize GDAL/GEOS env-variables in settings for improved macOS-support in general without Docker.

Refs: LINK-2267, LINK-2272, LINK-2270


Diff:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index da52bf169..54828680f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,4 +16,5 @@ jobs:
       postgres-major-version: 14
       use-postgis: true
       extra-commands: |
+        sudo apt-get install voikko-fi libvoikko-dev
         python manage.py spectacular --lang en --validate --fail-on-warn --api-version v1
diff --git a/.gitignore b/.gitignore
index 9ff17ef9e..64c9a3a48 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ static/CACHE
 media/
 .vagrant/
 .env
+config_dev.env
 .idea
 .django_secret
 *.iml
diff --git a/README.md b/README.md
index 59de3a7e0..7bf2ab8d4 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,10 @@ Install required Python packages into the virtualenv
 cd $INSTALL_BASE/linkedevents
 pip install -r requirements.txt
 ```
+
+Install Voikko by following [these](https://voikko.puimula.org/python.html) instructions.
+Recommendation is to use "morpho" Finnish language dictionary.
+
 Create the database, like so: (we have only tested on PostgreSQL)
 ```bash
 cd $INSTALL_BASE/linkedevents
@@ -164,11 +168,11 @@ After this, everything but search endpoint (/search) is working. See [search](#s
     ```
   * See: https://github.com/django/django/pull/15214
 * Fix:
-  * Edit `linkedevents/settings.py`, add to end:
+  * Edit `.zshrc` / `.bashrc` / `.bash_profile` and add the following lines:
     ```text
     # macOS:
-    GDAL_LIBRARY_PATH="/opt/homebrew/opt/gdal/lib/libgdal.dylib"
-    GEOS_LIBRARY_PATH="/opt/homebrew/opt/geos/lib/libgeos_c.dylib"
+    export GDAL_LIBRARY_PATH=/opt/homebrew/lib/libgdal.dylib
+    export GEOS_LIBRARY_PATH=/opt/homebrew/lib/libgeos_c.dylib
     ```
 
 ### Troubleshooting
diff --git a/docker/django/Dockerfile b/docker/django/Dockerfile
index cb90008ec..1730d462b 100644
--- a/docker/django/Dockerfile
+++ b/docker/django/Dockerfile
@@ -16,6 +16,13 @@ USER root
 ## https://github.com/docker/compose/issues/3270#issuecomment-363478501
 RUN mkdir -p /var/media && chmod g=u -R /var/media
 
+# Setup Voikko finnish morpho dictionary
+RUN mkdir -p /etc/voikko \
+    && chmod g=u -R /etc/voikko \
+    && cd /etc/voikko \
+    && wget https://www.puimula.org/htp/testing/voikko-snapshot-v5/dict-morpho.zip \
+    && unzip dict-morpho.zip
+
 COPY requirements.txt .
 COPY requirements-prod.txt .
 
diff --git a/events/filters.py b/events/filters.py
index 0b7a5e615..b2d699422 100644
--- a/events/filters.py
+++ b/events/filters.py
@@ -408,9 +408,11 @@ def filter_x_full_text(self, qs, name, value: Optional[str]):
             "en": "english",
             "sv": "swedish",
         }
+        # replace all most common delimiters with space
+        search_value = re.sub(r"[.,:;]", " ", value)
         search_vector_name = f"full_text__search_vector_{language}"
         search_query = SearchQuery(
-            value, search_type="websearch", config=config_map[language]
+            search_value, search_type="websearch", config=config_map[language]
         )
         search_rank = SearchRank(F(search_vector_name), search_query)
 
diff --git a/events/management/commands/refresh_full_text.py b/events/management/commands/refresh_full_text.py
deleted file mode 100644
index d5069169c..000000000
--- a/events/management/commands/refresh_full_text.py
+++ /dev/null
@@ -1,139 +0,0 @@
-import os
-import re
-
-from django.conf import settings
-from django.core.management import BaseCommand
-from django.db import connection
-from django.db.models import Exists, OuterRef, Q
-
-from events.models import Event, EventFullText, Place
-
-
-def sql_filename(filename):
-    return os.path.join(os.path.dirname(__file__), f"sql/refresh_full_text/{filename}")
-
-
-def field_regex(field):
-    return re.compile(
-        rf"(?P<head>setweight\([^)]*?{field}.*?')(?P<weight>[ABCD])(?P<tail>')",
-        re.DOTALL,
-    )
-
-
-class Command(BaseCommand):
-    help = "Refresh the full text materialized view. Use switches to adjust behaviour"
-
-    def add_arguments(self, parser):
-        parser.add_argument(
-            "--create",
-            action="store_true",
-            help="(Re)create the materialized view instead of refresh",
-        )
-
-        parser.add_argument(
-            "--drop",
-            action="store_true",
-            help="Drop the materialized view instead of refresh",
-        )
-
-        parser.add_argument(
-            "--create-no-transaction",
-            action="store_true",
-            help="Same as --create but without a transaction. Primary use case is for testing.",  # noqa: E501
-        )
-
-        parser.add_argument("--force", action="store_true", help="Force refresh")
-
-    def apply_overrides(self, sql):
-        for field, weight_override in settings.FULL_TEXT_WEIGHT_OVERRIDES.items():
-            replaced_sql = re.sub(
-                field_regex(field), rf"\g<head>{weight_override}\g<tail>", sql
-            )
-            if replaced_sql == sql:
-                self.stdout.write(
-                    self.style.WARNING(f"Failed to apply weight override for {field}!")
-                )
-            else:
-                self.stdout.write(
-                    f"Applied weight override for field {field} to {weight_override}"
-                )
-            sql = replaced_sql
-        return sql
-
-    def handle(self, *args, **options):
-        if options["drop"]:
-            # Print stdout success info
-            self.stdout.write("Dropping events_eventfulltext materialized view")
-            with connection.cursor() as cursor:
-                cursor.execute("DROP MATERIALIZED VIEW IF EXISTS events_eventfulltext")
-
-        elif options["create"] or options["create_no_transaction"]:
-            self.stdout.write(
-                "Creating events_eventfulltext materialized view (this may take some seconds)"  # noqa: E501
-            )
-
-            with open(sql_filename("create_materialized_view.sql"), "r") as file:
-                sql = file.read()
-
-            sql = self.apply_overrides(sql)
-            if not options["create_no_transaction"]:
-                sql = f"BEGIN;\n {sql} \nCOMMIT;\n"
-
-            with connection.cursor() as cursor:
-                cursor.execute(sql)
-
-        else:
-            if not options["force"]:
-                self.stdout.write("Checking if refresh is needed...")
-
-                def check_refresh_needed():
-                    # Check for new/modified events
-                    yield Event.objects.filter(
-                        ~Exists(
-                            EventFullText.objects.filter(
-                                event_id=OuterRef("id"),
-                                event_last_modified_time=OuterRef("last_modified_time"),
-                            )
-                        )
-                    ).count()
-
-                    # Check for new/modified places
-                    yield Event.objects.filter(
-                        ~Exists(
-                            EventFullText.objects.filter(
-                                Q(place__isnull=True, event__location__isnull=True)
-                                | Q(
-                                    place_id=OuterRef("location_id"),
-                                    place_last_modified_time=OuterRef(
-                                        "location__last_modified_time"
-                                    ),
-                                ),
-                                event_id=OuterRef("id"),
-                            )
-                        )
-                    ).count()
-
-                    # Check for deleted events
-                    yield EventFullText.objects.filter(
-                        ~Exists(Event.objects.filter(id=OuterRef("event_id"))),
-                        event__isnull=False,
-                    ).count()
-
-                    # Check for deleted places
-                    yield EventFullText.objects.filter(
-                        ~Exists(Place.objects.filter(id=OuterRef("place_id"))),
-                        place__isnull=False,
-                    ).count()
-
-                if not any(check_refresh_needed()):
-                    self.stdout.write(self.style.SUCCESS("Refresh not needed!"))
-                    return
-
-            self.stdout.write(
-                "Refreshing events_eventfulltext materialized view (this may take some seconds)"  # noqa: E501
-            )
-            with connection.cursor() as cursor:
-                cursor.execute(
-                    "REFRESH MATERIALIZED VIEW CONCURRENTLY events_eventfulltext"
-                )
-        self.stdout.write(self.style.SUCCESS("Done!"))
diff --git a/events/management/commands/sql/refresh_full_text/create_materialized_view.sql b/events/management/commands/sql/refresh_full_text/create_materialized_view.sql
deleted file mode 100644
index 7a632f7d3..000000000
--- a/events/management/commands/sql/refresh_full_text/create_materialized_view.sql
+++ /dev/null
@@ -1,41 +0,0 @@
-DROP INDEX IF EXISTS events_eventfulltext_id_index;
-DROP MATERIALIZED VIEW IF EXISTS events_eventfulltext;
-
-CREATE MATERIALIZED VIEW events_eventfulltext AS
-SELECT event.id                                                                     AS event_id,
-       place.id                                                                     AS place_id,
-       event.last_modified_time                                                     AS event_last_modified_time,
-       place.last_modified_time                                                     AS place_last_modified_time,
-
-       -- The weights can be adjusted using an environment variable, see refresh_full_text command
-       setweight(to_tsvector('finnish', coalesce(event.name_fi, '')), 'A') ||
-       setweight(to_tsvector('finnish', coalesce(event.short_description_fi, '')), 'C') ||
-       setweight(to_tsvector('finnish', coalesce(event.description_fi, '')), 'D') ||
-       setweight(to_tsvector('finnish', coalesce(place.name_fi, '')), 'A') ||
-       setweight(to_tsvector('finnish', coalesce(event_keywords.name_fi, '')), 'B') as search_vector_fi,
-
-       setweight(to_tsvector('english', coalesce(event.name_en, '')), 'A') ||
-       setweight(to_tsvector('english', coalesce(event.short_description_en, '')), 'C') ||
-       setweight(to_tsvector('english', coalesce(event.description_en, '')), 'D') ||
-       setweight(to_tsvector('english', coalesce(place.name_en, '')), 'A') ||
-       setweight(to_tsvector('english', coalesce(event_keywords.name_en, '')), 'B') as search_vector_en,
-
-       setweight(to_tsvector('swedish', coalesce(event.name_sv, '')), 'A') ||
-       setweight(to_tsvector('swedish', coalesce(event.short_description_sv, '')), 'C') ||
-       setweight(to_tsvector('swedish', coalesce(event.description_sv, '')), 'D') ||
-       setweight(to_tsvector('swedish', coalesce(place.name_sv, '')), 'A') ||
-       setweight(to_tsvector('swedish', coalesce(event_keywords.name_sv, '')), 'B') as search_vector_sv
-
-FROM events_event event
-         LEFT OUTER JOIN events_place place ON event.location_id = place.id
-         -- Join the keywords, aggregated as a string
-         LEFT OUTER JOIN (SELECT event_id,
-                                 string_agg(name_fi, ' ') AS name_fi,
-                                 string_agg(name_en, ' ') AS name_en,
-                                 string_agg(name_sv, ' ') AS name_sv
-                          FROM events_event_keywords
-                                   LEFT OUTER JOIN events_keyword ON events_event_keywords.keyword_id = events_keyword.id
-                          GROUP BY 1) AS event_keywords ON event_keywords.event_id = event.id;
-
--- Required when refreshing the materialized view concurrently
-CREATE UNIQUE INDEX events_eventfulltext_id_index ON events_eventfulltext (event_id);
diff --git a/events/management/commands/update_full_text_search_vectors.py b/events/management/commands/update_full_text_search_vectors.py
new file mode 100644
index 000000000..f92485491
--- /dev/null
+++ b/events/management/commands/update_full_text_search_vectors.py
@@ -0,0 +1,103 @@
+import logging
+
+from django.contrib.postgres.search import SearchVector
+from django.core.management.base import BaseCommand
+from django.db.models import OuterRef, Subquery
+from django.utils import timezone
+
+from events.models import Event, EventSearchIndex
+from events.utils import get_field_attr, split_word_bases
+
+logger = logging.getLogger(__name__)
+
+
+def update_full_text_search_objects(lang: str = "fi") -> int:
+    """
+    Updates the full text search objects for the events.
+    This function iterates through all events, extracts words from the specified
+    columns, and updates the EventSearchIndex model with the search vectors.
+    :param lang: The language code for the search objects (default is "fi").
+    :return: The number of populated search objects.
+    """
+    num_populated = 0
+    qs = Event.objects.all().order_by("pk")
+    for event in qs.iterator(chunk_size=10000):
+        words = set()
+        for column in Event.get_words_columns(lang):
+            row_content = get_field_attr(event, column)
+            if row_content:
+                split_word_bases(row_content, words, lang)
+
+        for keyword in event.keywords.values_list("name_%s" % lang, flat=True):
+            split_word_bases(keyword, words, lang)
+
+        logger.debug(f"Updating search vectors for {event.id}, words: {words}")
+
+        # create or update EventSearchIndex object
+        EventSearchIndex.objects.update_or_create(
+            event=event,
+            defaults={
+                "place": event.location if event else None,
+                "event_last_modified_time": event.last_modified_time
+                if event
+                else timezone.now(),
+                "place_last_modified_time": event.location.last_modified_time
+                if event and event.location
+                else timezone.now(),
+                "words_%s" % lang: list(words),
+            },
+        )
+        num_populated += 1
+    return num_populated
+
+
+def update_full_text_search_vectors():
+    """
+    Updates the search vectors for the full text search objects.
+    """
+    eqs = Event.objects.filter(full_text=OuterRef("pk"))
+    EventSearchIndex.objects.all().annotate(
+        event_name_fi=Subquery(eqs.values("name_fi")[:1]),
+        event_description_fi=Subquery(eqs.values("description_fi")[:1]),
+        event_short_description_fi=Subquery(eqs.values("short_description_fi")[:1]),
+        place_name_fi=Subquery(eqs.values("location__name_fi")[:1]),
+        event_name_sv=Subquery(eqs.values("name_sv")[:1]),
+        event_description_sv=Subquery(eqs.values("description_sv")[:1]),
+        event_short_description_sv=Subquery(eqs.values("short_description_sv")[:1]),
+        place_name_sv=Subquery(eqs.values("location__name_sv")[:1]),
+        event_name_en=Subquery(eqs.values("name_en")[:1]),
+        event_description_en=Subquery(eqs.values("description_en")[:1]),
+        event_short_description_en=Subquery(eqs.values("short_description_en")[:1]),
+        place_name_en=Subquery(eqs.values("location__name_en")[:1]),
+        keywords_fi=Subquery(eqs.values("keywords__name_fi")[:1]),
+        keywords_sv=Subquery(eqs.values("keywords__name_sv")[:1]),
+        keywords_en=Subquery(eqs.values("keywords__name_en")[:1]),
+    ).update(
+        search_vector_fi=SearchVector("event_name_fi", config="finnish", weight="A")
+        + SearchVector("place_name_fi", config="finnish", weight="A")
+        + SearchVector("words_fi", config="finnish", weight="A")
+        + SearchVector("keywords_fi", config="finnish", weight="B")
+        + SearchVector("event_short_description_fi", config="finnish", weight="C")
+        + SearchVector("event_description_fi", config="finnish", weight="D"),
+        search_vector_sv=SearchVector("event_name_sv", config="swedish", weight="A")
+        + SearchVector("place_name_sv", config="swedish", weight="A")
+        + SearchVector("words_sv", config="swedish", weight="A")
+        + SearchVector("keywords_sv", config="swedish", weight="B")
+        + SearchVector("event_short_description_sv", config="swedish", weight="C")
+        + SearchVector("event_description_sv", config="swedish", weight="D"),
+        search_vector_en=SearchVector("event_name_en", config="english", weight="A")
+        + SearchVector("place_name_en", config="english", weight="A")
+        + SearchVector("words_en", config="english", weight="A")
+        + SearchVector("keywords_en", config="english", weight="B")
+        + SearchVector("event_short_description_en", config="english", weight="C")
+        + SearchVector("event_description_en", config="english", weight="D"),
+    )
+
+
+class Command(BaseCommand):
+    def handle(self, *args, **options):
+        for lang in ["fi", "sv", "en"]:
+            logger.info(f"Generating search vectors for language: {lang}.")
+            num_populated = update_full_text_search_objects(lang)
+            logger.info(f"Search vectors updated for {num_populated} Events")
+        update_full_text_search_vectors()
diff --git a/events/migrations/0101_eventsearchindex.py b/events/migrations/0101_eventsearchindex.py
new file mode 100644
index 000000000..729ed2c34
--- /dev/null
+++ b/events/migrations/0101_eventsearchindex.py
@@ -0,0 +1,102 @@
+# Generated by Django 4.2.20 on 2025-04-02 09:13
+
+import django.contrib.postgres.fields
+import django.contrib.postgres.indexes
+import django.contrib.postgres.search
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("events", "0100_alter_event_maximum_attendee_capacity_and_more"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="EventSearchIndex",
+            fields=[
+                (
+                    "event",
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.DO_NOTHING,
+                        primary_key=True,
+                        related_name="full_text",
+                        serialize=False,
+                        to="events.event",
+                    ),
+                ),
+                ("event_last_modified_time", models.DateTimeField()),
+                ("place_last_modified_time", models.DateTimeField()),
+                (
+                    "words_fi",
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.CharField(max_length=64),
+                        default=list,
+                        size=None,
+                    ),
+                ),
+                (
+                    "words_en",
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.CharField(max_length=64),
+                        default=list,
+                        size=None,
+                    ),
+                ),
+                (
+                    "words_sv",
+                    django.contrib.postgres.fields.ArrayField(
+                        base_field=models.CharField(max_length=64),
+                        default=list,
+                        size=None,
+                    ),
+                ),
+                (
+                    "search_vector_fi",
+                    django.contrib.postgres.search.SearchVectorField(),
+                ),
+                (
+                    "search_vector_en",
+                    django.contrib.postgres.search.SearchVectorField(),
+                ),
+                (
+                    "search_vector_sv",
+                    django.contrib.postgres.search.SearchVectorField(),
+                ),
+                (
+                    "place",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.DO_NOTHING,
+                        related_name="full_text_place",
+                        to="events.place",
+                    ),
+                ),
+            ],
+            options={
+                "ordering": ["-pk"],
+            },
+        ),
+        migrations.DeleteModel(
+            name="EventFullText",
+        ),
+        migrations.AddIndex(
+            model_name="eventsearchindex",
+            index=django.contrib.postgres.indexes.GinIndex(
+                fields=["search_vector_fi"], name="events_even_search__95ba89_gin"
+            ),
+        ),
+        migrations.AddIndex(
+            model_name="eventsearchindex",
+            index=django.contrib.postgres.indexes.GinIndex(
+                fields=["search_vector_en"], name="events_even_search__9d9fe8_gin"
+            ),
+        ),
+        migrations.AddIndex(
+            model_name="eventsearchindex",
+            index=django.contrib.postgres.indexes.GinIndex(
+                fields=["search_vector_sv"], name="events_even_search__88c403_gin"
+            ),
+        ),
+    ]
diff --git a/events/models.py b/events/models.py
index a22ba52a9..4b1c4bb23 100644
--- a/events/models.py
+++ b/events/models.py
@@ -25,8 +25,8 @@
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.gis.db import models
-from django.contrib.postgres.fields import HStoreField
-from django.contrib.postgres.indexes import Index
+from django.contrib.postgres.fields import ArrayField, HStoreField
+from django.contrib.postgres.indexes import GinIndex, Index
 from django.contrib.postgres.search import SearchVectorField
 from django.core.mail import send_mail
 from django.db import transaction
@@ -1389,13 +1389,28 @@ def get_start_and_end_time_display(self, lang="fi", date_only=False) -> str:
 
         return ""
 
+    @classmethod
+    def get_words_columns(cls, lang):
+        """
+        Defines the columns that will be used when populating
+        words to language specific words column. The content
+        will be tokenized to lexems(to_tsvector) and added to
+        the search_column.
+        """
+        return [
+            "name_%s" % lang,
+            "location__name_%s" % lang,
+            "short_description_%s" % lang,
+            "description_%s" % lang,
+        ]
+
 
 reversion.register(Event)
 
 
-class EventFullText(models.Model):
+class EventSearchIndex(models.Model):
     """
-    A representation of the materialized view used in full-text search.
+    Event full-text search index.
     """
 
     event = models.OneToOneField(
@@ -1405,18 +1420,27 @@ class EventFullText(models.Model):
         primary_key=True,
     )
     place = models.ForeignKey(
-        Event, related_name="full_text_place", on_delete=models.DO_NOTHING
+        Place, related_name="full_text_place", on_delete=models.DO_NOTHING, null=True
     )
 
     event_last_modified_time = models.DateTimeField()
     place_last_modified_time = models.DateTimeField()
 
+    words_fi = ArrayField(models.CharField(max_length=64), default=list)
+    words_en = ArrayField(models.CharField(max_length=64), default=list)
+    words_sv = ArrayField(models.CharField(max_length=64), default=list)
+
     search_vector_fi = SearchVectorField()
     search_vector_en = SearchVectorField()
     search_vector_sv = SearchVectorField()
 
     class Meta:
-        managed = False
+        ordering = ["-pk"]
+        indexes = (
+            GinIndex(fields=["search_vector_fi"]),
+            GinIndex(fields=["search_vector_en"]),
+            GinIndex(fields=["search_vector_sv"]),
+        )
 
 
 @receiver(m2m_changed, sender=Event.keywords.through)
diff --git a/events/tests/conftest.py b/events/tests/conftest.py
index 453b2a020..be7b4f00f 100644
--- a/events/tests/conftest.py
+++ b/events/tests/conftest.py
@@ -234,6 +234,8 @@ def place(data_source, organization, administrative_division):
         publisher=organization,
         position=Point(50, 50),
         name_fi="Paikka 1",
+        name_sv="Plats 1",
+        name_en="Place 1",
     )
 
 
@@ -276,6 +278,9 @@ def event(data_source, organization, place, user):
         short_description="short desc",
         description="desc",
         name="tapahtuma",
+        name_fi="tapahtuma",
+        name_sv="evenemang",
+        name_en="event",
     )
 
 
diff --git a/events/tests/management/test_refresh_full_text.py b/events/tests/management/test_refresh_full_text.py
deleted file mode 100644
index ec20d6a4a..000000000
--- a/events/tests/management/test_refresh_full_text.py
+++ /dev/null
@@ -1,138 +0,0 @@
-from io import StringIO
-
-import pytest
-from django.core.management import call_command
-
-from events.models import EventFullText
-from events.tests.factories import EventFactory
-
-create_no_refresh = "\n".join(
-    [
-        "Creating events_eventfulltext materialized view (this may take some seconds)",
-        "Done!",
-        "Checking if refresh is needed...",
-        "Refresh not needed!",
-        "",
-    ]
-)
-
-create_refresh = "\n".join(
-    [
-        "Creating events_eventfulltext materialized view (this may take some seconds)",
-        "Done!",
-        "Checking if refresh is needed...",
-        "Refreshing events_eventfulltext materialized view (this may take some seconds)",
-        "Done!",
-        "",
-    ]
-)
-
-
-@pytest.mark.django_db(transaction=True)
-def test_refresh_no_changes(event, place):
-    out = StringIO()
-    call_command("refresh_full_text", "--create", stdout=out)
-    call_command("refresh_full_text", stdout=out)
-
-    assert out.getvalue() == create_no_refresh
-    assert EventFullText.objects.all().count() == 1
-
-
-@pytest.mark.django_db()
-def test_create_with_valid_overrides(settings):
-    settings.FULL_TEXT_WEIGHT_OVERRIDES = {
-        "event.name": "D",
-        "event.short_description": "D",
-        "event.description": "A",
-        "place.name": "D",
-        "event_keywords.name": "D",
-    }
-    out = StringIO()
-    call_command("refresh_full_text", "--create-no-transaction", stdout=out)
-
-    assert "Applied weight override for field event.name to D" in out.getvalue()
-    assert (
-        "Applied weight override for field event.short_description to D"
-        in out.getvalue()
-    )
-    assert "Applied weight override for field event.description to A" in out.getvalue()
-    assert "Applied weight override for field place.name to D" in out.getvalue()
-    assert (
-        "Applied weight override for field event_keywords.name to D" in out.getvalue()
-    )
-    assert "Failed to apply weight override" not in out.getvalue()
-
-
-@pytest.mark.django_db()
-def test_create_with_invalid_override(settings):
-    settings.FULL_TEXT_WEIGHT_OVERRIDES = {
-        "event.foobar": "D",
-    }
-    out = StringIO()
-    call_command("refresh_full_text", "--create-no-transaction", stdout=out)
-
-    assert "Failed to apply weight override for event.foobar" in out.getvalue()
-    assert "Applied weight override for" not in out.getvalue()
-
-
-@pytest.mark.django_db()
-def test_refresh_event_modified(event, place):
-    out = StringIO()
-    call_command("refresh_full_text", "--create-no-transaction", stdout=out)
-    event.name_fi = "Oho"
-    event.save()
-    call_command("refresh_full_text", stdout=out)
-
-    assert out.getvalue() == create_refresh
-    assert EventFullText.objects.all().count() == 1
-
-
-@pytest.mark.django_db()
-def test_refresh_event_inserted(event, place):
-    out = StringIO()
-    call_command("refresh_full_text", "--create-no-transaction", stdout=out)
-    EventFactory()
-    call_command("refresh_full_text", stdout=out)
-
-    assert out.getvalue() == create_refresh
-    assert EventFullText.objects.all().count() == 2
-
-
-@pytest.mark.django_db()
-def test_refresh_event_deleted(event, place):
-    out = StringIO()
-    call_command("refresh_full_text", "--create-no-transaction", stdout=out)
-    event.delete()
-    call_command("refresh_full_text", stdout=out)
-
-    assert out.getvalue() == create_refresh
-    assert EventFullText.objects.all().count() == 0
-
-
-@pytest.mark.django_db()
-def test_refresh_event_added_and_deleted(event, place):
-    out = StringIO()
-    call_command("refresh_full_text", "--create-no-transaction", stdout=out)
-    EventFactory()
-    event.delete()
-    call_command("refresh_full_text", stdout=out)
-
-    assert out.getvalue() == create_refresh
-    assert EventFullText.objects.all().count() == 1
-
-
-@pytest.mark.django_db()
-def test_refresh_place_modified(event, place):
-    out = StringIO()
-    call_command("refresh_full_text", "--create-no-transaction", stdout=out)
-    place.name_fi = "Some other place"
-    place.save()
-    call_command("refresh_full_text", stdout=out)
-
-    assert out.getvalue() == create_refresh
-    assert EventFullText.objects.all().count() == 1
-
-
-# Not testing:
-# 1) Place deletion: Protected by event.location relation
-# 2) Place addition: Not relevant
diff --git a/events/tests/management/test_update_full_text_search_vectors.py b/events/tests/management/test_update_full_text_search_vectors.py
new file mode 100644
index 000000000..69645df1f
--- /dev/null
+++ b/events/tests/management/test_update_full_text_search_vectors.py
@@ -0,0 +1,32 @@
+import pytest
+from django.core.management import call_command
+
+from events.models import EventSearchIndex
+
+
+@pytest.mark.django_db(transaction=True)
+def test_update_search_vectors(event, place, keyword):
+    keyword.name_fi = "tunnettu_avainsana"
+    keyword.name_sv = "känd_nyckelord"
+    keyword.name_en = "known_keyword"
+    keyword.save()
+    event.keywords.add(keyword)
+    call_command("update_full_text_search_vectors")
+    assert EventSearchIndex.objects.all().count() == 1
+    event_full_text = EventSearchIndex.objects.first()
+    assert event_full_text.event == event
+    assert event_full_text.place == place
+    assert event_full_text.event_last_modified_time == event.last_modified_time
+    assert event_full_text.place_last_modified_time == place.last_modified_time
+    assert event_full_text.search_vector_fi is not None
+    assert event_full_text.search_vector_sv is not None
+    assert event_full_text.search_vector_en is not None
+    assert place.name_fi[:4].lower() in str(event_full_text.search_vector_fi)
+    assert place.name_sv[:4].lower() in str(event_full_text.search_vector_sv)
+    assert place.name_en[:4].lower() in str(event_full_text.search_vector_en)
+    assert event.name_fi[:4].lower() in str(event_full_text.search_vector_fi)
+    assert event.name_sv[:4].lower() in str(event_full_text.search_vector_sv)
+    assert event.name_en[:4].lower() in str(event_full_text.search_vector_en)
+    assert keyword.name_fi[:4].lower() in str(event_full_text.search_vector_fi)
+    assert keyword.name_sv[:4].lower() in str(event_full_text.search_vector_sv)
+    assert keyword.name_en[:4].lower() in str(event_full_text.search_vector_en)
diff --git a/events/tests/test_event_filters.py b/events/tests/test_event_filters.py
index 6c6b493ba..2d6ae6524 100644
--- a/events/tests/test_event_filters.py
+++ b/events/tests/test_event_filters.py
@@ -106,7 +106,7 @@ def test_get_event_list_full_text(language):
     event_en.keywords.set([kw_en])
     event_sv.keywords.set([kw_sv])
 
-    call_command("refresh_full_text", "--create-no-transaction")
+    call_command("update_full_text_search_vectors")
 
     request = Mock()
     request.query_params = {"x_full_text_language": language}
diff --git a/events/tests/test_event_get.py b/events/tests/test_event_get.py
index 253133f98..8b6a353e0 100644
--- a/events/tests/test_event_get.py
+++ b/events/tests/test_event_get.py
@@ -637,7 +637,7 @@ def test_language_filter(api_client, event, event2, event3):
     get_list_and_assert_events("in_language=sv", [event2])
 
     # English should have one event (matches in_language)
-    get_list_and_assert_events("language=en", [event2])
+    get_list_and_assert_events("language=en", [event, event2])
     get_list_and_assert_events("in_language=en", [event2])
 
     # Russian should have one event (matches name_ru)
diff --git a/events/tests/test_utils.py b/events/tests/test_utils.py
index 0f7f2d29a..aeede2487 100644
--- a/events/tests/test_utils.py
+++ b/events/tests/test_utils.py
@@ -13,6 +13,7 @@
     clean_text_fields,
     parse_end_time,
     parse_time,
+    split_word_bases,
     start_of_day,
     start_of_next_day,
     start_of_previous_day,
@@ -172,6 +173,22 @@ def test_inconsistent_tz_default(api_client, minimal_event_dict, user, settings)
     ), list_with_offset_create_dt.json()
 
 
+@pytest.mark.parametrize(
+    "word, expected_result",
+    [
+        ("lentokone", {"lento", "kone"}),
+        ("päiväkoti", {"päivä", "koti"}),
+        ("lentokoneen", {"lento", "koneen"}),
+        ("lentokoneita", {"lento", "koneita"}),
+        ("saippuakivimittakaava", {"saippua", "kivi", "mitta", "kaava"}),
+        ("lentokone123", set()),
+    ],
+)
+def test_split_word_bases(word, expected_result):
+    result = split_word_bases(word, set())
+    assert result == expected_result
+
+
 @pytest.mark.parametrize(
     "data, allowed_html_fields, strip, expected_result",
     [
diff --git a/events/tests/utils.py b/events/tests/utils.py
index e07b45ce0..5d3d26c9d 100644
--- a/events/tests/utils.py
+++ b/events/tests/utils.py
@@ -17,7 +17,7 @@ def assert_event_data_is_equal(d1, d2, version="v1"):
         "data_source",
         "publisher",
         "location",
-        "name",
+        "name_en",
         "event_status",
         "sub_events",
         "custom_data",
diff --git a/events/utils.py b/events/utils.py
index ae18b5df8..b3f99adcb 100644
--- a/events/utils.py
+++ b/events/utils.py
@@ -1,10 +1,12 @@
 import collections
+import logging
 import re
 import warnings
 from datetime import datetime, timedelta
 from typing import Iterable, Optional
 
 import bleach
+import libvoikko
 import pytz
 from dateutil.parser import parse as dateutil_parse
 from django.conf import settings
@@ -19,6 +21,12 @@
 from events.sql import count_events_for_keywords, count_events_for_places
 from helevents.models import User
 
+logger = logging.getLogger(__name__)
+
+# setup libvoikko
+voikko = libvoikko.Voikko("fi", "/etc/voikko")
+voikko.setNoUglyHyphenation(True)
+
 
 def convert_to_camelcase(s):
     return "".join(word.title() if i else word for i, word in enumerate(s.split("_")))
@@ -30,6 +38,72 @@ def convert_from_camelcase(s):
     )
 
 
+def get_field_attr(obj: object, field: str) -> object:
+    """
+    Get attr recursively by following foreign key relations.
+    :param obj: object to get the attribute from
+    :param field: field name or foreign key relation
+    :return: attribute value
+    :rtype: object
+    """
+    fields = field.split("__")
+    if len(fields) == 1:
+        return getattr(obj, fields[0], None)
+    else:
+        first_field = fields[0]
+        remaining_fields = "__".join(fields[1:])
+        return get_field_attr(getattr(obj, first_field), remaining_fields)
+
+
+def is_compound_word(word: str) -> bool:
+    """
+    Check if the word is a compound word.
+    :param word: the word to check
+    :return: True if the word is a compound word, False otherwise
+    """
+    result = voikko.analyze(word)
+    if len(result) == 0:
+        return False
+    return True if result[0]["WORDBASES"].count("+") > 1 else False
+
+
+def get_word_bases(word: str) -> list:
+    """
+    Returns a list of word bases of the word, if it is a compound word.
+    :param word: the word to split
+    :return: a list of word bases
+    """
+    word = word.strip()
+    if is_compound_word(word):
+        # By Setting the setMinHyphenatedWordLength to word_length,
+        # voikko returns the words that are in the compound word
+        voikko.setMinHyphenatedWordLength(len(word))
+        word_bases = voikko.hyphenate(word)
+        return word_bases.split("-")
+    else:
+        return [word]
+
+
+def split_word_bases(word, words, lang: str = "fi") -> set:
+    """
+    Splits the word into its bases and adds them to the set of words.
+    :param word: the word to split
+    :param words: the set of words to add the bases to
+    :param lang: the language of the word (default: "fi")
+    :return: the set of words
+    """
+    if lang == "fi":
+        w_array = word or []
+        if isinstance(word, str):
+            w_array = word.split()
+        for word in w_array:
+            word_bases = get_word_bases(word)
+            if len(word_bases) > 1:
+                for base in word_bases:
+                    words.add(base)
+    return words
+
+
 def get_value_from_tuple_list(list_of_tuples, search_key, value_index):
     """
     Find "value" from list of tuples by using the other value in tuple as a
diff --git a/linkedevents/settings.py b/linkedevents/settings.py
index dd302f4fe..a5f66f1e9 100644
--- a/linkedevents/settings.py
+++ b/linkedevents/settings.py
@@ -22,6 +22,9 @@
 
 from linkedevents import __version__
 
+GDAL_LIBRARY_PATH = os.environ.get("GDAL_LIBRARY_PATH")
+GEOS_LIBRARY_PATH = os.environ.get("GEOS_LIBRARY_PATH")
+
 CONFIG_FILE_NAME = "config_dev.env"
 
 DEBUG_TOOLBAR_AVAILABLE = importlib.util.find_spec("debug_toolbar") is not None
diff --git a/registrations/tests/test_registration_get.py b/registrations/tests/test_registration_get.py
index 31b6c3448..03c0fd5af 100644
--- a/registrations/tests/test_registration_get.py
+++ b/registrations/tests/test_registration_get.py
@@ -322,7 +322,7 @@ def test_get_registration_with_event_included(user_api_client, event, registrati
     )
     response_event = response.data["event"]
     assert response_event["id"] == event.id
-    assert list(response_event["name"].values())[0] == event.name
+    assert list(response_event["name"].values())[0] == event.name_fi
     assert list(response_event["description"].values())[0] == event.description
     assert response_event["publisher"] == event.publisher.id
 
@@ -339,7 +339,7 @@ def test_get_registration_with_event_and_location_included(
     )
     response_location = response.data["event"]["location"]
     assert response_location["id"] == place.id
-    assert list(response_location["name"].values())[0] == place.name
+    assert list(response_location["name"].values())[0] == place.name_fi
 
 
 @pytest.mark.django_db
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 81d6dfb62..80970c70d 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -6,49 +6,43 @@
 #
 asgiref==3.8.1
     # via
-    #   -c requirements.txt
     #   django
-build==1.2.2
+build==1.2.2.post1
     # via pip-tools
-certifi==2024.8.30
+certifi==2025.1.31
     # via
-    #   -c requirements.txt
     #   requests
-charset-normalizer==3.3.2
+charset-normalizer==3.4.1
     # via
-    #   -c requirements.txt
     #   requests
-click==8.1.7
+click==8.1.8
     # via pip-tools
-coverage[toml]==7.6.1
+coverage[toml]==7.7.0
     # via pytest-cov
 django==4.2.20
     # via
-    #   -c requirements.txt
     #   django-anymail
     #   django-debug-toolbar
     #   django-extensions
 django-anymail==12.0
     # via -r requirements-dev.in
-django-debug-toolbar==4.4.6
+django-debug-toolbar==5.1.0
     # via -r requirements-dev.in
 django-extensions==3.2.3
     # via -r requirements-dev.in
 django-test-migrations==1.4.0
     # via -r requirements-dev.in
-ecdsa==0.19.0
+ecdsa==0.19.1
     # via
-    #   -c requirements.txt
     #   python-jose
 exceptiongroup==1.2.2
     # via
-    #   -c requirements.txt
     #   pytest
-factory-boy==3.3.1
+factory-boy==3.3.3
     # via
     #   -r requirements-dev.in
     #   pytest-factoryboy
-faker==29.0.0
+faker==37.0.2
     # via factory-boy
 freezegun==1.5.1
     # via
@@ -56,25 +50,20 @@ freezegun==1.5.1
     #   pytest-freezer
 idna==3.10
     # via
-    #   -c requirements.txt
     #   requests
-importlib-metadata==8.5.0
+importlib-metadata==8.6.1
     # via
-    #   -c requirements.txt
     #   build
 inflection==0.5.1
     # via
-    #   -c requirements.txt
     #   pytest-factoryboy
-iniconfig==2.0.0
+iniconfig==2.1.0
     # via pytest
-markupsafe==2.1.5
+markupsafe==3.0.2
     # via
-    #   -c requirements.txt
     #   werkzeug
-packaging==24.1
+packaging==24.2
     # via
-    #   -c requirements.txt
     #   build
     #   pytest
     #   pytest-factoryboy
@@ -82,65 +71,57 @@ pip-tools==7.4.1
     # via -r requirements-dev.in
 pluggy==1.5.0
     # via pytest
-pyasn1==0.6.1
+pyasn1==0.4.8
     # via
-    #   -c requirements.txt
     #   python-jose
     #   rsa
-pyproject-hooks==1.1.0
+pyproject-hooks==1.2.0
     # via
     #   build
     #   pip-tools
-pytest==8.3.3
+pytest==8.3.5
     # via
     #   -r requirements-dev.in
     #   pytest-cov
     #   pytest-django
     #   pytest-factoryboy
     #   pytest-freezer
-pytest-cov==5.0.0
+pytest-cov==6.0.0
     # via -r requirements-dev.in
-pytest-django==4.9.0
+pytest-django==4.10.0
     # via -r requirements-dev.in
 pytest-factoryboy==2.7.0
     # via -r requirements-dev.in
-pytest-freezer==0.4.8
+pytest-freezer==0.4.9
     # via -r requirements-dev.in
 python-dateutil==2.9.0.post0
     # via
-    #   -c requirements.txt
-    #   faker
     #   freezegun
-python-jose==3.3.0
+python-jose==3.4.0
     # via
-    #   -c requirements.txt
     #   -r requirements-dev.in
 requests==2.32.3
     # via
-    #   -c requirements.txt
     #   django-anymail
     #   requests-mock
 requests-mock==1.12.1
     # via -r requirements-dev.in
 rsa==4.9
     # via
-    #   -c requirements.txt
     #   python-jose
 ruff==0.7.1
     # via -r requirements-dev.in
-six==1.16.0
+six==1.17.0
     # via
-    #   -c requirements.txt
     #   ecdsa
     #   python-dateutil
-snakemd==2.2.0
+snakemd==2.3.0
     # via -r requirements-dev.in
-sqlparse==0.5.1
+sqlparse==0.5.3
     # via
-    #   -c requirements.txt
     #   django
     #   django-debug-toolbar
-tomli==2.0.2
+tomli==2.2.1
     # via
     #   build
     #   coverage
@@ -148,22 +129,22 @@ tomli==2.0.2
     #   pytest
 typing-extensions==4.12.2
     # via
-    #   -c requirements.txt
     #   asgiref
     #   django-test-migrations
     #   pytest-factoryboy
+tzdata==2025.1
+    # via
+    #   faker
 urllib3==1.26.20
     # via
-    #   -c requirements.txt
     #   django-anymail
     #   requests
-werkzeug==3.0.6
+werkzeug==3.1.3
     # via -r requirements-dev.in
-wheel==0.44.0
+wheel==0.45.1
     # via pip-tools
-zipp==3.20.2
+zipp==3.21.0
     # via
-    #   -c requirements.txt
     #   importlib-metadata
 
 # The following packages are considered to be unsafe in a requirements file:
diff --git a/requirements.in b/requirements.in
index e05fb4609..21d356bd6 100644
--- a/requirements.in
+++ b/requirements.in
@@ -35,6 +35,7 @@ ipython
 isodate
 jinja2
 langdetect
+libvoikko
 lxml
 markdown
 pillow
diff --git a/requirements.txt b/requirements.txt
index 88b3cf971..0001083c9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,44 +8,44 @@ asgiref==3.8.1
     # via
     #   django
     #   django-cors-headers
-asttokens==2.4.1
+asttokens==3.0.0
     # via stack-data
-async-timeout==4.0.3
+async-timeout==5.0.1
     # via redis
-attrs==24.2.0
+attrs==25.3.0
     # via
     #   -r requirements.in
     #   cattrs
     #   jsonschema
     #   referencing
     #   requests-cache
-authlib==1.3.2
+authlib==1.5.1
     # via drf-oidc-auth
-beautifulsoup4==4.12.3
+beautifulsoup4==4.13.3
     # via -r requirements.in
-bleach==6.1.0
+bleach==6.2.0
     # via -r requirements.in
-cachetools==5.5.0
+cachetools==5.5.2
     # via django-helusers
 cattrs==24.1.2
     # via requests-cache
-certifi==2024.8.30
+certifi==2025.1.31
     # via
     #   elasticsearch
     #   requests
     #   sentry-sdk
 cffi==1.17.1
     # via cryptography
-charset-normalizer==3.3.2
+charset-normalizer==3.4.1
     # via requests
-cryptography==44.0.1
+cryptography==44.0.2
     # via
     #   authlib
     #   drf-oidc-auth
     #   social-auth-core
-decorator==5.1.1
+decorator==5.2.1
     # via ipython
-defusedxml==0.8.0rc2
+defusedxml==0.7.1
     # via
     #   python3-openid
     #   social-auth-core
@@ -82,13 +82,13 @@ django==4.2.20
     #   social-auth-app-django
 django-admin-autocomplete-filter==0.7.1
     # via -r requirements.in
-django-appconf==1.0.6
+django-appconf==1.1.0
     # via django-image-cropping
 django-cleanup==9.0.0
     # via -r requirements.in
-django-cors-headers==4.4.0
+django-cors-headers==4.7.0
     # via -r requirements.in
-django-environ==0.11.2
+django-environ==0.12.0
     # via -r requirements.in
 django-filter==24.3
     # via
@@ -96,7 +96,7 @@ django-filter==24.3
     #   djangorestframework-gis
 django-haystack==3.3.0
     # via -r requirements.in
-django-helusers==0.13.0
+django-helusers==0.13.3
     # via
     #   -r requirements.in
     #   helsinki-profile-gdpr-api
@@ -106,18 +106,18 @@ django-jinja==2.11.0
     # via -r requirements.in
 django-js-asset==2.2.0
     # via django-mptt
-django-leaflet==0.30.1
+django-leaflet==0.31.0
     # via -r requirements.in
 django-mailer==2.3.2
     # via -r requirements.in
-django-modeltranslation==0.19.9
+django-modeltranslation==0.19.12
     # via -r requirements.in
 django-mptt==0.16.0
     # via
     #   -r requirements.in
     #   django-munigeo
     #   django-orghierarchy
-django-munigeo==0.3.10
+django-munigeo==0.3.12
     # via -r requirements.in
 django-orghierarchy==0.4.0
     # via -r requirements.in
@@ -129,7 +129,7 @@ django-parler-rest==2.2
     # via django-munigeo
 django-redis==5.4.0
     # via -r requirements.in
-django-rest-knox==5.0.1
+django-rest-knox==5.0.2
     # via -r requirements.in
 django-reversion==5.1.0
     # via -r requirements.in
@@ -154,11 +154,11 @@ drf-oidc-auth==3.0.0
     # via
     #   -r requirements.in
     #   helsinki-profile-gdpr-api
-drf-spectacular==0.27.2
+drf-spectacular==0.28.0
     # via -r requirements.in
 easy-thumbnails==2.10
     # via -r requirements.in
-ecdsa==0.19.0
+ecdsa==0.19.1
     # via python-jose
 elasticsearch==7.17.12
     # via -r requirements.in
@@ -166,17 +166,17 @@ exceptiongroup==1.2.2
     # via
     #   cattrs
     #   ipython
-executing==2.1.0
+executing==2.2.0
     # via stack-data
 helsinki-profile-gdpr-api==0.2.0
     # via -r requirements.in
 httmock==1.4.0
     # via -r requirements.in
-icalendar==5.0.13
+icalendar==6.1.2
     # via -r requirements.in
 idna==3.10
     # via requests
-importlib-metadata==8.5.0
+importlib-metadata==8.6.1
     # via markdown
 inflection==0.5.1
     # via drf-spectacular
@@ -186,7 +186,7 @@ isodate==0.6.1
     # via
     #   -r requirements.in
     #   rdflib
-jedi==0.19.1
+jedi==0.19.2
     # via ipython
 jinja2==3.1.6
     # via
@@ -194,19 +194,21 @@ jinja2==3.1.6
     #   django-jinja
 jsonschema==4.23.0
     # via drf-spectacular
-jsonschema-specifications==2023.12.1
+jsonschema-specifications==2024.10.1
     # via jsonschema
 langdetect==1.0.9
     # via -r requirements.in
+libvoikko==4.3
+    # via -r requirements.in
 lockfile==0.12.2
     # via django-mailer
-lxml==5.3.0
+lxml==5.3.1
     # via
     #   -r requirements.in
     #   python-docx
 markdown==3.7
     # via -r requirements.in
-markupsafe==2.1.5
+markupsafe==3.0.2
     # via jinja2
 matplotlib-inline==0.1.7
     # via ipython
@@ -214,7 +216,7 @@ oauthlib==3.2.2
     # via
     #   requests-oauthlib
     #   social-auth-core
-packaging==24.1
+packaging==24.2
     # via
     #   deprecation
     #   django-haystack
@@ -222,33 +224,33 @@ parso==0.8.4
     # via jedi
 pexpect==4.9.0
     # via ipython
-pillow==10.4.0
+pillow==11.1.0
     # via
     #   -r requirements.in
     #   easy-thumbnails
-platformdirs==4.3.6
+platformdirs==4.3.7
     # via requests-cache
-prompt-toolkit==3.0.47
+prompt-toolkit==3.0.50
     # via ipython
-psycopg2==2.9.9
+psycopg2==2.9.10
     # via -r requirements.in
 ptyprocess==0.7.0
     # via pexpect
 pure-eval==0.2.3
     # via stack-data
-pyasn1==0.6.1
+pyasn1==0.4.8
     # via
     #   python-jose
     #   rsa
 pycparser==2.22
     # via cffi
-pycryptodome==3.20.0
+pycryptodome==3.22.0
     # via django-searchable-encrypted-fields
-pygments==2.18.0
+pygments==2.19.1
     # via ipython
-pyjwt==2.9.0
+pyjwt==2.10.1
     # via social-auth-core
-pyparsing==3.1.4
+pyparsing==3.2.1
     # via rdflib
 python-dateutil==2.9.0.post0
     # via
@@ -256,14 +258,12 @@ python-dateutil==2.9.0.post0
     #   icalendar
 python-docx==1.1.2
     # via -r requirements.in
-python-jose==3.3.0
+python-jose==3.4.0
     # via django-helusers
 python3-openid==3.2.0
     # via social-auth-core
-pytz==2024.2
-    # via
-    #   -r requirements.in
-    #   icalendar
+pytz==2025.1
+    # via -r requirements.in
 pyyaml==6.0.2
     # via
     #   -r requirements.in
@@ -271,13 +271,13 @@ pyyaml==6.0.2
     #   drf-spectacular
 rdflib==6.3.2
     # via -r requirements.in
-redis==5.0.8
+redis==5.2.1
     # via django-redis
-referencing==0.35.1
+referencing==0.36.2
     # via
     #   jsonschema
     #   jsonschema-specifications
-regex==2024.9.11
+regex==2024.11.6
     # via -r requirements.in
 requests==2.32.3
     # via
@@ -296,33 +296,31 @@ requests-cache==1.2.1
     #   django-munigeo
 requests-oauthlib==2.0.0
     # via social-auth-core
-rpds-py==0.20.0
+rpds-py==0.23.1
     # via
     #   jsonschema
     #   referencing
 rsa==4.9
     # via python-jose
-sentry-sdk==2.14.0
+sentry-sdk==2.24.0
     # via -r requirements.in
-six==1.16.0
+six==1.17.0
     # via
-    #   asttokens
-    #   bleach
     #   django-munigeo
     #   ecdsa
     #   isodate
     #   langdetect
     #   python-dateutil
     #   url-normalize
-social-auth-app-django==5.4.2
+social-auth-app-django==5.4.3
     # via -r requirements.in
-social-auth-core==4.5.4
+social-auth-core==4.5.6
     # via
     #   -r requirements.in
     #   social-auth-app-django
 soupsieve==2.6
     # via beautifulsoup4
-sqlparse==0.5.1
+sqlparse==0.5.3
     # via django
 stack-data==0.6.3
     # via ipython
@@ -335,11 +333,15 @@ traitlets==5.14.3
 typing-extensions==4.12.2
     # via
     #   asgiref
+    #   beautifulsoup4
     #   cattrs
     #   django-modeltranslation
     #   drf-spectacular
     #   ipython
     #   python-docx
+    #   referencing
+tzdata==2025.1
+    # via icalendar
 ujson==5.10.0
     # via -r requirements.in
 uritemplate==4.1.1
@@ -356,11 +358,11 @@ wcwidth==0.2.13
     # via prompt-toolkit
 webencodings==0.5.1
     # via bleach
-whitenoise==6.7.0
+whitenoise==6.9.0
     # via -r requirements.in
-xlsxwriter==3.2.0
+xlsxwriter==3.2.2
     # via -r requirements.in
-zipp==3.20.2
+zipp==3.21.0
     # via importlib-metadata
 
 # The following packages are considered to be unsafe in a requirements file:


Reviews:
user: login: nicobav
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T13:26:49Z
user: login: mhieta
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T13:30:37Z
user: login: mhieta
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T10:08:13Z
user: login: nicobav
body: Very nice
![](https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExdzk1ZTN6b3VyaXB1YndzMHFjeGt5bTFmMDI5NmQzZzU0b2ZzamR2YyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/Od0QRnzwRBYmDU3eEO/giphy.gif)
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T11:08:50Z

Review comments:
diff_hunk: @@ -1419,6 +1434,41 @@ class Meta:
         managed = False
 
 
+class XEventFullText(models.Model):
path: events/models.py
user: login: nicobav
body: Even though we're still experimenting, I'd leave the X out of the name and call this `EventFullText` - we can use the x in the api still though 👍 
created_at: 2025-04-01T13:22:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1419,6 +1434,41 @@ class Meta:
         managed = False
 
 
+class XEventFullText(models.Model):
path: events/models.py
user: login: mhieta
body: `EventFullText` is already reserved/used for the representation of the materialized view used in current full-text search. So the idea was not to touch that right now, as it will be major refactoring and also we would like to use the materialized view for reference testing purposes for now.
created_at: 2025-04-01T13:30:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1419,6 +1434,41 @@ class Meta:
         managed = False
 
 
+class XEventFullText(models.Model):
path: events/models.py
user: login: mhieta
body: Agreed, that we'll use a completely new model name here: `EventSearchIndex`.
created_at: 2025-04-02T10:08:13Z
author_association: CONTRIBUTOR

PR comments:
user: login: terovirtanen
created_at: 2025-03-21T13:59:06Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: terovirtanen
created_at: 2025-03-31T13:41:30Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: terovirtanen
created_at: 2025-03-31T14:04:33Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: terovirtanen
created_at: 2025-03-31T14:23:14Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: terovirtanen
created_at: 2025-03-31T14:37:47Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: terovirtanen
created_at: 2025-03-31T15:44:32Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: terovirtanen
created_at: 2025-04-01T09:32:43Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: terovirtanen
created_at: 2025-04-02T08:04:02Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: terovirtanen
created_at: 2025-04-02T09:46:33Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: terovirtanen
created_at: 2025-04-02T10:26:16Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: terovirtanen
created_at: 2025-04-02T11:04:21Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: terovirtanen
created_at: 2025-04-02T11:15:50Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-04-02T12:13:28Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=City-of-Helsinki_linkedevents&pullRequest=1048) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=City-of-Helsinki_linkedevents&pullRequest=1048&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=City-of-Helsinki_linkedevents&pullRequest=1048&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=City-of-Helsinki_linkedevents&pullRequest=1048&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=City-of-Helsinki_linkedevents&pullRequest=1048&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=City-of-Helsinki_linkedevents&pullRequest=1048&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=City-of-Helsinki_linkedevents&pullRequest=1048)


author_association: NONE
user: login: terovirtanen
created_at: 2025-04-02T12:17:04Z
body: **LINKEDEVENTS-API branch is deployed to platta: https://linkedevents-pr1048.api.dev.hel.ninja** :rocket::rocket::rocket:
author_association: CONTRIBUTOR