Source code for town6.layout

import secrets
from functools import cached_property

from onegov.core.elements import Confirm, Intercooler, Link, LinkGroup
from onegov.core.static import StaticFile
from onegov.core.utils import to_html_ul
from onegov.chat.collections import ChatCollection
from onegov.chat.models import Chat
from onegov.directory import DirectoryCollection
from onegov.form import FormCollection
from onegov.org.elements import QrCodeLink, IFrameLink
from onegov.org.layout import (
    Layout as OrgLayout,
    DefaultLayout as OrgDefaultLayout,
    DefaultMailLayout as OrgDefaultMailLayout,
    AdjacencyListLayout as OrgAdjacencyListLayout,
    AllocationEditFormLayout as OrgAllocationEditFormLayout,
    AllocationRulesLayout as OrgAllocationRulesLayout,
    ArchivedTicketsLayout as OrgArchivedTicketsLayout,
    DashboardLayout as OrgDashboardLayout,
    DirectoryCollectionLayout as OrgDirectoryCollectionLayout,
    DirectoryEntryCollectionLayout as OrgDirectoryEntryCollectionLayout,
    DirectoryEntryLayout as OrgDirectoryEntryLayout,
    EditorLayout as OrgEditorLayout,
    EventLayout as OrgEventLayout,
    ExportCollectionLayout as OrgExportCollectionLayout,
    ExternalLinkLayout as OrgExternalLinkLayout,
    FindYourSpotLayout as OrgFindYourSpotLayout,
    FormCollectionLayout as OrgFormCollectionLayout,
    SurveyCollectionLayout as OrgSurveyCollectionLayout,
    FormEditorLayout as OrgFormEditorLayout,
    FormSubmissionLayout as OrgFormSubmissionLayout,
    SurveySubmissionLayout as OrgSurveySubmissionLayout,
    SurveySubmissionWindowLayout as OrgSurveySubmissionWindowLayout,
    HomepageLayout as OrgHomepageLayout,
    ImageSetCollectionLayout as OrgImageSetCollectionLayout,
    ImageSetLayout as OrgImageSetLayout,
    MessageCollectionLayout as OrgMessageCollectionLayout,
    NewsLayout as OrgNewsLayout,
    NewsletterLayout as OrgNewsletterLayout,
    PageLayout as OrgPageLayout,
    PaymentCollectionLayout as OrgPaymentCollectionLayout,
    PaymentProviderLayout as OrgPaymentProviderLayout,
    PersonCollectionLayout as OrgPersonCollectionLayout,
    PersonLayout as OrgPersonLayout,
    PublicationLayout as OrgPublicationLayout,
    OccurrenceLayout as OrgOccurrenceLayout,
    OccurrencesLayout as OrgOccurrencesLayout,
    RecipientLayout as OrgRecipientLayout,
    ReservationLayout as OrgReservationLayout,
    ResourceLayout as OrgResourceLayout,
    ResourcesLayout as OrgResourcesLayout,
    ResourceRecipientsLayout as OrgResourceRecipientsLayout,
    ResourceRecipientsFormLayout as OrgResourceRecipientsFormLayout,
    SettingsLayout as OrgSettingsLayout,
    TextModuleLayout as OrgTextModuleLayout,
    TextModulesLayout as OrgTextModulesLayout,
    TicketChatMessageLayout as OrgTicketChatMessageLayout,
    TicketLayout as OrgTicketLayout,
    TicketNoteLayout as OrgTicketNoteLayout,
    TicketsLayout as OrgTicketsLayout,
    UserLayout as OrgUserLayout,
    UserGroupLayout as OrgUserGroupLayout,
    UserGroupCollectionLayout as OrgUserGroupCollectionLayout,
    UserManagementLayout as OrgUserManagementLayout)
from onegov.org.models import PageMove
from onegov.org.models.directory import ExtendedDirectoryEntryCollection
from onegov.page import PageCollection
from onegov.stepsequence import step_sequences
from onegov.stepsequence.extension import StepsLayoutExtension
from onegov.town6 import _
from onegov.town6.theme import user_options


from typing import Any, NamedTuple, TypeVar, TYPE_CHECKING
if TYPE_CHECKING:
    from collections.abc import Iterator
    from onegov.event import Event
    from onegov.form import FormDefinition, FormSubmission
    from onegov.form.models.definition import SurveyDefinition
    from onegov.form.models.submission import SurveySubmission
    from onegov.org.models import ExtendedDirectoryEntry
    from onegov.org.request import PageMeta
    from onegov.page import Page
    from onegov.reservation import Resource
    from onegov.ticket import Ticket
    from onegov.town6.app import TownApp
    from onegov.town6.request import TownRequest
    from typing import TypeAlias




