Source code for org.layout

import babel.dates
import re

from babel import Locale
from datetime import date, datetime, time, timedelta
from dateutil import rrule
from dateutil.rrule import rrulestr
from decimal import Decimal
from functools import cached_property
from markupsafe import Markup
from onegov.chat import TextModuleCollection
from onegov.core.crypto import RANDOM_TOKEN_LENGTH
from onegov.core.custom import json
from onegov.core.elements import Block, Button, Confirm, Intercooler
from onegov.core.elements import Link, LinkGroup
from onegov.form.collection import SurveyCollection
from onegov.org.elements import QrCodeLink, IFrameLink
from onegov.core.i18n import SiteLocale
from onegov.core.layout import ChameleonLayout
from onegov.core.static import StaticFile
from onegov.core.utils import linkify, paragraphify
from onegov.directory import DirectoryCollection
from onegov.event import OccurrenceCollection
from onegov.file import File
from onegov.form import FormCollection, as_internal_id
from onegov.newsletter import NewsletterCollection, RecipientCollection
from onegov.org import _
from onegov.org import utils
from onegov.org.exports.base import OrgExport
from onegov.org.models import ExportCollection, Editor
from onegov.org.models import GeneralFileCollection
from onegov.org.models import ImageFile
from onegov.org.models import ImageFileCollection
from onegov.org.models import ImageSetCollection
from onegov.org.models import News
from onegov.org.models import PageMove
from onegov.org.models import PersonMove
from onegov.org.models import PublicationCollection
from onegov.org.models import ResourceRecipientCollection
from onegov.org.models import Search
from onegov.org.models import SiteCollection
from onegov.org.models.directory import ExtendedDirectoryEntryCollection
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.open_graph import OpenGraphMixin
from onegov.org.theme.org_theme import user_options
from onegov.org.utils import IMG_URLS
from onegov.pay import PaymentCollection, PaymentProviderCollection
from onegov.people import PersonCollection
from onegov.qrcode import QrCode
from onegov.reservation import ResourceCollection
from onegov.ticket import TicketCollection
from onegov.ticket.collection import ArchivedTicketCollection
from onegov.user import Auth, UserCollection, UserGroupCollection
from onegov.user.utils import password_reset_url
from sedate import to_timezone
from translationstring import TranslationString


from typing import overload, Any, TYPE_CHECKING
if TYPE_CHECKING:
    from chameleon import PageTemplateFile
    from collections.abc import Callable, Iterable, Iterator, Sequence
    from onegov.core.elements import Trait
    from onegov.core.elements import Link as BaseLink
    from onegov.core.orm.abstract import AdjacencyList
    from onegov.core.security.permissions import Intent
    from onegov.core.templates import MacrosLookup
    from onegov.directory import DirectoryEntryCollection
    from onegov.event import Event, Occurrence
    from onegov.form import FormDefinition, FormSubmission
    from onegov.form.models.definition import (
        SurveySubmission, SurveyDefinition)
    from onegov.org.models import (
        ExtendedDirectory, ExtendedDirectoryEntry, ImageSet, Organisation)
    from onegov.org.app import OrgApp
    from onegov.org.request import OrgRequest, PageMeta
    from onegov.reservation import Resource
    from onegov.ticket import Ticket
    from onegov.user import User, UserGroup
    from sedate.types import TzInfoOrName
    from typing import TypeVar
    from webob import Response
    from wtforms import Field

