diff --git a/src/onegov/activity/models/attendee.py b/src/onegov/activity/models/attendee.py index 36bafcff07..8ac477e2c6 100644 --- a/src/onegov/activity/models/attendee.py +++ b/src/onegov/activity/models/attendee.py @@ -65,7 +65,10 @@ class Attendee(Base, TimestampMixin, ORMSearchable): 'name': {'type': 'text'}, 'notes': {'type': 'localized'} } - es_public = False + + @hybrid_property + def es_public(self) -> bool: + return False @property def es_suggestion(self) -> str: diff --git a/src/onegov/agency/models/agency.py b/src/onegov/agency/models/agency.py index 6e0750f202..b05a3e794f 100644 --- a/src/onegov/agency/models/agency.py +++ b/src/onegov/agency/models/agency.py @@ -1,3 +1,8 @@ +from sqlalchemy import and_ +from sqlalchemy.orm import object_session +from sqlalchemy.orm import relationship +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.agency.models.membership import ExtendedAgencyMembership from onegov.agency.utils import get_html_paragraph_with_line_breaks from onegov.core.crypto import random_token @@ -11,9 +16,6 @@ from onegov.org.models.extensions import PublicationExtension from onegov.people import Agency from onegov.user import RoleMapping -from sqlalchemy.orm import object_session -from sqlalchemy.orm import relationship - from typing import Any from typing import IO @@ -24,6 +26,7 @@ from markupsafe import Markup from onegov.agency.request import AgencyRequest from onegov.core.types import AppenderQuery + from sqlalchemy.sql import ClauseElement from uuid import UUID @@ -40,10 +43,17 @@ class ExtendedAgency(Agency, AccessExtension, PublicationExtension): es_type_name = 'extended_agency' - @property - def es_public(self) -> bool: # type:ignore[override] + @hybrid_property + def es_public(self) -> bool: return self.access == 'public' and self.published + @es_public.expression # type:ignore[no-redef] + def es_public(cls) -> 'ClauseElement': + return and_( + cls.access == 'public', + cls.published == True + ) + #: Defines which fields of a membership and person should be exported to #: the PDF. The fields are expected to contain two parts seperated by a #: point. The first part is either `membership` or `person`, the second diff --git a/src/onegov/agency/models/membership.py b/src/onegov/agency/models/membership.py index b4b4d66039..a1048c6c7b 100644 --- a/src/onegov/agency/models/membership.py +++ b/src/onegov/agency/models/membership.py @@ -1,15 +1,18 @@ +from sqlalchemy import case, select +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.orm.mixins import dict_property from onegov.core.orm.mixins import meta_property from onegov.org.models.extensions import AccessExtension from onegov.org.models.extensions import PublicationExtension from onegov.people import AgencyMembership - from typing import TYPE_CHECKING if TYPE_CHECKING: from onegov.agency.models import ExtendedAgency from onegov.agency.models import ExtendedPerson from sqlalchemy.orm import relationship + from sqlalchemy.sql import ClauseElement class ExtendedAgencyMembership(AgencyMembership, AccessExtension, @@ -20,8 +23,8 @@ class ExtendedAgencyMembership(AgencyMembership, AccessExtension, es_type_name = 'extended_membership' - @property - def es_public(self) -> bool: # type:ignore[override] + @hybrid_property + def es_public(self) -> bool: if self.agency: if self.agency.meta.get('access', 'public') != 'public': return False @@ -36,6 +39,36 @@ def es_public(self) -> bool: # type:ignore[override] return self.access == 'public' + @es_public.expression # type:ignore[no-redef] + def es_public(cls) -> 'ClauseElement': + from onegov.agency.models import ExtendedAgency, ExtendedPerson + + person_meta = select([ExtendedPerson.meta]).where( + ExtendedPerson.id == cls.person_id + ).as_scalar() + + person_published = select([ExtendedPerson.published]).where( + ExtendedPerson.id == cls.person_id + ).as_scalar() + + agency_meta = select([ExtendedAgency.meta]).where( + ExtendedAgency.id == cls.agency_id + ).as_scalar() + + agency_published = select([ExtendedAgency.published]).where( + ExtendedAgency.id == cls.agency_id + ).as_scalar() + + return case( + [ + (person_meta['access'] != 'public', False), + (person_published != True, False), + (agency_meta['access'] != 'public', False), + (agency_published != True, False), + ], + else_=cls.meta['access'] == 'public' + ) + # Todo: It is very unclear how this should be used. In the PDF rendering, # it is placed a middle column with 0.5 cm after the title. # On the agency, it is placed after the membership title, so not a prefix diff --git a/src/onegov/agency/models/person.py b/src/onegov/agency/models/person.py index e632d8c3c8..9e2c53c188 100644 --- a/src/onegov/agency/models/person.py +++ b/src/onegov/agency/models/person.py @@ -1,9 +1,12 @@ +from sqlalchemy import func, select, and_ +from sqlalchemy.orm import object_session +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.agency.utils import get_html_paragraph_with_line_breaks from onegov.org.models import Organisation from onegov.org.models.extensions import AccessExtension from onegov.org.models.extensions import PublicationExtension from onegov.people import Person -from sqlalchemy.orm import object_session from typing import TYPE_CHECKING @@ -13,6 +16,7 @@ from onegov.agency.request import AgencyRequest from onegov.core.types import AppenderQuery from sqlalchemy.orm import relationship + from sqlalchemy.sql import ClauseElement class ExtendedPerson(Person, AccessExtension, PublicationExtension): @@ -22,10 +26,17 @@ class ExtendedPerson(Person, AccessExtension, PublicationExtension): es_type_name = 'extended_person' - @property - def es_public(self) -> bool: # type:ignore[override] + @hybrid_property + def es_public(self) -> bool: return self.access == 'public' and self.published + @es_public.expression # type:ignore[no-redef] + def es_public(cls) -> 'ClauseElement': + return and_( + cls.access == 'public', + cls.published == True + ) + es_properties = { 'title': {'type': 'text'}, 'function': {'type': 'localized'}, @@ -50,14 +61,28 @@ def es_suggestion(self) -> tuple[str, ...]: AppenderQuery[ExtendedAgencyMembership] ] - @property + @hybrid_property def phone_internal(self) -> str: org = object_session(self).query(Organisation).one() number = getattr(self, org.agency_phone_internal_field) digits = org.agency_phone_internal_digits return number.replace(' ', '')[-digits:] if number and digits else '' - @property + @phone_internal.expression # type:ignore[no-redef] + def phone_internal(cls) -> 'ClauseElement': + org_subquery = ( + select([Organisation.agency_phone_internal_field, + Organisation.agency_phone_internal_digits]) + .limit(1) + .scalar_subquery() + ) + return func.substr( + func.replace(getattr( + cls, org_subquery.c.agency_phone_internal_field), ' ', ''), + -org_subquery.c.agency_phone_internal_field_digits + ).label('phone_internal') + + @hybrid_property def phone_es(self) -> list[str]: result = [self.phone_internal] for number in (self.phone, self.phone_direct): diff --git a/src/onegov/agency/views/search.py b/src/onegov/agency/views/search.py index dfdf8a9a61..c16ab88e79 100644 --- a/src/onegov/agency/views/search.py +++ b/src/onegov/agency/views/search.py @@ -1,9 +1,8 @@ from onegov.agency import AgencyApp from onegov.agency.layout import AgencySearchLayout from onegov.core.security import Public -from onegov.org.models import Search -from onegov.org.views.search import search as search_view - +from onegov.org.models import Search, SearchPostgres +from onegov.org.views.search import search as search_view, search_postgres from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -23,3 +22,15 @@ def search( if isinstance(data, dict): data['layout'] = AgencySearchLayout(self, request) return data + + +@AgencyApp.html(model=SearchPostgres, template='search_postgres.pt', + permission=Public) +def agency_search_postgres( + self: SearchPostgres['Base'], + request: 'AgencyRequest' +) -> 'RenderData | Response': + data = search_postgres(self, request) + if isinstance(data, dict): + data['layout'] = AgencySearchLayout(self, request) + return data diff --git a/src/onegov/core/elements.py b/src/onegov/core/elements.py index 37e6cf6145..76a41a8b3b 100644 --- a/src/onegov/core/elements.py +++ b/src/onegov/core/elements.py @@ -13,6 +13,7 @@ This module should eventually replace the elements.py module. """ +from sqlalchemy.ext.hybrid import hybrid_property from onegov.core.templates import render_macro @@ -137,7 +138,7 @@ class AccessMixin: __slots__ = () - @property + @hybrid_property def access(self) -> str: """ Wraps model.access, ensuring it is always available, even if the model does not use it. diff --git a/src/onegov/directory/models/directory.py b/src/onegov/directory/models/directory.py index c3f7029ea7..66613b5986 100644 --- a/src/onegov/directory/models/directory.py +++ b/src/onegov/directory/models/directory.py @@ -2,6 +2,9 @@ from email_validator import validate_email from enum import Enum + +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.cache import instance_lru_cache from onegov.core.cache import lru_cache from onegov.core.crypto import random_token @@ -90,7 +93,7 @@ def directory_entry(self) -> 'DirectoryEntry | None': entries = self.linked_directory_entries return entries[0] if entries else None - @property + @hybrid_property def access(self) -> str: # we don't want these files to show up in search engines return 'secret' if self.published else 'private' @@ -110,7 +113,7 @@ class Directory(Base, ContentMixin, TimestampMixin, 'lead': {'type': 'localized'} } - @property + @hybrid_property def es_public(self) -> bool: return False # to be overridden downstream diff --git a/src/onegov/directory/models/directory_entry.py b/src/onegov/directory/models/directory_entry.py index acb6ecdc7a..06bd8f9f0b 100644 --- a/src/onegov/directory/models/directory_entry.py +++ b/src/onegov/directory/models/directory_entry.py @@ -1,3 +1,5 @@ +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.orm import Base from onegov.core.orm.mixins import ContentMixin from onegov.core.orm.mixins import TimestampMixin @@ -6,7 +8,7 @@ from onegov.file import AssociatedFiles from onegov.gis import CoordinatesMixin from onegov.search import SearchableContent -from sqlalchemy import Column +from sqlalchemy import Column, func, cast, ARRAY, String from sqlalchemy import ForeignKey from sqlalchemy import Index from sqlalchemy import Text @@ -30,10 +32,10 @@ class DirectoryEntry(Base, ContentMixin, CoordinatesMixin, TimestampMixin, __tablename__ = 'directory_entries' es_properties = { - 'keywords': {'type': 'keyword'}, 'title': {'type': 'localized'}, 'lead': {'type': 'localized'}, - 'directory_id': {'type': 'keyword'}, + 'keywords': {'type': 'keyword'}, + # 'directory_id': {'type': 'keyword'}, # since the searchable text might include html, we remove it # even if there's no html -> possibly decreasing the search @@ -41,7 +43,7 @@ class DirectoryEntry(Base, ContentMixin, CoordinatesMixin, TimestampMixin, 'text': {'type': 'localized_html'} } - @property + @hybrid_property def es_public(self) -> bool: return False # to be overridden downstream @@ -113,17 +115,26 @@ def external_link_visible(self) -> bool | None: def directory_name(self) -> str: return self.directory.name - @property + @hybrid_property def keywords(self) -> set[str]: return set(self._keywords.keys()) if self._keywords else set() # FIXME: asymmetric properties are not supported by mypy, switch to # a custom descriptor, if desired. - @keywords.setter + @keywords.setter # type:ignore[no-redef] def keywords(self, value: 'Collection[str] | None') -> None: self._keywords = dict.fromkeys(value, '') if value else None - @property + @keywords.expression # type:ignore[no-redef] + def keywords(cls): + return func.array_to_string( + func.array_agg( + cast(func.jsonb_each_text(cls._keywords).keys(), ARRAY(String)) + ), + ' ' + ) + + @hybrid_property def text(self) -> str: return self.directory.configuration.extract_searchable(self.values) diff --git a/src/onegov/event/models/event.py b/src/onegov/event/models/event.py index 402b272065..a63d02bd0b 100644 --- a/src/onegov/event/models/event.py +++ b/src/onegov/event/models/event.py @@ -1,12 +1,12 @@ import warnings from datetime import datetime - from dateutil import rrule from dateutil.rrule import rrulestr from icalendar import Calendar as vCalendar from icalendar import Event as vEvent from icalendar import vRecur +from sqlalchemy.ext.hybrid import hybrid_property from onegov.core.orm import Base from onegov.core.orm.abstract import associated @@ -194,7 +194,7 @@ def set_blob( 'filter_keywords': {'type': 'keyword'} } - @property + @hybrid_property def es_public(self) -> bool: return self.state == 'published' diff --git a/src/onegov/event/models/occurrence.py b/src/onegov/event/models/occurrence.py index 30b6925ea9..86dccdf8f7 100644 --- a/src/onegov/event/models/occurrence.py +++ b/src/onegov/event/models/occurrence.py @@ -1,5 +1,7 @@ from icalendar import Calendar as vCalendar from icalendar import Event as vEvent +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.orm import Base from onegov.core.orm.mixins import TimestampMixin from onegov.core.orm.types import UUID @@ -8,7 +10,7 @@ from pytz import UTC from sedate import to_timezone from sedate import utcnow -from sqlalchemy import Column +from sqlalchemy import Column, select from sqlalchemy import ForeignKey from sqlalchemy.orm import relationship from uuid import uuid4 @@ -18,6 +20,7 @@ if TYPE_CHECKING: import uuid from onegov.event.models import Event + from sqlalchemy.sql import ClauseElement class Occurrence(Base, OccurrenceMixin, TimestampMixin): @@ -72,6 +75,12 @@ def as_ical(self, url: str | None = None) -> bytes: vcalendar.add_component(vevent) return vcalendar.to_ical() - @property + @hybrid_property def access(self) -> str: return self.event.access + + @access.expression # type:ignore[no-redef] + def access(cls) -> 'ClauseElement': + return select([Event.meta['access']]).where( + Event.id == cls.event_id + ).as_scalar() diff --git a/src/onegov/feriennet/models/activity.py b/src/onegov/feriennet/models/activity.py index 540562fa72..e9a563e03a 100644 --- a/src/onegov/feriennet/models/activity.py +++ b/src/onegov/feriennet/models/activity.py @@ -1,4 +1,8 @@ from functools import cached_property + +from sqlalchemy import func +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.activity import Activity, ActivityCollection, Occasion from onegov.activity import PublicationRequestCollection from onegov.activity.models import DAYS @@ -15,6 +19,8 @@ if TYPE_CHECKING: from collections.abc import Iterator, Sequence from markupsafe import Markup + from sqlalchemy.sql import ClauseElement + from onegov.activity.models import PublicationRequest from onegov.feriennet.request import FeriennetRequest @@ -32,7 +38,7 @@ class VacationActivity(Activity, CoordinatesExtension, SearchableContent): 'organiser': {'type': 'text'} } - @property + @hybrid_property def es_public(self) -> bool: return self.state == 'accepted' @@ -40,7 +46,7 @@ def es_public(self) -> bool: def es_skip(self) -> bool: return self.state == 'preview' - @property + @hybrid_property def organiser(self) -> list[str]: organiser: list[str] = [ self.user.username, @@ -71,6 +77,23 @@ def organiser(self) -> list[str]: return organiser + @organiser.expression # ignore[no-redef] + def organizer(cls) -> 'ClauseElement': + return func.array([ + cls.user.username, + cls.user.realname, + cls.user.data.get('organisation', ''), + cls.user.data.get('address', ''), + cls.user.data.get('zip_code', ''), + cls.user.data.get('place', ''), + cls.user.data.get('email', ''), + cls.user.data.get('phone', ''), + cls.user.data.get('emergency', ''), + cls.user.data.get('website', ''), + cls.user.data.get('bank_account', ''), + cls.user.data.get('bank_beneficiary', ''), + ]) + def ordered_tags( self, request: 'FeriennetRequest', diff --git a/src/onegov/file/models/file.py b/src/onegov/file/models/file.py index dddd7ef225..da7ce8cbfe 100644 --- a/src/onegov/file/models/file.py +++ b/src/onegov/file/models/file.py @@ -113,7 +113,7 @@ class SearchableFile(ORMSearchable): def es_suggestion(self) -> str: return self.name - @property + @hybrid_property def es_public(self) -> bool: return self.published @@ -311,7 +311,7 @@ def reference_observer(self, reference: 'UploadedFile') -> None: def name_observer(self, name: str) -> None: self.order = normalize_for_url(name) - @property + @hybrid_property def access(self) -> str: return 'public' if self.published else 'private' diff --git a/src/onegov/form/models/submission.py b/src/onegov/form/models/submission.py index a7a874a191..1c76369ca8 100644 --- a/src/onegov/form/models/submission.py +++ b/src/onegov/form/models/submission.py @@ -413,7 +413,7 @@ class CompleteFormSubmission(FormSubmission): class FormFile(File): __mapper_args__ = {'polymorphic_identity': 'formfile'} - @property + @hybrid_property def access(self) -> str: # we don't want these files to show up in search engines return 'secret' if self.published else 'private' diff --git a/src/onegov/fsi/models/course.py b/src/onegov/fsi/models/course.py index 6ed1f19c9f..7a05e23549 100644 --- a/src/onegov/fsi/models/course.py +++ b/src/onegov/fsi/models/course.py @@ -25,7 +25,10 @@ class Course(Base, ORMSearchable): 'name': {'type': 'localized'}, 'description': {'type': 'localized'}, } - es_public = True + + @hybrid_property + def es_public(self) -> bool: + return True id: 'Column[uuid.UUID]' = Column( UUID, # type:ignore[arg-type] diff --git a/src/onegov/fsi/models/course_attendee.py b/src/onegov/fsi/models/course_attendee.py index 8e5a116a15..f5e17bcb54 100644 --- a/src/onegov/fsi/models/course_attendee.py +++ b/src/onegov/fsi/models/course_attendee.py @@ -1,6 +1,8 @@ +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.orm import Base from onegov.core.orm.types import UUID, JSON -from sqlalchemy import Boolean +from sqlalchemy import Boolean, case, and_ from onegov.search import ORMSearchable from sedate import utcnow from sqlalchemy import Column, Text, ForeignKey, ARRAY, desc @@ -14,6 +16,7 @@ from onegov.core.types import AppenderQuery from onegov.user import User from sqlalchemy.orm import Query + from sqlalchemy.sql import ClauseElement from .course_event import CourseEvent from .course_subscription import CourseSubscription @@ -49,7 +52,9 @@ class CourseAttendee(Base, ORMSearchable): 'title': {'type': 'text'}, } - es_public = False + @hybrid_property + def es_public(self) -> bool: + return False id: 'Column[uuid.UUID]' = Column( UUID, # type:ignore[arg-type] @@ -128,7 +133,7 @@ def __str__(self) -> str: cascade='all, delete-orphan' ) - @property + @hybrid_property def title(self) -> str: return ' '.join( part @@ -136,6 +141,15 @@ def title(self) -> str: if part ) or self.email + @title.expression # type:ignore[no-redef] + def title(cls) -> 'ClauseElement': + has_name = and_(cls.first_name != None, cls.last_name != None) + return case([ + (has_name, cls.first_name + ' ' + cls.last_name), + (cls.first_name != None, cls.first_name), + (cls.last_name != None, cls.last_name) + ], else_=cls._email) + @property def lead(self) -> str | None: return self.organisation @@ -152,7 +166,7 @@ def role(self) -> str: assert self.user is not None return self.user.role - @property + @hybrid_property def email(self) -> str: """Needs a switch for external users""" if not self.user_id: @@ -161,11 +175,17 @@ def email(self) -> str: # where it isn't allowed to be None, so we should # probably disallow it and properly deal with it # in places where it's allowed to be None - return self._email # type:ignore[return-value] + return self._email assert self.user is not None return self.user.username - @email.setter + @email.expression # type:ignore[no-redef] + def email(cls) -> 'ClauseElement': + return case([ + (cls.user_id == None, cls._email) + ], else_=cls.user.username) + + @email.setter # type:ignore[no-redef] def email(self, value: str) -> None: self._email = value diff --git a/src/onegov/fsi/models/course_event.py b/src/onegov/fsi/models/course_event.py index 6305c6b143..c9243a0805 100644 --- a/src/onegov/fsi/models/course_event.py +++ b/src/onegov/fsi/models/course_event.py @@ -7,7 +7,8 @@ from icalendar import Event as vEvent from sedate import utcnow, to_timezone from sqlalchemy import ( - Column, Boolean, SmallInteger, Enum, Text, Interval, ForeignKey, or_, and_) + Column, Boolean, SmallInteger, Enum, Text, Interval, ForeignKey, or_, and_, + select) from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship, object_session from uuid import uuid4 @@ -31,6 +32,7 @@ from onegov.core.types import AppenderQuery from onegov.fsi.request import FsiRequest from sqlalchemy.orm import Query + from sqlalchemy.sql import ClauseElement from typing import Self, TypeAlias from wtforms.fields.choices import _Choice from .course import Course @@ -121,7 +123,7 @@ class CourseEvent(Base, TimestampMixin, ORMSearchable): lazy='joined' ) - @property + @hybrid_property def es_public(self) -> bool: return not self.hidden_from_public @@ -129,10 +131,18 @@ def es_public(self) -> bool: def title(self) -> str: return str(self) - @property + @hybrid_property def name(self) -> str: return self.course.name + @name.expression # type:ignore + def name(cls) -> 'ClauseElement': + from .course import Course + + return select([Course.name]).where( + Course.id == cls.course_id + ).as_scalar() + @property def lead(self) -> str: return ( @@ -141,10 +151,18 @@ def lead(self) -> str: f'{self.presenter_company}' ) - @property + @hybrid_property def description(self) -> 'Markup': return self.course.description + @description.expression # type:ignore[no-redef] + def description(cls) -> 'ClauseElement': + from .course import Course + + return select([Course.description]).where( + Course.id == cls.course_id + ).as_scalar() + def __str__(self) -> str: start = to_timezone( self.start, 'Europe/Zurich').strftime('%d.%m.%Y %H:%M') diff --git a/src/onegov/fsi/views/search.py b/src/onegov/fsi/views/search.py index e915c4cec3..7860364182 100644 --- a/src/onegov/fsi/views/search.py +++ b/src/onegov/fsi/views/search.py @@ -1,8 +1,11 @@ from onegov.core.security import Personal from onegov.fsi import FsiApp -from onegov.org.models import Search -from onegov.town6.views.search import town_search as search_view +from onegov.org.models import Search, SearchPostgres +from onegov.org.views.search import search as search_view +from onegov.org.views.search import search_postgres as search_postgres_view from onegov.org.views.search import suggestions as suggestions_view +from onegov.org.views.search import (suggestions_postgres as + suggestions_postgres_view) from typing import TYPE_CHECKING @@ -21,9 +24,25 @@ def search( return search_view(self, request) +@FsiApp.html(model=SearchPostgres, template='search.pt', permission=Personal) +def search_postgres( + self: SearchPostgres['Base'], + request: 'FsiRequest' +) -> 'RenderData | Response': + return search_postgres_view(self, request) + + @FsiApp.json(model=Search, name='suggest', permission=Personal) def suggestions( self: Search['Base'], request: 'FsiRequest' ) -> 'JSON_ro': return suggestions_view(self, request) + + +@FsiApp.json(model=SearchPostgres, name='suggest', permission=Personal) +def suggestions_postgres( + self: SearchPostgres['Base'], + request: 'FsiRequest' +) -> 'JSON_ro': + return suggestions_postgres_view(self, request) diff --git a/src/onegov/landsgemeinde/models/agenda.py b/src/onegov/landsgemeinde/models/agenda.py index 966313d62c..4969411783 100644 --- a/src/onegov/landsgemeinde/models/agenda.py +++ b/src/onegov/landsgemeinde/models/agenda.py @@ -1,3 +1,5 @@ +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.orm import Base from onegov.core.orm.mixins import content_property from onegov.core.orm.mixins import dict_markup_property @@ -49,7 +51,10 @@ class AgendaItem( __tablename__ = 'landsgemeinde_agenda_items' - es_public = True + @hybrid_property + def es_public(self) -> bool: + return True + es_properties = { 'title': {'type': 'text'}, 'overview': {'type': 'localized_html'}, diff --git a/src/onegov/landsgemeinde/models/assembly.py b/src/onegov/landsgemeinde/models/assembly.py index e52b1c871a..36e2df5bd2 100644 --- a/src/onegov/landsgemeinde/models/assembly.py +++ b/src/onegov/landsgemeinde/models/assembly.py @@ -1,3 +1,5 @@ +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.orm import Base from onegov.core.orm.mixins import dict_markup_property from onegov.core.orm.mixins import ContentMixin @@ -46,11 +48,14 @@ class Assembly( __tablename__ = 'landsgemeinde_assemblies' - es_public = True es_properties = { 'overview': {'type': 'localized_html'}, } + @hybrid_property + def es_public(self) -> bool: + return True + @property def es_suggestion(self) -> tuple[str, ...]: return ( diff --git a/src/onegov/landsgemeinde/models/file.py b/src/onegov/landsgemeinde/models/file.py index 581ac84ede..43890fb1b5 100644 --- a/src/onegov/landsgemeinde/models/file.py +++ b/src/onegov/landsgemeinde/models/file.py @@ -1,3 +1,5 @@ +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.file.models.file import File from onegov.file.models.file import SearchableFile @@ -9,6 +11,6 @@ class LandsgemeindeFile(File, SearchableFile): es_type_name = 'landsgemeinde_file' - @property + @hybrid_property def es_public(self) -> bool: return True diff --git a/src/onegov/landsgemeinde/models/votum.py b/src/onegov/landsgemeinde/models/votum.py index 9148c9243f..104b15b917 100644 --- a/src/onegov/landsgemeinde/models/votum.py +++ b/src/onegov/landsgemeinde/models/votum.py @@ -1,3 +1,5 @@ +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.orm import Base from onegov.core.orm.mixins import ContentMixin from onegov.core.orm.mixins import dict_markup_property @@ -43,7 +45,10 @@ class Votum( __tablename__ = 'landsgemeinde_vota' - es_public = True + @hybrid_property + def es_public(self) -> bool: + return True + es_properties = { 'text': {'type': 'localized_html'}, 'motion': {'type': 'localized_html'}, diff --git a/src/onegov/landsgemeinde/views/search.py b/src/onegov/landsgemeinde/views/search.py index ded65c259f..a01b54d625 100644 --- a/src/onegov/landsgemeinde/views/search.py +++ b/src/onegov/landsgemeinde/views/search.py @@ -1,9 +1,8 @@ from onegov.core.security import Public from onegov.landsgemeinde import LandsgemeindeApp from onegov.landsgemeinde.layouts import DefaultLayout -from onegov.org.models import Search -from onegov.org.views.search import search - +from onegov.org.models import Search, SearchPostgres +from onegov.org.views.search import search, search_postgres from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -19,3 +18,12 @@ def landsgemeinde_search( request: 'LandsgemeindeRequest' ) -> 'RenderData | Response': return search(self, request, DefaultLayout(self, request)) + + +@LandsgemeindeApp.html(model=SearchPostgres, template='search_postgres.pt', + permission=Public) +def landsgemeinde_search_postgres( + self: SearchPostgres['Base'], + request: 'LandsgemeindeRequest' +) -> 'RenderData | Response': + return search_postgres(self, request, DefaultLayout(self, request)) diff --git a/src/onegov/newsletter/models.py b/src/onegov/newsletter/models.py index 1c7e2b63e4..50cf0e195f 100644 --- a/src/onegov/newsletter/models.py +++ b/src/onegov/newsletter/models.py @@ -1,4 +1,6 @@ from email_validator import validate_email +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.crypto import random_token from onegov.core.orm import Base from onegov.core.orm.mixins import ( @@ -54,7 +56,7 @@ class Newsletter(Base, ContentMixin, TimestampMixin, SearchableContent): 'html': {'type': 'localized_html'} } - @property + @hybrid_property def es_public(self) -> bool: return self.sent is not None diff --git a/src/onegov/onboarding/app.py b/src/onegov/onboarding/app.py index a460033181..ab5864d9b4 100644 --- a/src/onegov/onboarding/app.py +++ b/src/onegov/onboarding/app.py @@ -2,7 +2,7 @@ from onegov.file import DepotApp from onegov.onboarding.theme import OnboardingTheme from onegov.reservation import LibresIntegration -from onegov.search import ElasticsearchApp +from onegov.search import SearchApp from typing import Any, TYPE_CHECKING @@ -10,7 +10,7 @@ from collections.abc import Iterator -class OnboardingApp(Framework, LibresIntegration, DepotApp, ElasticsearchApp): +class OnboardingApp(Framework, LibresIntegration, DepotApp, SearchApp): serve_static_files = True diff --git a/src/onegov/onboarding/models/town_assistant.py b/src/onegov/onboarding/models/town_assistant.py index 98971182b3..b2d6d1f2ca 100644 --- a/src/onegov/onboarding/models/town_assistant.py +++ b/src/onegov/onboarding/models/town_assistant.py @@ -202,7 +202,7 @@ def add_town( 'org': name }) - self.app.es_perform_reindex() + self.app.perform_reindex() self.app.send_transactional_email( subject=title, receivers=(user, ), diff --git a/src/onegov/org/app.py b/src/onegov/org/app.py index e713c9ce39..dce7467f6e 100644 --- a/src/onegov/org/app.py +++ b/src/onegov/org/app.py @@ -29,7 +29,7 @@ from onegov.org.theme import OrgTheme from onegov.pay import PayApp from onegov.reservation import LibresIntegration -from onegov.search import ElasticsearchApp +from onegov.search import SearchApp from onegov.ticket import TicketCollection from onegov.ticket import TicketPermission from onegov.user import UserApp @@ -54,7 +54,7 @@ from webob import Response -class OrgApp(Framework, LibresIntegration, ElasticsearchApp, MapboxApp, +class OrgApp(Framework, LibresIntegration, SearchApp, MapboxApp, DepotApp, PayApp, FormApp, UserApp, WebsocketsApp): serve_static_files = True diff --git a/src/onegov/org/layout.py b/src/onegov/org/layout.py index 1450555647..3dae7d8d5c 100644 --- a/src/onegov/org/layout.py +++ b/src/onegov/org/layout.py @@ -43,6 +43,7 @@ from onegov.org.models.extensions import PersonLinkExtension from onegov.org.models.external_link import ExternalLinkCollection from onegov.org.models.form import submission_deletable +from onegov.org.models.search import SearchPostgres from onegov.org.open_graph import OpenGraphMixin from onegov.org.theme.org_theme import user_options from onegov.org.utils import IMG_URLS @@ -333,11 +334,19 @@ def homepage_url(self) -> str: @cached_property def search_url(self) -> str: """ Returns the url to the search page. """ + # Allows using postgres search while es search remains default + if (self.request.path_info + and 'search-postgres' in self.request.path_info): + return self.request.class_link(SearchPostgres) return self.request.class_link(Search) @cached_property def suggestions_url(self) -> str: """ Returns the url to the suggestions json view. """ + # Allows using postgres search while es search remains default + if (self.request.path_info + and 'search-postgres' in self.request.path_info): + return self.request.class_link(SearchPostgres, name='suggest') return self.request.class_link(Search, name='suggest') @cached_property diff --git a/src/onegov/org/models/__init__.py b/src/onegov/org/models/__init__.py index 08d1a99e77..e96ad2f939 100644 --- a/src/onegov/org/models/__init__.py +++ b/src/onegov/org/models/__init__.py @@ -46,7 +46,7 @@ from onegov.org.models.recipient import ResourceRecipient from onegov.org.models.recipient import ResourceRecipientCollection from onegov.org.models.resource import DaypassResource -from onegov.org.models.search import Search +from onegov.org.models.search import Search, SearchPostgres from onegov.org.models.sitecollection import SiteCollection from onegov.org.models.swiss_holidays import SwissHolidays from onegov.org.models.tan import TANAccess @@ -101,6 +101,7 @@ 'ResourceRecipient', 'ResourceRecipientCollection', 'Search', + 'SearchPostgres', 'SiteCollection', 'SubmissionMessage', 'SwissHolidays', diff --git a/src/onegov/org/models/directory.py b/src/onegov/org/models/directory.py index 48043d062c..c754210ab5 100644 --- a/src/onegov/org/models/directory.py +++ b/src/onegov/org/models/directory.py @@ -4,6 +4,8 @@ from datetime import timedelta from functools import cached_property from markupsafe import Markup +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.orm.mixins import ( content_property, dict_markup_property, dict_property, meta_property) from onegov.core.utils import linkify @@ -422,7 +424,7 @@ class ExtendedDirectory(Directory, AccessExtension, Extendable, def entry_cls_name(self) -> str: return 'ExtendedDirectoryEntry' - @property + @hybrid_property def es_public(self) -> bool: return self.access == 'public' @@ -504,7 +506,7 @@ class ExtendedDirectoryEntry(DirectoryEntry, PublicationExtension, # technically not enforced, but it should be a given directory: relationship[ExtendedDirectory] - @property + @hybrid_property def es_public(self) -> bool: return self.access == 'public' and self.published diff --git a/src/onegov/org/models/file.py b/src/onegov/org/models/file.py index 4c9eb89ec4..46a84b71ba 100644 --- a/src/onegov/org/models/file.py +++ b/src/onegov/org/models/file.py @@ -6,6 +6,9 @@ from dateutil.relativedelta import relativedelta from functools import cached_property from itertools import chain, groupby + +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.orm import as_selectable from onegov.core.orm.mixins import dict_property, meta_property from onegov.file import File, FileSet, FileCollection, FileSetCollection @@ -17,14 +20,14 @@ from onegov.search import ORMSearchable from operator import itemgetter from sedate import standardize_date, utcnow -from sqlalchemy import asc, desc, select, nullslast # type: ignore +from sqlalchemy import asc, desc, select, nullslast, and_, case # type: ignore from typing import ( overload, Any, Generic, Literal, NamedTuple, TypeVar, TYPE_CHECKING) if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator from sqlalchemy.orm import Query, Session - from sqlalchemy.sql import Select + from sqlalchemy.sql import Select, ClauseElement from typing import Self _T = TypeVar('_T') @@ -234,7 +237,7 @@ class GeneralFile(File, SearchableFile): linked_accesses: dict_property[dict[str, str]] linked_accesses = meta_property(default=dict) - @property + @hybrid_property def access(self) -> str: if self.publication: return 'public' @@ -246,10 +249,30 @@ def access(self) -> str: return widest_access(*self.linked_accesses.values()) - @property + @access.expression # type:ignore[no-redef] + def access(cls): + return case([ + (cls.publication == True, 'public'), + (cls.meta['linked_accesses'] == None, 'secret'), + (cls.meta['linked_accesses'].op('?')('public'), 'public'), + (cls.meta['linked_accesses'].op('?')('secret'), 'secret'), + (cls.meta['linked_accesses'].op('?')('mtan'), 'mtan'), + (cls.meta['linked_accesses'].op('?')('secret_mtan'), + 'secret_mtan'), + (cls.meta['linked_accesses'].op('?')('member'), 'member'), + ], else_='private') + + @hybrid_property def es_public(self) -> bool: return self.published and self.access == 'public' + @es_public.expression # type:ignore[no-redef] + def es_public(cls) -> 'ClauseElement': + return and_( + cls.published == True, + cls.access == 'public' + ) + class ImageFile(File): __mapper_args__ = {'polymorphic_identity': 'image'} @@ -263,7 +286,7 @@ class ImageSet(FileSet, AccessExtension, ORMSearchable): 'lead': {'type': 'localized'} } - @property + @hybrid_property def es_public(self) -> bool: return self.access == 'public' diff --git a/src/onegov/org/models/page.py b/src/onegov/org/models/page.py index d18891f78b..2f144ea29e 100644 --- a/src/onegov/org/models/page.py +++ b/src/onegov/org/models/page.py @@ -1,4 +1,7 @@ from datetime import datetime + +from sqlalchemy.ext.hybrid import hybrid_property + from onegov.core.orm.mixins import ( content_property, dict_markup_property, dict_property, meta_property) from onegov.form import Form, move_fields @@ -28,6 +31,7 @@ from typing import Any, TYPE_CHECKING if TYPE_CHECKING: + from sqlalchemy.sql import ClauseElement from onegov.org.request import OrgRequest, PageMeta from sqlalchemy.orm import Query, Session @@ -56,10 +60,18 @@ class Topic(Page, TraitInfo, SearchableContent, AccessExtension, def es_skip(self) -> bool: return self.meta.get('trait') == 'link' # do not index links - @property + @hybrid_property def es_public(self) -> bool: return self.access == 'public' and self.published + @es_public.expression # type:ignore[no-redef] + def es_public(cls) -> 'ClauseElement': + retval = and_( + cls.meta['access'] == 'public', + cls.published == True + ) + return retval + @property def deletable(self) -> bool: """ Returns true if this page may be deleted. """ @@ -154,10 +166,17 @@ class News(Page, TraitInfo, SearchableContent, NewsletterExtension, hashtags: dict_property[list[str]] = meta_property(default=list) - @property + @hybrid_property def es_public(self) -> bool: return self.access == 'public' and self.published + @es_public.expression # type:ignore[no-redef] + def es_public(cls) -> 'ClauseElement': + return and_( + cls.access == 'public', + cls.published == True + ) + @observes('content') def content_observer(self, content: dict[str, Any]) -> None: self.hashtags = self.es_tags or [] diff --git a/src/onegov/org/models/search.py b/src/onegov/org/models/search.py index 3177130e67..f87ca40d35 100644 --- a/src/onegov/org/models/search.py +++ b/src/onegov/org/models/search.py @@ -4,25 +4,29 @@ from elasticsearch_dsl.query import MatchPhrase from elasticsearch_dsl.query import MultiMatch from functools import cached_property +from sedate import utcnow +from sqlalchemy import func +from typing import TYPE_CHECKING, Any, List + from onegov.core.collection import Pagination, _M +from onegov.core.orm import Base from onegov.event.models import Event +from onegov.search.utils import searchable_sqlalchemy_models - -from typing import TYPE_CHECKING if TYPE_CHECKING: from onegov.org.request import OrgRequest + from onegov.search import Searchable from onegov.search.dsl import Hit, Response, Search as ESSearch class Search(Pagination[_M]): - results_per_page = 10 max_query_length = 100 def __init__(self, request: 'OrgRequest', query: str, page: int) -> None: super().__init__(page) self.request = request - self.query = query + self.web_search = query @cached_property def available_documents(self) -> int: @@ -35,13 +39,13 @@ def explain(self) -> bool: @property def q(self) -> str: - return self.query + return self.web_search def __eq__(self, other: object) -> bool: return ( isinstance(other, self.__class__) and self.page == other.page - and self.query == other.query + and self.web_search == other.web_search ) if TYPE_CHECKING: @@ -56,11 +60,11 @@ def page_index(self) -> int: return self.page def page_by_index(self, index: int) -> 'Search[_M]': - return Search(self.request, self.query, index) + return Search(self.request, self.web_search, index) @cached_property def batch(self) -> 'Response | None': # type:ignore[override] - if not self.query: + if not self.web_search: return None search = self.request.app.es_search_by_request( @@ -70,7 +74,7 @@ def batch(self) -> 'Response | None': # type:ignore[override] # queries need to be cut at some point to make sure we're not # pushing the elasticsearch cluster to the brink - query = self.query[:self.max_query_length] + query = self.web_search[:self.max_query_length] if query.startswith('#'): search = self.hashtag_search(search, query) @@ -95,13 +99,16 @@ def get_sort_key(event: Event) -> float: batch = self.batch.load() events = [] non_events = [] + for search_result in batch: if isinstance(search_result, Event): events.append(search_result) else: non_events.append(search_result) + if not events: return batch + sorted_events = sorted( events, key=get_sort_key @@ -147,7 +154,7 @@ def feeling_lucky(self) -> str | None: first_entry = self.batch[0].load() # XXX the default view to the event should be doing the redirect - if first_entry.__tablename__ == 'events': + if first_entry.es_type_name == 'events': return self.request.link(first_entry, 'latest') else: return self.request.link(first_entry) @@ -159,5 +166,219 @@ def subset_count(self) -> int: def suggestions(self) -> tuple[str, ...]: return tuple(self.request.app.es_suggestions_by_request( - self.request, self.query + self.request, self.web_search )) + + +def locale_mapping(locale: str) -> str: + mapping = {'de_CH': 'german', 'fr_CH': 'french', 'it_CH': 'italian', + 'rm_CH': 'english'} + return mapping.get(locale, 'english') + + +class SearchPostgres(Pagination[_M]): + """ + Implements searching in postgres db based on the gin index + """ + results_per_page = 10 + max_query_length = 100 + + def __init__(self, request: 'OrgRequest', query: str, page: int): + self.request = request + self.web_search = query + self.page = page # page index + + self.nbr_of_docs = 0 + self.nbr_of_results = 0 + + @cached_property + def available_documents(self) -> int: + if not self.nbr_of_docs: + self.load_batch_results + return self.nbr_of_docs + + @cached_property + def available_results(self) -> int: + if not self.nbr_of_results: + self.load_batch_results + return self.nbr_of_results + + @property + def q(self) -> str: + """ + Returns the user's query term from the search field of the UI + + """ + return self.web_search + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SearchPostgres): + return NotImplemented + return self.page == other.page and self.web_search == other.web_search + + def subset(self) -> 'List[Searchable] | None': # type:ignore[override] + return self.batch + + @property + def page_index(self) -> int: + return self.page + + def page_by_index(self, index: int) -> 'SearchPostgres[_M]': + return SearchPostgres(self.request, self.web_search, index) + + @cached_property + def batch(self) -> 'List[Searchable]': # type:ignore[override] + if not self.web_search: + return [] + + if self.web_search.startswith('#'): + results = self.hashtag_search() + else: + results = self.generic_search() + + return results[self.offset:self.offset + self.batch_size] + + @cached_property + def load_batch_results(self) -> list[Any]: + """ + Load search results and sort upcoming events by occurrence start date. + This methods is a wrapper around `batch.load()`, which returns the + actual search results form the query. + + """ + batch: List[Searchable] = self.batch + future_events: List[Searchable] = [] + other: List[Searchable] = [] + + for search_result in batch: + if (isinstance(search_result, Event) + and search_result.latest_occurrence + and search_result.latest_occurrence.start > utcnow()): + future_events.append(search_result) + else: + other.append(search_result) + + if not future_events: + return batch + + sorted_events = sorted( + future_events, key=lambda e: + e.latest_occurrence.start, # type:ignore[attr-defined] + reverse=True) + + return sorted_events + other + + def _create_weighted_vector( + self, + model: Any, + language: str = 'simple' + ) -> Any: + # for now weight the first field with 'A', the rest with 'B' + weighted_vectors = [ + func.setweight( + func.to_tsvector( + language, + getattr(model, field, '')), + weight + ) + for field, weight in zip(model.es_properties.keys(), 'ABBBBBBBBBB') + if not field.startswith('es_') # TODO: rename to fts_ + ] + + # combine all weighted vectors + if weighted_vectors: + combined_vector = weighted_vectors[0] + for vector in weighted_vectors[1:]: + combined_vector = combined_vector.op('||')(vector) + else: + combined_vector = func.to_tsvector(language, '') + + return combined_vector + + def generic_search(self) -> list['Searchable']: + doc_count = 0 + results: List[Any] = [] + language = locale_mapping(self.request.locale or 'de_CH') + ts_query = func.websearch_to_tsquery(language, + func.unaccent(self.web_search)) + session = self.request.session + + for base in self.request.app.session_manager.bases: + for model in searchable_sqlalchemy_models(base): + if model.es_public or self.request.is_logged_in: + query = session.query(model) + if not self.request.is_logged_in: + query = query.filter(model.es_public == True) + + if session.query(query.exists()).scalar(): + weighted = ( + self._create_weighted_vector(model, language)) + rank_expression = func.coalesce( + func.ts_rank( + weighted, + ts_query, + 0 # normalization, ignore document length + ), 0).label('rank') + query = (query.filter(model.fts_idx.op('@@')(ts_query)) + .add_columns(rank_expression)) + + res = list(query.all()) + doc_count += len(res) + results.extend(res) + + # remove duplicates, sort by rank + results = list(set(results)) + results.sort(key=lambda x: x[1], reverse=True) + + self.nbr_of_docs = doc_count + self.nbr_of_results = len(results) + + # remove rank column from results and return + return [r[0] for r in results] + + def hashtag_search(self) -> list['Searchable']: + q = self.web_search.lstrip('#') + results = [] + + for model in searchable_sqlalchemy_models(Base): + # skip certain tables for hashtag search for better performance + if (model.es_type_name not in ['attendees', 'files', 'people', + 'tickets', 'users']): + if model.es_public or self.request.is_logged_in: + for doc in self.request.session.query(model).all(): + if doc.es_tags and q in doc.es_tags: + results.append(doc) + + # remove duplicates + results = list(set(results)) + + self.nbr_of_results = len(results) + return results + + def feeling_lucky(self) -> str | None: + if self.batch: + first_entry = self.batch[0] + + # XXX the default view to the event should be doing the redirect + if first_entry.es_type_name == 'events': + return self.request.link(first_entry, 'latest') + else: + return self.request.link(first_entry) + return None + + @cached_property + def subset_count(self) -> int: + return self.available_results + + def suggestions(self) -> tuple[str, ...]: + suggestions = [] + + for element in self.generic_search(): + if element.es_type_name == 'files': + continue + suggest = getattr(element, 'es_suggestion', '') + if isinstance(suggest, tuple): + suggest = suggest[0] + suggestions.append(suggest) + + return tuple(suggestions[:15]) diff --git a/src/onegov/org/models/ticket.py b/src/onegov/org/models/ticket.py index 7871c7adea..f9cad7af92 100644 --- a/src/onegov/org/models/ticket.py +++ b/src/onegov/org/models/ticket.py @@ -1,5 +1,6 @@ from functools import cached_property from markupsafe import Markup + from onegov.chat.collections import ChatCollection from onegov.core.templates import render_macro from onegov.directory import Directory, DirectoryEntry @@ -14,11 +15,11 @@ from onegov.ticket import Ticket, Handler, handlers from onegov.search.utils import extract_hashtags from purl import URL -from sqlalchemy import desc +from sqlalchemy import desc, select from sqlalchemy import func +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import object_session - from typing import Any, TYPE_CHECKING if TYPE_CHECKING: from onegov.chat.models import Chat @@ -29,6 +30,7 @@ from onegov.ticket.handler import _Q from sqlalchemy import Column from sqlalchemy.orm import Query, Session + from sqlalchemy.sql import ClauseElement from uuid import UUID @@ -48,6 +50,8 @@ class OrgTicketMixin: """ + _cached_extra_localized_text: str + if TYPE_CHECKING: number: Column[str] group: Column[str] @@ -67,7 +71,7 @@ def reference(self, request: 'OrgRequest') -> str: def reference_group(self, request: 'OrgRequest') -> str: return request.translate(self.group) - @cached_property + @hybrid_property def extra_localized_text(self) -> str: # extracts of attachments are currently not searchable - if they were @@ -87,8 +91,18 @@ def extra_localized_text(self) -> str: q = q.filter_by(channel_id=self.number) q = q.filter(Message.type.in_(('ticket_note', 'ticket_chat'))) q = q.with_entities(Message.text) + result = ' '.join(n.text for n in q if n.text) - return ' '.join(n.text for n in q if n.text) + return result + + @extra_localized_text.expression # type:ignore[no-redef] + def extra_localized_text(cls) -> 'ClauseElement': + return ( + select([func.string_agg(Message.text, ' ')]) + .where(Message.channel_id == cls.number) + .where(Message.type.in_(('ticket_note', 'ticket_chat'))) + .label('extra_localized_text') + ) @property def es_tags(self) -> list[str] | None: diff --git a/src/onegov/org/path.py b/src/onegov/org/path.py index 8733ddcc0f..46b34ce6f5 100644 --- a/src/onegov/org/path.py +++ b/src/onegov/org/path.py @@ -64,7 +64,7 @@ from onegov.org.models import ResourcePersonMove from onegov.org.models import ResourceRecipient from onegov.org.models import ResourceRecipientCollection -from onegov.org.models import Search +from onegov.org.models import Search, SearchPostgres from onegov.org.models import SiteCollection from onegov.org.models import TicketNote from onegov.org.models import Topic @@ -743,6 +743,16 @@ def get_search( return Search(request, q, page) +@OrgApp.path(model=SearchPostgres, path='/search-postgres', + converters={'page': int}) +def get_search_postgres( + request: 'OrgRequest', + q: str = '', + page: int = 0 +) -> SearchPostgres[Any]: + return SearchPostgres(request, q, page) + + @OrgApp.path(model=AtoZPages, path='/a-z') def get_a_to_z(request: 'OrgRequest') -> AtoZPages: return AtoZPages(request) diff --git a/src/onegov/org/templates/search.pt b/src/onegov/org/templates/search.pt index d6cad352a7..d1928703de 100644 --- a/src/onegov/org/templates/search.pt +++ b/src/onegov/org/templates/search.pt @@ -17,8 +17,7 @@
Your search returned no results.
+ +