[docs] T = TypeVar('T')
[docs] class PartnerCard(NamedTuple):
[docs] url: str | None
[docs] image_url: str | None
[docs] lead: str | None
[docs] class Layout(OrgLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
def __init__(self, model: Any, request: 'TownRequest', edit_mode: bool = False) -> None: super().__init__(model, request) self.request.include('foundation6')
[docs] self.edit_mode = edit_mode
@property
[docs] def primary_color(self) -> str: return (self.org.theme_options or {}).get( 'primary-color-ui', user_options['primary-color-ui'])
@cached_property
[docs] def font_awesome_path(self) -> str: return self.request.link(StaticFile( 'font-awesome5/css/all.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)
@cached_property
[docs] def drilldown_back(self) -> str: back = self.request.translate(_('back')) return ( '<li class="js-drilldown-back">' f'<a tabindex="0">{back}</a></li>' )
@property
[docs] def on_homepage(self) -> bool: return self.request.url == self.homepage_url
@property
[docs] def partners(self) -> list[PartnerCard]: partner_attrs = [key for key in dir(self.org) if 'partner' in key] partner_count = int(len(partner_attrs) / 3) return [ PartnerCard( url=url, image_url=image_url, lead=lead, ) for ix in range(1, partner_count + 1) if any(( (url := getattr(self.org, f'partner_{ix}_url')), (image_url := getattr(self.org, f'partner_{ix}_img')), (lead := getattr(self.org, f'partner_{ix}_name')), )) ]
@property
[docs] def show_partners(self) -> bool: if self.on_homepage: if '<partner' in (self.org.homepage_structure or ''): # The widget is rendered return False if self.org.always_show_partners and not self.request.is_admin: return True return False
@cached_property
[docs] def search_keybindings_help(self) -> str: return self.request.translate( _('Press ${shortcut} to open Search', mapping={'shortcut': 'Ctrl+Shift+F / Ctrl+Shift+S'}) )
@cached_property
[docs] def page_collection(self) -> PageCollection: return PageCollection(self.request.session)
[docs] def page_by_path(self, path: str) -> 'Page | None': return self.page_collection.by_path(path)
[docs] class DefaultLayout(OrgDefaultLayout, Layout): if TYPE_CHECKING:
[docs] app: TownApp
request: TownRequest def __init__(self, model: Any, request: TownRequest) -> None: ... @cached_property
[docs] def top_navigation(self) -> tuple['NavigationEntry', ...]: # type:ignore def yield_children(page: 'PageMeta') -> 'NavigationEntry': if page.type != 'news': children = tuple( yield_children(p) for p in page.children ) else: children = () return ( page, Link(page.title, page.link(self.request)), children ) return tuple(yield_children(page) for page in self.root_pages)
@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] class DefaultMailLayout(OrgDefaultMailLayout, Layout): """ A special layout for creating HTML E-Mails. """
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class AdjacencyListLayout(OrgAdjacencyListLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class SettingsLayout(OrgSettingsLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class PageLayout(OrgPageLayout, AdjacencyListLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
@cached_property
[docs] def contact_html(self) -> str: return self.model.get_contact_html(self.request) or to_html_ul( self.org.contact )
[docs] class NewsLayout(OrgNewsLayout, AdjacencyListLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
@cached_property
[docs] def contact_html(self) -> str: return self.model.get_contact_html(self.request) or to_html_ul( self.org.contact, convert_dashes=False )
[docs] class EditorLayout(OrgEditorLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class FormEditorLayout(OrgFormEditorLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
@step_sequences.registered_step( 1, _('Form'), cls_after='FormSubmissionLayout') @step_sequences.registered_step( 2, _('Check'), cls_before='FormSubmissionLayout', cls_after='TicketChatMessageLayout' ) @step_sequences.registered_step( 2, _('Check'), cls_before='DirectoryEntryCollectionLayout', cls_after='TicketChatMessageLayout') @step_sequences.registered_step( 2, _('Check'), cls_before='EventLayout', cls_after='TicketChatMessageLayout') @step_sequences.registered_step( 2, _('Check'), cls_before='DirectoryEntryLayout', cls_after='TicketChatMessageLayout' )
[docs] class FormSubmissionLayout( StepsLayoutExtension, OrgFormSubmissionLayout, DefaultLayout ):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] model: 'FormSubmission | FormDefinition'
if TYPE_CHECKING: def __init__( self, model: 'FormSubmission | FormDefinition', request: 'TownRequest', title: str | None = None, *, hide_steps: bool = False ) -> None: ... @property
[docs] def step_position(self) -> int | None: if self.request.view_name in ('send-message',): return None if self.model.__class__.__name__ == 'CustomFormDefinition': return 1 return 2
[docs] class SurveySubmissionLayout( StepsLayoutExtension, OrgSurveySubmissionLayout, DefaultLayout ):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] model: 'SurveySubmission | SurveyDefinition'
if TYPE_CHECKING: def __init__( self, model: 'SurveySubmission | SurveyDefinition', request: 'TownRequest', title: str | None = None, *, hide_steps: bool = False ) -> None: ... @property
[docs] def step_position(self) -> int | None: if self.request.view_name in ('send-message',): return None if self.model.__class__.__name__ == 'SurveyDefinition': return 1 return 2
[docs] class FormCollectionLayout(OrgFormCollectionLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
@property
[docs] def forms_url(self) -> str: return self.request.class_link(FormCollection)
[docs] class SurveySubmissionWindowLayout(OrgSurveySubmissionWindowLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class SurveyCollectionLayout(OrgSurveyCollectionLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class PersonCollectionLayout(OrgPersonCollectionLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class PersonLayout(OrgPersonLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class TicketsLayout(OrgTicketsLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class ArchivedTicketsLayout(OrgArchivedTicketsLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class TicketLayout(OrgTicketLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
@cached_property
[docs] class TicketNoteLayout(OrgTicketNoteLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
@step_sequences.registered_step( 3, _('Confirmation'), cls_before='FormSubmissionLayout') @step_sequences.registered_step( 3, _('Confirmation'), cls_before='EventLayout') @step_sequences.registered_step( 3, _('Confirmation'), cls_before='ReservationLayout')
[docs] class TicketChatMessageLayout( StepsLayoutExtension, OrgTicketChatMessageLayout, DefaultLayout ):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
if TYPE_CHECKING: def __init__( self, model: 'Ticket', request: 'TownRequest', internal: bool = False, *, hide_steps: bool = False, ) -> None: ... @property
[docs] def step_position(self) -> int: return 3
[docs] class TextModulesLayout(OrgTextModulesLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class TextModuleLayout(OrgTextModuleLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class ResourcesLayout(OrgResourcesLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class FindYourSpotLayout(OrgFindYourSpotLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class ResourceRecipientsLayout(OrgResourceRecipientsLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class ResourceRecipientsFormLayout( OrgResourceRecipientsFormLayout, DefaultLayout ):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class ResourceLayout(OrgResourceLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
@step_sequences.registered_step( 1, _('Form'), cls_after='ReservationLayout') @step_sequences.registered_step( 2, _('Check'), cls_before='ReservationLayout', cls_after='TicketChatMessageLayout')
[docs] class ReservationLayout( StepsLayoutExtension, OrgReservationLayout, ResourceLayout ):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
if TYPE_CHECKING: def __init__( self, model: Resource, request: TownRequest, *, hide_steps: bool = False, ) -> None: ... @property
[docs] def step_position(self) -> int | None: """ Note the last step is the ticket status page with step 3. """ view_name = self.request.view_name if view_name == 'form': return 1 if view_name == 'confirmation': return 2 return None
[docs] class AllocationRulesLayout(OrgAllocationRulesLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class AllocationEditFormLayout(OrgAllocationEditFormLayout, DefaultLayout): """ Same as the resource layout, but with different editbar links, because there's not really an allocation view, but there are allocation forms. """
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class OccurrencesLayout(OrgOccurrencesLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
@cached_property
[docs] class OccurrenceLayout(OrgOccurrenceLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
@step_sequences.registered_step(1, _('Form'), cls_after='FormSubmissionLayout') @step_sequences.registered_step( 2, _('Check'), cls_before='EventLayout', cls_after='TicketChatMessageLayout' )
[docs] class EventLayout(StepsLayoutExtension, OrgEventLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] model: 'Event'
if TYPE_CHECKING: def __init__( self, model: 'Event', request: 'TownRequest', *, hide_steps: bool = False ) -> None: ... @property
[docs] def step_position(self) -> int: if self.request.view_name == 'new': return 1 return 2
[docs] class NewsletterLayout(OrgNewsletterLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class RecipientLayout(OrgRecipientLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class ImageSetCollectionLayout(OrgImageSetCollectionLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class ImageSetLayout(OrgImageSetLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class UserManagementLayout(OrgUserManagementLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class UserLayout(OrgUserLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class UserGroupCollectionLayout(OrgUserGroupCollectionLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class UserGroupLayout(OrgUserGroupLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class ExportCollectionLayout(OrgExportCollectionLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class PaymentProviderLayout(OrgPaymentProviderLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class PaymentCollectionLayout(OrgPaymentCollectionLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class MessageCollectionLayout(OrgMessageCollectionLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class DirectoryCollectionLayout(OrgDirectoryCollectionLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
@step_sequences.registered_step( 1, _('Form'), cls_after='FormSubmissionLayout' )
[docs] class DirectoryEntryCollectionLayout( StepsLayoutExtension, OrgDirectoryEntryCollectionLayout, DefaultLayout ): if TYPE_CHECKING:
[docs] app: 'TownApp'
request: 'TownRequest' def __init__( self, model: ExtendedDirectoryEntryCollection, request: 'TownRequest', *, hide_steps: bool = False, ) -> None: ... @property
[docs] def step_position(self) -> int: return 1
# FIXME: Is there a reason we don't add the export link in Town6? # If not then just delete this method and use the one from Org @cached_property
@step_sequences.registered_step(1, _('Form'), cls_after='FormSubmissionLayout')
[docs] class DirectoryEntryLayout( StepsLayoutExtension, OrgDirectoryEntryLayout, DefaultLayout ):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
if TYPE_CHECKING: def __init__( self, model: ExtendedDirectoryEntry, request: TownRequest, *, hide_steps: bool = False ) -> None: ... @property
[docs] def step_position(self) -> int: return 1
[docs] class PublicationLayout(OrgPublicationLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class DashboardLayout(OrgDashboardLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class GeneralFileCollectionLayout(DefaultLayout): def __init__(self, model: Any, request: 'TownRequest') -> None: """ The order of assets differ from org where common.js must come first including jquery. Here, the foundation6 assets contain jquery and must come first. """ super().__init__(model, request) request.include('upload') request.include('prompt')
[docs] class ImageFileCollectionLayout(DefaultLayout): def __init__(self, model: Any, request: 'TownRequest') -> None: super().__init__(model, request) request.include('upload') request.include('editalttext')
[docs] class ExternalLinkLayout(OrgExternalLinkLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class HomepageLayout(OrgHomepageLayout, DefaultLayout):
[docs] app: 'TownApp'
[docs] request: 'TownRequest'
[docs] class ChatLayout(DefaultLayout): def __init__(self, model: Any, request: 'TownRequest') -> None: super().__init__(model, request)
[docs] token = self.make_websocket_token()
# Make token available to JavaScript when creating the WebSocket # connection. self.custom_body_attributes['data-websocket-token'] = token # Store the WebSocket token in the session check when the connection is # initiated. request.browser_session['websocket_token'] = token
[docs] def make_websocket_token(self) -> str: """ A user (authenticated or anonymous) attempts to create a chat connection. For the connection to succeed, they must present a one-time token to the WebSocket server. TODO: Add lifespan to the token? """ return secrets.token_hex(16)
[docs] class StaffChatLayout(ChatLayout): def __init__(self, model: Any, request: 'TownRequest') -> None: super().__init__(model, request) self.request.include('websockets') self.request.include('staff-chat') self.custom_body_attributes['data-websocket-endpoint'] = ( self.app.websockets_client_url(request)) self.custom_body_attributes['data-websocket-schema'] = ( self.app.schema) @cached_property
[docs] def breadcrumbs(self) -> list[Link]: return [ Link(_('Homepage'), self.homepage_url), Link(_('Chats'), self.request.link( self.request.app.org, name='chats' )) ]
[docs] class ClientChatLayout(ChatLayout): def __init__(self, model: Any, request: 'TownRequest') -> None: super().__init__(model, request) self.request.include('websockets') self.request.include('client-chat') self.custom_body_attributes['data-websocket-endpoint'] = ( self.app.websockets_client_url(request)) self.custom_body_attributes['data-websocket-schema'] = ( self.app.schema)
[docs] class ChatInitiationFormLayout(DefaultLayout): pass
[docs] class ArchivedChatsLayout(DefaultLayout): @cached_property
[docs] def breadcrumbs(self) -> list[Link]: bc = [ Link(_('Homepage'), self.homepage_url), Link( _('Chat Archive'), self.request.class_link( ChatCollection, { 'state': 'archived', }, name='archive' ), attrs={ 'class': ('chats'), } ) ] if isinstance(self.model, Chat): bc.append( Link(self.model.customer_name, self.request.link( self.model, 'staff-view' )) ) return bc