[docs] _T = TypeVar('_T')
[docs] capitalised_name = re.compile(r'[A-Z]{1}[a-z]+')
[docs] class Layout(ChameleonLayout, OpenGraphMixin): """ Contains methods to render a page inheriting from layout.pt. All pages inheriting from layout.pt rely on this class being present as 'layout' variable:: @OrgApp.html(model=Example, template='example.pt', permission=Public) def view_example(self, request): return { 'layout': DefaultLayout(self, request) } It is meant to be extended for different parts of the site. For example, the :class:`DefaultLayout` includes the top navigation defined by onegov.page. It's possible though to have a different part of the website use a completely different top navigation. For that, a new Layout class inheriting from this one should be added. """
[docs] app: 'OrgApp'
[docs] request: 'OrgRequest'
[docs] date_long_without_year_format = 'E d. MMMM'
[docs] datetime_long_without_year_format = 'E d. MMMM HH:mm'
[docs] datetime_short_format = 'E d.MM.Y HH:mm'
[docs] event_format = 'EEEE, d. MMMM YYYY'
[docs] event_short_format = 'EE d. MMMM YYYY'
[docs] isodate_format = 'y-M-d'
[docs] def has_model_permission(self, permission: type['Intent'] | None) -> bool: return self.request.has_permission(self.model, permission)
@property
[docs] def name(self) -> str: """ Takes the class name of the layout and generates a name which can be used as a class. """ return '-'.join( token.lower() for token in capitalised_name.findall( self.__class__.__name__ ) )
@property
[docs] def org(self) -> 'Organisation': """ An alias for self.request.app.org. """ return self.request.app.org
@property
[docs] def primary_color(self) -> str: return (self.org.theme_options or {}).get( 'primary-color', user_options['primary-color'])
@cached_property
[docs] def favicon_apple_touch_url(self) -> str | None: return self.app.org.favicon_apple_touch_url
@cached_property
[docs] def favicon_pinned_tab_safari_url(self) -> str | None: return self.app.org.favicon_pinned_tab_safari_url
@cached_property
[docs] def favicon_win_url(self) -> str | None: return self.app.org.favicon_win_url
@cached_property
[docs] def favicon_mac_url(self) -> str | None: return self.app.org.favicon_mac_url
@cached_property
[docs] def default_map_view(self) -> dict[str, Any]: return self.org.default_map_view or { 'lon': 8.30576869173879, 'lat': 47.05183585, 'zoom': 12 }
@cached_property
[docs] def svg(self) -> 'PageTemplateFile': return self.template_loader['svg.pt']
@cached_property
[docs] def font_awesome_path(self) -> str: return self.request.link(StaticFile( 'font-awesome/css/font-awesome.min.css', version=self.app.version ))
@cached_property
[docs] def sentry_init_path(self) -> str: static_file = StaticFile.from_application( self.app, 'sentry/js/sentry-init.js' ) return self.request.link(static_file)
[docs] def static_file_path(self, path: str) -> str: return self.request.link(StaticFile(path, version=self.app.version))
[docs] def with_hashtags(self, text: str | None) -> Markup | None: if text is None: return None return utils.hashtag_elements(self.request, text)
@cached_property
[docs] def page_id(self) -> str: """ Returns the unique page id of the rendered page. Used to have a useful id in the body element for CSS/JS. """ page_id = self.request.path_info assert page_id is not None page_id = page_id.lstrip('/') page_id = page_id.replace('/', '-') page_id = page_id.replace('+', '') page_id = page_id.rstrip('-') return 'page-' + (page_id or 'root')
@cached_property
[docs] def body_classes(self) -> 'Iterator[str]': """ Yields a list of body classes used on the body. """ if self.request.is_logged_in: yield 'is-logged-in' yield 'role-{}'.format(self.request.current_role) else: yield 'is-logged-out' yield self.name
@cached_property
[docs] def top_navigation(self) -> 'Sequence[Link] | None': """ Returns a list of :class:`onegov.org.elements.Link` objects. Those links are used for the top navigation. If nothing is returned, no top navigation is displayed. """ return None
@cached_property
[docs] def breadcrumbs(self) -> 'Sequence[Link] | None': """ Returns a list of :class:`onegov.org.elements.Link` objects. Those links are used for the breadcrumbs. If nothing is returned, no top breadcrumbs are displayed. """ return None
@cached_property @cached_property @cached_property
[docs] def locales(self) -> list[tuple[str, str]]: to = self.request.url def get_name(locale: str) -> str: language_name = Locale.parse(locale).get_language_name() if language_name is None: # fallback to just the locale name return locale return language_name.capitalize() def get_link(locale: str) -> str: return SiteLocale(locale).link(self.request, to) return [ (get_name(locale), get_link(locale)) for locale in sorted(self.app.locales) ]
@cached_property
[docs] def file_upload_url(self) -> str: """ Returns the url to the file upload action. """ url = self.request.link( GeneralFileCollection(self.request.session), name='upload' ) return self.csrf_protected_url(url)
@cached_property
[docs] def file_upload_json_url(self) -> str: """ Adds the json url for file uploads. """ url = self.request.link( GeneralFileCollection(self.request.session), name='upload.json' ) return self.csrf_protected_url(url)
@cached_property
[docs] def file_list_url(self) -> str: """ Adds the json url for file lists. """ return self.request.link( GeneralFileCollection(self.request.session), name='json' )
@cached_property
[docs] def image_upload_url(self) -> str: """ Returns the url to the image upload action. """ url = self.request.link( ImageFileCollection(self.request.session), name='upload' ) return self.csrf_protected_url(url)
@cached_property
[docs] def image_upload_json_url(self) -> str: """ Adds the json url for image uploads. """ url = self.request.link( ImageFileCollection(self.request.session), name='upload.json' ) return self.csrf_protected_url(url)
@cached_property
[docs] def image_list_url(self) -> str: """ Adds the json url for image lists. """ return self.request.link( ImageFileCollection(self.request.session), name='json' )
@cached_property
[docs] def sitecollection_url(self) -> str: """ Adds the json url for internal links lists. """ return self.request.link(SiteCollection(self.request.session))
@cached_property
[docs] def homepage_url(self) -> str: """ Returns the url to the main page. """ return self.request.link(self.app.org)
@cached_property
[docs] def search_url(self) -> str: """ Returns the url to the search page. """ return self.request.class_link(Search)
@cached_property
[docs] def suggestions_url(self) -> str: """ Returns the url to the suggestions json view. """ return self.request.class_link(Search, name='suggest')
@cached_property
[docs] def events_url(self) -> str: return self.request.link( OccurrenceCollection(self.request.session) )
@cached_property
[docs] def directories_url(self) -> str: return self.request.link( DirectoryCollection(self.request.session) )
@cached_property
[docs] def news_url(self) -> str: return self.request.class_link(News, {'absorb': ''})
@cached_property
[docs] def newsletter_url(self) -> str: return self.request.class_link(NewsletterCollection)
[docs] def login_to_url(self, to: str | None, skip: bool = False) -> str: auth = Auth.from_request(self.request, to=to, skip=skip) return self.request.link(auth, 'login')
[docs] def login_from_path(self) -> str: auth = Auth.from_request_path(self.request) return self.request.link(auth, name='login')
[docs] def export_formatter(self, format: str) -> 'Callable[[object], Any]': """ Returns a formatter function which takes a value and returns the value ready for export. """ def is_daterange_list( value: object, datetype: type[object] | tuple[type[object], ...] ) -> bool: if isinstance(value, (list, tuple)): return all(is_daterange(v, datetype) for v in value) return False def is_daterange( value: object, datetype: type[object] | tuple[type[object], ...] ) -> bool: if isinstance(value, (list, tuple)): if len(value) == 2: if all(isinstance(v, datetype) for v in value): return True return False def default(value: object) -> Any: if isinstance(value, Decimal): return float(value) if isinstance(value, (date, datetime)): return value.isoformat() if isinstance(value, time): return f'{value.hour}:{value.minute}' # FIXME: Why not isinstance(value, TranslationString)? if hasattr(value, 'domain'): return self.request.translator(value) # type:ignore[arg-type] if isinstance(value, str): return '\n'.join(value.splitlines()) # normalize newlines if isinstance(value, (list, tuple)): return tuple(formatter(v) for v in value) return value if format in ('xlsx', 'csv'): # FIXME: For some reason TypeGuard wasn't working so I changed # value from object to Any def formatter(value: Any) -> Any: if is_daterange_list(value, (date, datetime)): return '\n'.join(formatter(v) for v in value) if is_daterange(value, datetime): return ' - '.join( self.format_date(v, 'datetime') for v in value) if is_daterange(value, date): return ' - '.join( self.format_date(v, 'date') for v in value) if isinstance(value, datetime): return self.format_date(value, 'datetime') if isinstance(value, date): return self.format_date(value, 'date') if isinstance(value, (list, tuple)): return '\n'.join(formatter(v) for v in value) if isinstance(value, bool): value = value and _('Yes') or _('No') if isinstance(value, dict): return value and json.dumps(value) or None return default(value) else: formatter = default return formatter
[docs] def thumbnail_url(self, url: str | None) -> str | None: """ Takes the given url and returns the thumbnail url for it. Uses some rough heuristic to determine if a url is actually served by onegov.file or not. May possibly fail. """ if not url or '/storage/' not in url: return url image_id = url.split('/storage/')[-1] # image file ids are generated from the random_token function if len(image_id) == RANDOM_TOKEN_LENGTH: return self.request.class_link( ImageFile, {'id': image_id}, name='thumbnail') else: return url
@property
[docs] def include_editor(self) -> None: self.request.include('redactor') self.request.include('editor')
[docs] def include_code_editor(self) -> None: self.request.include('code_editor')
[docs] def file_data_file( self, file_data: dict[str, Any] | None ) -> File | None: if file_data is None: return None if (ref := file_data.get('data', '')).startswith('@'): return self.request.session.query(File).filter_by( id=ref.lstrip('@')).first() return None
[docs] def field_file(self, field: 'Field') -> list[File | None] | File | None: if field.type == 'UploadField': return self.file_data_file(field.data) elif field.type == 'UploadMultipleField': return [ self.file_data_file(file_data) for file_data in (field.data or []) ] return None
@cached_property
[docs] def move_person_url_template(self) -> str: assert isinstance(self.model, PersonLinkExtension) implementation = PersonMove.get_implementation(self.model) return self.csrf_protected_url(self.request.class_link( implementation, { 'subject': '{subject_id}', 'target': '{target_id}', 'direction': '{direction}', 'key': PersonMove.get_key(self.model) } ))
[docs] def get_user_color(self, username: str) -> str: return utils.get_user_color(username)
[docs] def get_user_title(self, username: str) -> str: user = UserCollection(self.request.session).by_username(username) return user and user.title or username
[docs] def to_timezone( self, date: datetime, timezone: 'TzInfoOrName' ) -> datetime: return to_timezone(date, timezone)
[docs] def format_time_range( self, start: datetime | time, end: datetime | time ) -> str: time_range = utils.render_time_range(start, end) if time_range in ('00:00 - 24:00', '00:00 - 23:59'): return self.request.translate(_('all day')) return time_range
[docs] def format_date_range( self, start: date | datetime, end: date | datetime ) -> str: if start == end: return self.format_date(start, 'date') else: return ' - '.join(( self.format_date(start, 'date'), self.format_date(end, 'date') ))
[docs] def format_datetime_range( self, start: datetime, end: datetime, with_year: bool = False ) -> str: if start.date() == end.date() or ( (end - start) <= timedelta(hours=23) and end.time() < time(6, 0) ): show_single_day = True else: show_single_day = False if show_single_day: fmt: str = with_year and 'date_long' or 'date_long_without_year' return ( f'{self.format_date(start, fmt)} ' f'{self.format_time_range(start, end)}' ) else: fmt = with_year and 'datetime_long' or 'datetime_long_without_year' return ( f'{self.format_date(start, fmt)} - ' f'{self.format_date(end, fmt)}' )
[docs] def format_timedelta(self, delta: timedelta) -> str: return babel.dates.format_timedelta( delta=delta, locale=self.request.locale )
[docs] def format_seconds(self, seconds: float) -> str: return self.format_timedelta(timedelta(seconds=seconds))
[docs] def password_reset_url(self, user: 'User | None') -> str | None: if not user: return None return password_reset_url( user, self.request, self.request.class_link(Auth, name='reset-password') )
@overload
[docs] def linkify(self, text: str) -> Markup: ...
@overload def linkify(self, text: None) -> None: ... def linkify(self, text: str | None) -> Markup | None: if text is None: return None if isinstance(text, TranslationString): # translate the text before applying linkify if it's a # translation string text = self.request.translate(text) linkified = linkify(text) if isinstance(text, Markup): return linkified return linkified.replace('\n', Markup('<br>'))
[docs] def linkify_field(self, field: 'Field', rendered: Markup) -> Markup: include = ('TextAreaField', 'StringField', 'EmailField', 'URLField') if field.render_kw: if field.render_kw.get('data-editor') == 'markdown': return rendered # HtmlField if field.render_kw.get('class_') == 'editor': return rendered if field.type in include: return self.linkify(rendered) return rendered
@property
[docs] file_extension_fa_icon_mapping = { 'pdf': 'fa-file-pdf', 'jpg': 'fa-file-image', 'jpeg': 'fa-file-image', 'png': 'fa-file-image', 'img': 'fa-file-image', 'ico': 'fa-file-image', 'svg': 'fa-file-image', 'bmp': 'fa-file-image', 'gif': 'fa-file-image', 'tiff': 'fa-file-image', 'ogg': 'fa-file-music', 'wav': 'fa-file-music', 'mpa': 'fa-file-music', 'mp3': 'fa-file-music', 'avi': 'fa-file-video', 'mp4': 'fa-file-video', 'mpg': 'fa-file-video', 'mpeg': 'fa-file-video', 'mov': 'fa-file-video', 'vid': 'fa-file-video', 'webm': 'fa-file-video', 'zip': 'fa-file-zip', '7z': 'fa-file-zip', 'rar': 'fa-file-zip', 'pkg': 'fa-file-zip', 'tar.gz': 'fa-file-zip', 'txt': 'fa-file-alt', 'log': 'fa-file-alt', 'csv': 'fas fa-file-csv', # hack: csv icon is a pro-icon 'xls': 'fa-file-excel', 'xlsx': 'fa-file-excel', 'xlsm': 'fa-file-excel', 'ods': 'fa-file-excel', 'odt': 'fa-file-word', 'doc': 'fa-file-word', 'docx': 'fa-file-word', 'pptx': 'fa-file-powerpoint', }
[docs] def get_fa_file_icon(self, filename: str) -> str: """ Returns the font awesome file icon name for the given file according its extension. NOTE: Currently, org and town6 are using different font awesome versions, hence this only works for town6. """ default_icon = 'fa-file' if '.' not in filename: return default_icon ext = filename.split('.')[1].lower() return self.file_extension_fa_icon_mapping.get(ext, default_icon)
[docs] class DefaultLayoutMixin: if TYPE_CHECKING: # forward declare required attributes
[docs] model: Any
request: OrgRequest
[docs] def hide_from_robots(self) -> None: """ Returns a X-Robots-Tag:noindex header on secret pages. This is probably not where you would expect this to happen, but it ensures that this works on all pages without having to jump through hoops. """ if not hasattr(self.model, 'access'): return if self.model.access not in ('secret', 'secret_mtan'): return @self.request.after def respond_with_no_index(response: 'Response') -> None: response.headers['X-Robots-Tag'] = 'noindex'
[docs] class DefaultLayout(Layout, DefaultLayoutMixin): """ The default layout meant for the public facing parts of the site. """
[docs] request: 'OrgRequest'
[docs] edit_mode: bool
def __init__(self, model: Any, request: 'OrgRequest', edit_mode: bool = False) -> None: super().__init__(model, request) self.edit_mode = edit_mode # always include the common js files self.request.include('common') self.request.include('chosen') # always include the map components self.request.include(self.org.geo_provider) if self.request.is_manager: self.request.include('sortable') self.request.include('websockets') self.custom_body_attributes['data-websocket-endpoint'] = ( self.app.websockets_client_url(request)) self.custom_body_attributes['data-websocket-schema'] = ( self.app.schema) self.custom_body_attributes['data-websocket-channel'] = ( self.app.websockets_private_channel) if self.org.open_files_target_blank: self.request.include('all_blank') self.hide_from_robots()
[docs] def show_label(self, field: 'Field') -> bool: return True
@cached_property
[docs] def breadcrumbs(self) -> 'Sequence[Link] | None': """ Returns the breadcrumbs for the current page. """ return [Link(_('Homepage'), self.homepage_url)]
[docs] def exclude_invisible(self, items: 'Iterable[_T]') -> 'Sequence[_T]': items = self.request.exclude_invisible(items) if not self.request.is_manager: return tuple(i for i in items if getattr(i, 'published', True)) return items
@property
[docs] def root_pages(self) -> tuple['PageMeta', ...]: return self.request.root_pages
@cached_property
[docs] def top_navigation(self) -> 'Sequence[Link] | None': return tuple( Link(r.title, r.link(self.request)) for r in self.root_pages )
@cached_property
[docs] def qr_endpoint(self) -> str: return self.request.class_link(QrCode)
@cached_property
[docs] class DefaultMailLayoutMixin: if TYPE_CHECKING: # forward declare required attributes
[docs] request: OrgRequest
@property def org(self) -> Organisation: ...
[docs] def paragraphify(self, text: str) -> Markup: return paragraphify(text)
[docs] class DefaultMailLayout(Layout, DefaultMailLayoutMixin): # type:ignore[misc] """ A special layout for creating HTML E-Mails. """ @cached_property
[docs] def base(self) -> 'PageTemplateFile': return self.template_loader['mail_layout.pt']
@cached_property
[docs] def macros(self) -> 'MacrosLookup': return self.template_loader.mail_macros
@cached_property
[docs] def contact_html(self) -> Markup: """ Returns the contacts html, but instead of breaking it into multiple lines (like on the site footer), this version puts it all on one line. """ lines = (l.strip() for l in self.org.meta['contact'].splitlines()) lines = (l for l in lines if l) return linkify(', '.join(lines))
[docs] class AdjacencyListMixin: """ Provides layouts for models inheriting from :class:`onegov.core.orm.abstract.AdjacencyList` """ if TYPE_CHECKING:
[docs] model: AdjacencyList
request: OrgRequest def csrf_protected_url(self, url: str) -> str: ... @property def homepage_url(self) -> str: ... @cached_property
[docs] def sortable_url_template(self) -> str: return self.csrf_protected_url( self.request.class_link( PageMove, { 'subject_id': '{subject_id}', 'target_id': '{target_id}', 'direction': '{direction}' } ) )
[docs] def get_breadcrumbs(self, item: 'AdjacencyList') -> 'Iterator[Link]': """ Yields the breadcrumbs for the given adjacency list item. """ yield Link(_('Homepage'), self.homepage_url) if item: for ancestor in item.ancestors: yield Link(ancestor.title, self.request.link(ancestor)) yield Link(item.title, self.request.link(item))
[docs] def get_sidebar( self, type: str | None = None ) -> 'Iterator[Link | LinkGroup]': """ Yields the sidebar for the given adjacency list item. """ query = self.model.siblings.filter(self.model.__class__.type == type) def filter( items: 'Iterable[AdjacencyList]' ) -> 'Sequence[AdjacencyList]': items = self.request.exclude_invisible(items) if not self.request.is_manager: return tuple(i for i in items if getattr(i, 'published', True)) return items items = filter(query.all()) for item in items: if item != self.model: yield Link(item.title, self.request.link(item), model=item) else: children = ( Link(c.title, self.request.link(c), model=c) for c in filter(self.model.children) ) yield LinkGroup( title=item.title, links=tuple(children), model=item )
[docs] class AdjacencyListLayout(DefaultLayout, AdjacencyListMixin):
[docs] request: 'OrgRequest'
[docs] class SettingsLayout(DefaultLayout): def __init__( self, model: Any, request: 'OrgRequest', setting: str | None = None ) -> None: super().__init__(model, request) self.include_editor() self.include_code_editor() self.request.include('tags-input')
[docs] self.setting = setting
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: bc = [ Link(_('Homepage'), self.homepage_url), Link(_('Settings'), self.request.link(self.org, 'settings')) ] if self.setting: bc.append(Link(_(self.setting), '#')) return bc
[docs] class PageLayout(AdjacencyListLayout): @cached_property
[docs] def og_image_source(self) -> str | None: if not self.model.text: return super().og_image_source for url in IMG_URLS.findall(self.model.text) or []: if self.is_internal(url): return url return super().og_image_source
@cached_property
[docs] def breadcrumbs(self) -> 'Sequence[Link]': return tuple(self.get_breadcrumbs(self.model))
@cached_property
[docs] class NewsLayout(AdjacencyListLayout): @cached_property
[docs] def og_image_source(self) -> str | None: if not self.model.text: return super().og_image_source for url in IMG_URLS.findall(self.model.text) or []: if self.is_internal(url): return url return super().og_image_source
@cached_property
[docs] def breadcrumbs(self) -> 'Sequence[Link]': return tuple(self.get_breadcrumbs(self.model))
# FIXME: This layout is a little bit too lax about the model type # but without intersections this will be annoying to type
[docs] class EditorLayout(AdjacencyListLayout): def __init__( self, model: Editor, request: 'OrgRequest', site_title: str | None ) -> None: super().__init__(model, request)
[docs] self.site_title = site_title
self.include_editor()
[docs] self.edit_mode = True
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: links = list(self.get_breadcrumbs(self.model.page)) links.append(Link(self.site_title, url='#')) return links
[docs] class FormEditorLayout(DefaultLayout):
[docs] model: ('FormDefinition | FormCollection | SurveyCollection' '| SurveyDefinition')
def __init__( self, model: ('FormDefinition | FormCollection | SurveyCollection' '| SurveyDefinition'), request: 'OrgRequest' ) -> None: super().__init__(model, request) self.include_editor() self.include_code_editor()
[docs] class FormSubmissionLayout(DefaultLayout):
[docs] model: 'FormSubmission | FormDefinition'
def __init__( self, model: 'FormSubmission | FormDefinition', request: 'OrgRequest', title: str | None = None ) -> None: super().__init__(model, request) self.include_code_editor()
[docs] self.title = title or self.form.title
@cached_property
[docs] def form(self) -> 'FormDefinition': if hasattr(self.model, 'form'): return self.model.form # type:ignore[return-value] else: return self.model
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: collection = FormCollection(self.request.session) return [ Link(_('Homepage'), self.homepage_url), Link(_('Forms'), self.request.link(collection)), Link(self.title, self.request.link(self.model)) ]
@cached_property
[docs] def can_delete_form(self) -> bool: return all( submission_deletable(submission, self.request.session) for submission in self.form.submissions )
@cached_property
[docs] class FormCollectionLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Forms'), '#') ]
@property
[docs] def external_forms(self) -> ExternalLinkCollection: return ExternalLinkCollection(self.request.session)
@property
[docs] def form_definitions(self) -> FormCollection: return FormCollection(self.request.session)
@property
[docs] class SurveySubmissionWindowLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: collection = SurveyCollection(self.request.session) return [ Link(_('Homepage'), self.homepage_url), Link(_('Surveys'), self.request.link(collection)), Link(self.model.survey.title, self.request.link(self.model.survey) ), Link(self.model.title, self.request.link(self.model)) ]
@property
[docs] class SurveySubmissionLayout(DefaultLayout):
[docs] model: 'SurveySubmission | SurveyDefinition'
def __init__( self, model: 'SurveySubmission | SurveyDefinition', request: 'OrgRequest', title: str | None = None ) -> None: super().__init__(model, request) self.include_code_editor()
[docs] self.title = title or self.form.title
@cached_property
[docs] def form(self) -> 'SurveyDefinition': if hasattr(self.model, 'survey'): return self.model.survey # type:ignore[return-value] else: return self.model
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: collection = SurveyCollection(self.request.session) return [ Link(_('Homepage'), self.homepage_url), Link(_('Surveys'), self.request.link(collection)), Link(self.title, self.request.link(self.model)) ]
@cached_property
[docs] class SurveyCollectionLayout(DefaultLayout): @property
[docs] def survey_definitions(self) -> SurveyCollection: return SurveyCollection(self.request.session)
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Surveys'), '#') ]
@property
[docs] class PersonCollectionLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('People'), '#') ]
@cached_property
[docs] class PersonLayout(DefaultLayout): @cached_property
[docs] def collection(self) -> PersonCollection: return PersonCollection(self.request.session)
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('People'), self.request.link(self.collection)), Link(_(self.model.title), self.request.link(self.model)) ]
@cached_property
[docs] class TicketsLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Tickets'), '#') ]
[docs] class ArchivedTicketsLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Tickets'), '#') ]
@cached_property
[docs] class TicketLayout(DefaultLayout):
[docs] model: 'Ticket'
def __init__(self, model: 'Ticket', request: 'OrgRequest') -> None: super().__init__(model, request) self.request.include('timeline') @cached_property
[docs] def collection(self) -> TicketCollection: return TicketCollection(self.request.session)
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Tickets'), self.request.link(self.collection)), Link(self.model.number, '#') ]
@cached_property @cached_property
[docs] def has_submission_files(self) -> bool: submission = getattr(self.model.handler, 'submission', None) return submission is not None and bool(submission.files)
[docs] class TicketNoteLayout(DefaultLayout):
[docs] ticket: 'Ticket'
@overload def __init__( self, model: 'Ticket', request: 'OrgRequest', title: str, ticket: None = None ) -> None: ... @overload def __init__( self, model: Any, request: 'OrgRequest', title: str, ticket: 'Ticket' ) -> None: ... def __init__( self, model: Any, request: 'OrgRequest', title: str, ticket: 'Ticket | None' = None ) -> None: super().__init__(model, request)
[docs] self.title = title
self.ticket = ticket or model @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Tickets'), self.request.link( TicketCollection(self.request.session) )), Link(self.ticket.number, self.request.link(self.ticket)), Link(self.title, '#') ]
# FIXME: Something about this layout is really broken, since it clearly # expects a Ticket as the first argument, but we sometimes pass # it a Reservation instead, also we never seem to be using internal # breadcrumbs, which are broken, because they were using a non-existant # ticket attribute, much akin to TicketNoteLayout
[docs] class TicketChatMessageLayout(DefaultLayout):
[docs] model: 'Ticket'
def __init__( self, model: 'Ticket', request: 'OrgRequest', internal: bool = False ) -> None: super().__init__(model, request)
[docs] self.internal = internal
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return ( self.internal_breadcrumbs if self.internal else self.public_breadcrumbs )
@property
[docs] def internal_breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Tickets'), self.request.link( TicketCollection(self.request.session) )), Link(self.model.number, self.request.link(self.model)), Link(_('New Message'), '#') ]
@property
[docs] def public_breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Ticket Status'), self.request.link(self.model, 'status')), Link(_('New Message'), '#') ]
[docs] class TextModulesLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Text modules'), '#') ]
@cached_property
[docs] class TextModuleLayout(DefaultLayout): @cached_property
[docs] def collection(self) -> TextModuleCollection: return TextModuleCollection(self.request.session)
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Text modules'), self.request.link(self.collection)), Link(self.model.name, self.request.link(self.model)) ]
@cached_property
[docs] class ResourcesLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Reservations'), self.request.link(self.model)) ]
@property
[docs] def external_resources(self) -> ExternalLinkCollection: return ExternalLinkCollection(self.request.session)
@property
[docs] def resources_url(self) -> str: return self.request.class_link(ResourceCollection)
@cached_property
[docs] class FindYourSpotLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link( _('Homepage'), self.homepage_url ), Link( _('Reservations'), self.request.class_link(ResourceCollection) ), Link( _('Find Your Spot'), self.request.link(self.model) ) ]
[docs] class ResourceRecipientsLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link( _('Homepage'), self.homepage_url ), Link( _('Reservations'), self.request.class_link(ResourceCollection) ), Link( _('Notifications'), self.request.link(self.model) ) ]
@cached_property
[docs] class ResourceRecipientsFormLayout(DefaultLayout): def __init__(self, model: Any, request: 'OrgRequest', title: str) -> None: super().__init__(model, request)
[docs] self.title = title
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link( _('Homepage'), self.homepage_url ), Link( _('Reservations'), self.request.class_link(ResourceCollection) ), Link( _('Notifications'), self.request.class_link( ResourceRecipientCollection ) ), Link(self.title, '#') ]
[docs] class ResourceLayout(DefaultLayout):
[docs] model: 'Resource'
def __init__(self, model: 'Resource', request: 'OrgRequest') -> None: super().__init__(model, request) self.request.include('fullcalendar') @cached_property
[docs] def collection(self) -> ResourceCollection: return ResourceCollection(self.request.app.libres_context)
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Reservations'), self.request.link(self.collection)), Link(_(self.model.title), self.request.link(self.model)) ]
@cached_property
[docs] class ReservationLayout(ResourceLayout):
[docs] class AllocationRulesLayout(ResourceLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Reservations'), self.request.link(self.collection)), Link(_(self.model.title), self.request.link(self.model)), Link(_('Rules'), '#') ]
@cached_property
[docs] class AllocationEditFormLayout(DefaultLayout): """ Same as the resource layout, but with different editbar links, because there's not really an allocation view, but there are allocation forms. """ @cached_property
[docs] def collection(self) -> ResourceCollection: return ResourceCollection(self.request.app.libres_context)
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Reservations'), self.request.link(self.collection)), Link(_('Edit allocation'), '#') ]
@cached_property
[docs] class EventLayoutMixin:
[docs] request: 'OrgRequest'
[docs] def format_recurrence(self, recurrence: str | None) -> str: """ Returns a human readable version of an RRULE used by us. """ # FIXME: We define a very similar constant in our forms, we should # move this to onegov.org.constants and use it for both. WEEKDAYS = ( # noqa: N806 _('Mo'), _('Tu'), _('We'), _('Th'), _('Fr'), _('Sa'), _('Su') ) if recurrence: rule = rrulestr(recurrence) # FIXME: Implement this without relying on internal attributes if getattr(rule, '_freq', None) == rrule.WEEKLY: return _( 'Every ${days} until ${end}', mapping={ 'days': ', '.join( self.request.translate(WEEKDAYS[day]) for day in rule._byweekday # type:ignore ), 'end': rule._until.date( # type:ignore ).strftime('%d.%m.%Y') } ) return ''
[docs] def event_deletable(self, event: 'Event') -> bool: tickets = TicketCollection(self.request.session) ticket = tickets.by_handler_id(event.id.hex) return not ticket
[docs] class OccurrencesLayout(DefaultLayout, EventLayoutMixin):
[docs] app: 'OrgApp'
[docs] request: 'OrgRequest'
@property
[docs] def og_description(self) -> str: return self.request.translate(_('Events'))
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Events'), self.request.link(self.model)) ]
@cached_property
[docs] class OccurrenceLayout(DefaultLayout, EventLayoutMixin):
[docs] app: 'OrgApp'
[docs] request: 'OrgRequest'
[docs] model: 'Occurrence'
def __init__(self, model: 'Occurrence', request: 'OrgRequest') -> None: super().__init__(model, request) self.request.include('monthly-view') @cached_property
[docs] def collection(self) -> OccurrenceCollection: return OccurrenceCollection(self.request.session)
@property
[docs] def og_description(self) -> str | None: return self.model.event.description
@cached_property
[docs] def og_image(self) -> File | None: return self.model.event.image or super().og_image
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Events'), self.request.link(self.collection)), Link(self.model.title, self.request.link(self.model)) ]
@cached_property
[docs] class EventLayout(EventLayoutMixin, DefaultLayout):
[docs] app: 'OrgApp'
[docs] request: 'OrgRequest'
[docs] model: 'Event'
if TYPE_CHECKING: def __init__(self, model: 'Event', request: 'OrgRequest') -> None: ... @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Events'), self.events_url), Link(self.model.title, self.request.link(self.model)), ]
@cached_property
[docs] class NewsletterLayout(DefaultLayout): @cached_property
[docs] def collection(self) -> NewsletterCollection: return NewsletterCollection(self.app.session())
@cached_property
[docs] def recipients(self) -> RecipientCollection: return RecipientCollection(self.app.session())
@cached_property
[docs] def is_collection(self) -> bool: return isinstance(self.model, NewsletterCollection)
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: if self.is_collection and self.view_name == 'new': return [ Link(_('Homepage'), self.homepage_url), Link(_('Newsletter'), self.request.link(self.collection)), Link(_('New'), '#') ] if self.is_collection and self.view_name == 'update': return [ Link(_('Homepage'), self.homepage_url), Link(_('Newsletter'), self.request.link(self.collection)), Link(_('Edit'), '#') ] elif self.is_collection: return [ Link(_('Homepage'), self.homepage_url), Link(_('Newsletter'), '#') ] else: return [ Link(_('Homepage'), self.homepage_url), Link(_('Newsletter'), self.request.link(self.collection)), Link(self.model.title, '#') ]
@cached_property
[docs] class RecipientLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Newsletter'), self.request.link( NewsletterCollection(self.app.session()) )), Link(_('Subscribers'), self.request.link(self.model)) ]
@cached_property
[docs] class ImageSetCollectionLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Photo Albums'), self.request.link(self.model)) ]
@cached_property
[docs] class ImageSetLayout(DefaultLayout):
[docs] model: 'ImageSet'
def __init__(self, model: 'ImageSet', request: 'OrgRequest') -> None: super().__init__(model, request) self.request.include('photoswipe') @property
[docs] def collection(self) -> ImageSetCollection: return ImageSetCollection(self.request.session)
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Photo Albums'), self.request.link(self.collection)), Link(self.model.title, self.request.link(self.model)) ]
@cached_property
[docs] class UserManagementLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Usermanagement'), self.request.class_link(UserCollection)) ]
@cached_property
[docs] class UserLayout(DefaultLayout): if TYPE_CHECKING:
[docs] model: User
def __init__(self, model: User, request: OrgRequest) -> None: ... @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Usermanagement'), self.request.class_link(UserCollection)), Link(self.model.title, self.request.link(self.model)) ]
@cached_property
[docs] class UserGroupCollectionLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('User groups'), self.request.link(self.model)) ]
@cached_property
[docs] class UserGroupLayout(DefaultLayout): if TYPE_CHECKING:
[docs] model: UserGroup
def __init__(self, model: UserGroup, request: OrgRequest) -> None: ... @cached_property
[docs] def collection(self) -> UserGroupCollection['UserGroup']: return UserGroupCollection(self.request.session)
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('User groups'), self.request.link(self.collection)), Link(self.model.name, self.request.link(self.model)) ]
@cached_property
[docs] class ExportCollectionLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Exports'), self.request.class_link(ExportCollection)) ]
[docs] class PaymentProviderLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Payment Providers'), self.request.class_link( PaymentProviderCollection )) ]
@cached_property
[docs] class PaymentCollectionLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Payments'), self.request.class_link( PaymentProviderCollection )) ]
@cached_property
[docs] class MessageCollectionLayout(DefaultLayout): def __init__(self, model: Any, request: 'OrgRequest') -> None: super().__init__(model, request) self.request.include('timeline') @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Timeline'), '#') ]
[docs] class DirectoryCollectionLayout(DefaultLayout):
[docs] model: 'DirectoryCollection[Any] | DirectoryEntryCollection[Any]'
def __init__( self, model: 'DirectoryCollection[Any] | DirectoryEntryCollection[Any]', request: 'OrgRequest' ) -> None: super().__init__(model, request) self.include_editor() self.include_code_editor() self.request.include('iconwidget') @property
[docs] def og_description(self) -> str: return self.request.translate(_('Directories'))
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Directories'), '#') ]
@cached_property
[docs] class DirectoryEntryMixin:
[docs] request: 'OrgRequest'
[docs] model: 'ExtendedDirectoryEntry | ExtendedDirectoryEntryCollection'
[docs] custom_body_attributes: dict[str, Any]
[docs] def init_markers(self) -> None: self.request.include('photoswipe') if self.directory.marker_color: self.custom_body_attributes['data-default-marker-color'] = ( self.directory.marker_color) if self.directory.marker_icon: self.custom_body_attributes['data-default-marker-icon'] = ( self.directory.marker_icon.encode('unicode-escape')[2:])
@property
[docs] def directory(self) -> 'ExtendedDirectory': return self.model.directory
@cached_property
[docs] def thumbnail_field_id(self) -> str | None: if thumbnail := self.directory.configuration.thumbnail: return as_internal_id(thumbnail) return None
[docs] def thumbnail_file_id(self, entry: 'ExtendedDirectoryEntry') -> str | None: thumbnail = self.thumbnail_field_id if not thumbnail: return None return (entry.values.get(thumbnail) or {}).get('data', '').lstrip('@')
[docs] def thumbnail_file(self, entry: 'ExtendedDirectoryEntry') -> File | None: file_id = self.thumbnail_file_id(entry) if not file_id: return None return self.request.session.query(File).filter_by(id=file_id).first()
[docs] class DirectoryEntryCollectionLayout(DefaultLayout, DirectoryEntryMixin):
[docs] request: 'OrgRequest'
[docs] model: ExtendedDirectoryEntryCollection
def __init__( self, model: ExtendedDirectoryEntryCollection, request: 'OrgRequest' ) -> None: super().__init__(model, request) self.init_markers() if self.directory.numbering == 'standard': self.custom_body_attributes['data-default-marker-icon'] = 'numbers' elif self.directory.numbering == 'custom': self.custom_body_attributes['data-default-marker-icon'] = 'custom' @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Directories'), self.request.class_link( DirectoryCollection )), Link(_(self.model.directory.title), self.request.class_link( ExtendedDirectoryEntryCollection, { 'directory_name': self.model.directory_name } )) ]
@cached_property @property
[docs] def publication_filters(self) -> dict[str, str]: if not self.request.is_logged_in: return {} if self.request.is_manager: return { 'published_only': _('Published'), 'upcoming_only': _('Upcoming'), 'past_only': _('Past'), } return { 'published_only': _('Published'), 'past_only': _('Past'), }
@property
[docs] def publication_filter_title(self) -> str: default_title = self.request.translate(_('Publication')) for filter in self.publication_filters: if filter in self.request.params: applied_title = self.request.translate( self.publication_filters[filter]) return f'{default_title}: {applied_title}' return f'{default_title}: {self.request.translate(_("Choose filter"))}'
@property
[docs] class DirectoryEntryLayout(DefaultLayout, DirectoryEntryMixin):
[docs] request: 'OrgRequest'
[docs] model: 'ExtendedDirectoryEntry'
def __init__( self, model: 'ExtendedDirectoryEntry', request: 'OrgRequest' ) -> None: super().__init__(model, request) self.init_markers()
[docs] def show_label(self, field: 'Field') -> bool: return field.id not in self.model.hidden_label_fields
@cached_property
[docs] def og_image(self) -> File | None: return self.thumbnail_file(self.model) or super().og_image
@property
[docs] def og_description(self) -> str | None: return self.directory.lead
@property
[docs] def thumbnail_field_ids(self) -> list[str]: return [ as_internal_id(e) for e in getattr( self.model.directory.configuration, 'show_as_thumbnails', []) or [] ]
@cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Directories'), self.request.class_link( DirectoryCollection )), Link(_(self.model.directory.title), self.request.class_link( ExtendedDirectoryEntryCollection, { 'directory_name': self.model.directory.name } )), Link(_(self.model.title), self.request.link(self.model)) ]
@overload
[docs] def linkify(self, text: str) -> Markup: ...
@overload def linkify(self, text: None) -> None: ... def linkify(self, text: str | None) -> Markup | None: linkified = super().linkify(text) return linkified.replace( '\\n', Markup('<br>')) if linkified else linkified @cached_property
[docs] class PublicationLayout(DefaultLayout): def __init__(self, model: Any, request: 'OrgRequest') -> None: super().__init__(model, request) self.request.include('filedigest') @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Publications'), self.request.class_link( PublicationCollection )) ]
[docs] class DashboardLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Dashboard'), '#') ]
[docs] class GeneralFileCollectionLayout(DefaultLayout): def __init__(self, model: Any, request: 'OrgRequest') -> None: request.include('common') request.include('upload') request.include('prompt') super().__init__(model, request)
[docs] class ImageFileCollectionLayout(DefaultLayout): def __init__(self, model: Any, request: 'OrgRequest') -> None: request.include('common') request.include('upload') request.include('editalttext') super().__init__(model, request)
[docs] class ExternalLinkLayout(DefaultLayout): @property
[docs] class HomepageLayout(DefaultLayout): @property @cached_property
[docs] def sortable_url_template(self) -> str: return self.csrf_protected_url( self.request.class_link( PageMove, { 'subject_id': '{subject_id}', 'target_id': '{target_id}', 'direction': '{direction}' } ) )