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]
NavigationEntry: TypeAlias = tuple[
PageMeta,
Link,
tuple['NavigationEntry', ...]
]
[docs]
class PartnerCard(NamedTuple):
[docs]
class Layout(OrgLayout):
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:
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]
class AdjacencyListLayout(OrgAdjacencyListLayout, DefaultLayout):
[docs]
class SettingsLayout(OrgSettingsLayout, DefaultLayout):
[docs]
class PageLayout(OrgPageLayout, AdjacencyListLayout):
[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):
@cached_property
[docs]
class EditorLayout(OrgEditorLayout, DefaultLayout):
@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 SurveySubmissionLayout(
StepsLayoutExtension,
OrgSurveySubmissionLayout,
DefaultLayout
):
[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 SurveySubmissionWindowLayout(OrgSurveySubmissionWindowLayout,
DefaultLayout):
[docs]
class SurveyCollectionLayout(OrgSurveyCollectionLayout, DefaultLayout):
[docs]
class PersonCollectionLayout(OrgPersonCollectionLayout, DefaultLayout):
[docs]
class PersonLayout(OrgPersonLayout, DefaultLayout):
[docs]
class TicketsLayout(OrgTicketsLayout, DefaultLayout):
[docs]
class ArchivedTicketsLayout(OrgArchivedTicketsLayout, DefaultLayout):
[docs]
class TicketLayout(OrgTicketLayout, DefaultLayout):
@cached_property
[docs]
def editbar_links(self) -> list[Link | LinkGroup] | None:
links = super().editbar_links
if links is not None and self.request.is_manager:
if self.request.app.org.gever_endpoint:
links.append(
Link(
text=_('Upload to Gever'),
url=self.request.link(self.model, 'send-to-gever'),
attrs={'class': 'upload'},
traits=(
Confirm(
_('Do you really want to upload this ticket?'),
_('This will upload this ticket to the '
'Gever instance, if configured.'),
_('Upload Ticket'),
_('Cancel')
)
)
)
)
return links
[docs]
class TicketNoteLayout(OrgTicketNoteLayout, DefaultLayout):
@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
):
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]
request: 'TownRequest'
[docs]
class TextModuleLayout(OrgTextModuleLayout, DefaultLayout):
[docs]
request: 'TownRequest'
[docs]
class ResourcesLayout(OrgResourcesLayout, DefaultLayout):
[docs]
class FindYourSpotLayout(OrgFindYourSpotLayout, DefaultLayout):
[docs]
class ResourceRecipientsLayout(OrgResourceRecipientsLayout, DefaultLayout):
[docs]
class ResourceLayout(OrgResourceLayout, DefaultLayout):
@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
):
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]
class OccurrencesLayout(OrgOccurrencesLayout, DefaultLayout):
@cached_property
[docs]
def editbar_links(self) -> list[Link | LinkGroup]:
links = super().editbar_links
if self.request.is_manager:
links.append(
LinkGroup(
title=_('Add'),
links=[
Link(
text=_('Event'),
url=self.request.link(self.model, 'enter-event'),
attrs={'class': 'new-form'}
),
]
)
)
return links
[docs]
class OccurrenceLayout(OrgOccurrenceLayout, DefaultLayout):
@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):
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]
class RecipientLayout(OrgRecipientLayout, DefaultLayout):
[docs]
class ImageSetCollectionLayout(OrgImageSetCollectionLayout, DefaultLayout):
[docs]
class ImageSetLayout(OrgImageSetLayout, DefaultLayout):
[docs]
class UserManagementLayout(OrgUserManagementLayout, DefaultLayout):
[docs]
class UserLayout(OrgUserLayout, DefaultLayout):
[docs]
class UserGroupCollectionLayout(OrgUserGroupCollectionLayout, DefaultLayout):
[docs]
class UserGroupLayout(OrgUserGroupLayout, DefaultLayout):
[docs]
class ExportCollectionLayout(OrgExportCollectionLayout, DefaultLayout):
[docs]
class PaymentProviderLayout(OrgPaymentProviderLayout, DefaultLayout):
[docs]
class PaymentCollectionLayout(OrgPaymentCollectionLayout, DefaultLayout):
[docs]
class MessageCollectionLayout(OrgMessageCollectionLayout, DefaultLayout):
[docs]
class DirectoryCollectionLayout(OrgDirectoryCollectionLayout, DefaultLayout):
@step_sequences.registered_step(
1, _('Form'), cls_after='FormSubmissionLayout'
)
[docs]
class DirectoryEntryCollectionLayout(
StepsLayoutExtension,
OrgDirectoryEntryCollectionLayout,
DefaultLayout
):
if TYPE_CHECKING:
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
[docs]
def editbar_links(self) -> list[Link | LinkGroup]:
export_link = Link(
text=_('Export'),
url=self.request.link(self.model, name='+export'),
attrs={'class': 'export-link'}
)
def links() -> 'Iterator[Link | LinkGroup]':
qr_link = None
if self.request.is_admin:
yield Link(
text=_('Configure'),
url=self.request.link(self.model, '+edit'),
attrs={'class': 'edit-link'}
)
if self.request.is_manager:
yield export_link
yield Link(
text=_('Import'),
url=self.request.class_link(
ExtendedDirectoryEntryCollection, {
'directory_name': self.model.directory_name
}, name='+import'
),
attrs={'class': 'import-link'}
)
qr_link = QrCodeLink(
text=_('QR'),
url=self.request.link(self.model),
attrs={'class': 'qr-code-link'}
)
if self.request.is_admin:
yield Link(
text=_('Delete'),
url=self.csrf_protected_url(
self.request.link(self.model)
),
attrs={'class': 'delete-link'},
traits=(
Confirm(
_(
'Do you really want to delete "${title}"?',
mapping={
'title': self.model.directory.title
}
),
_('All entries will be deleted as well!'),
_('Delete directory'),
_('Cancel')
),
Intercooler(
request_method='DELETE',
redirect_after=self.request.class_link(
DirectoryCollection
)
)
)
)
yield Link(
text=self.request.translate(_('Change URL')),
url=self.request.link(
self.model.directory,
'change-url'),
attrs={'class': 'internal-url'},
)
if self.request.is_manager:
yield LinkGroup(
title=_('Add'),
links=[
Link(
text=_('Entry'),
url=self.request.link(
self.model,
name='+new'
),
attrs={'class': 'new-directory-entry'}
)
]
)
if qr_link:
yield qr_link
if self.request.is_manager:
yield IFrameLink(
text=_('iFrame'),
url=self.request.link(self.model),
attrs={'class': 'new-iframe'}
)
return list(links())
@step_sequences.registered_step(1, _('Form'), cls_after='FormSubmissionLayout')
[docs]
class DirectoryEntryLayout(
StepsLayoutExtension,
OrgDirectoryEntryLayout,
DefaultLayout
):
[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]
class DashboardLayout(OrgDashboardLayout, DefaultLayout):
[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]
class HomepageLayout(OrgHomepageLayout, DefaultLayout):
[docs]
request: 'TownRequest'
[docs]
class ChatLayout(DefaultLayout):
def __init__(self, model: Any, request: 'TownRequest') -> None:
super().__init__(model, request)
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 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