Source code for org.views.settings

""" The settings view, defining things like the logo or color of the org. """
from __future__ import annotations

from itertools import groupby
from markupsafe import Markup
from webob.exc import HTTPForbidden
from onegov.api.models import ApiKey
from onegov.core.directives import fetch_form_class
from onegov.core.elements import Link, Confirm, Intercooler
from onegov.core.security import Secret
from onegov.core.templates import render_macro
from onegov.event.models.event import EventFilterValue
from onegov.form import as_internal_id
from onegov.form import Form
from onegov.form.utils import extract_text_from_html
from onegov.form.utils import get_fields_from_class
from onegov.org import _
from onegov.org.app import OrgApp
from onegov.org.forms import AnalyticsSettingsForm
from onegov.org.forms import FooterSettingsForm
from onegov.org.forms import AppearanceSettingsForm
from onegov.org.forms import HolidaySettingsForm
from onegov.org.forms import HomepageSettingsForm
from onegov.org.forms import MapSettingsForm
from onegov.org.forms import AccessSettingsForm
from onegov.org.forms.settings import (
    ModuleActivationSettingsForm, OrgTicketSettingsForm, HeaderSettingsForm,
    LinksSettingsForm, NewsletterSettingsForm, LinkMigrationForm,
    LinkHealthCheckForm, OrganisationProfileSettingsForm, PeopleSettingsForm,
    GeverSettingsForm, OneGovApiSettingsForm, DataRetentionPolicyForm,
    VATSettingsForm, EventSettingsForm, KabaSettingsForm,
    ResourceSettingsForm)
from onegov.org.layout import DefaultLayout
from onegov.org.layout import SettingsLayout
from onegov.org.management import LinkHealthCheck
from onegov.org.management import LinkMigration
from onegov.org.models import Organisation
from onegov.org.models import SwissHolidays
from onegov.org.path import ShortLink
from uuid import uuid4


from typing import cast, Any, TYPE_CHECKING
if TYPE_CHECKING:
    from collections.abc import Iterator
    from onegov.core.types import RenderData
    from onegov.org.directives import SettingViewMeta
    from onegov.org.request import OrgRequest
    from typing import type_check_only
    from webob import Response

    @type_check_only




[docs] setting_categories = [ _('General'), _('Modules'), _('Advanced Settings'), ]
@OrgApp.html( model=Organisation, name='settings', template='settings.pt', permission=Secret )
[docs] def view_settings( self: Organisation, request: OrgRequest, layout: SettingsLayout | None = None ) -> RenderData: layout = layout or SettingsLayout(self, request) request.include('settings_search') def translated_text(value: object) -> tuple[str, str]: if not isinstance(value, str): return '', '' source = extract_text_from_html(str(value)) translated = extract_text_from_html(str(request.translate(value))) return translated, source def search_text(*values: str) -> str: return ' '.join(dict.fromkeys(value for value in values if value)) def query_settings() -> Iterator[dict[str, Any]]: registry: dict[tuple[type, str], SettingViewMeta] registry = request.app.config.setting_view_registry for metadata in registry.values(): if not issubclass(Organisation, metadata.model): continue setting: dict[str, Any] = { 'name': metadata.name, 'setting': metadata.setting, 'icon': metadata.icon, 'order': metadata.order, } if metadata.category is not None: setting['category'] = metadata.category if ( metadata.name == 'citizen-login-settings' and not request.app.settings.org.citizen_login_enabled ): continue if ( metadata.name == 'newsletter-settings' and not request.app.org.show_newsletter ): continue if ( metadata.name == 'ris-settings' and not request.app.org.ris_enabled | False ): continue title, source_title = translated_text(metadata.setting) setting['title'] = title setting['search_text'] = search_text(title, source_title) setting_link = request.link(self, name=metadata.name) setting['link'] = setting_link form_class = fetch_form_class(metadata.form, self, request) fields: list[dict[str, str]] = [] fieldsets: list[dict[str, str]] = [] seen_fieldsets: set[str] = set() for name, field in get_fields_from_class(form_class): label = field.kwargs.get('label') if label is None and field.args: label = field.args[0] field_title, source_field_title = translated_text(label) if not field_title: continue description, source_description = translated_text( field.kwargs.get('description') ) fieldset_label = field.kwargs.get('fieldset') if fieldset_label is None: fieldset_label = getattr(field, 'fieldset', None) fieldset, source_fieldset = translated_text(fieldset_label) if fieldset and source_fieldset not in seen_fieldsets: fieldset_id = f'fieldset-{as_internal_id(source_fieldset)}' fieldsets.append( { 'title': fieldset, 'search_text': search_text( fieldset, source_fieldset ), 'link': f'{setting_link}#{fieldset_id}', } ) seen_fieldsets.add(source_fieldset) fields.append( { 'title': field_title, 'description': description, 'fieldset': fieldset, 'search_text': search_text( field_title, source_field_title, description, source_description, ), 'link': f'{setting_link}#{name}', } ) setting['fields'] = fields setting['fieldsets'] = fieldsets yield setting settings = list(query_settings()) settings.sort(key=lambda s: s.get('order', 0)) category_order = {cat: i for i, cat in enumerate(setting_categories)} # Additional Settings settings_by_category = [ (cat, list(group)) for cat, group in groupby( sorted(settings, key=lambda s: ( category_order.get( s.get('category', _('General')), len(setting_categories) ), s.get('order', 1000) )), key=lambda s: s.get('category', _('General')) ) ] return { 'layout': layout, 'title': _('Settings'), 'settings_by_category': settings_by_category, 'settings_search_results': settings, }
[docs] def handle_generic_settings( self: Organisation, request: OrgRequest, form: Form, title: str, layout: SettingsLayout | None = None, subtitle: str | None = None ) -> RenderData | Response: settings_url = request.link(self, name='settings') layout = layout or SettingsLayout(self, request, title) layout.setting = layout.setting or title layout.edit_mode = True layout.editmode_links[1] = Link( text=_('Cancel'), url=request.return_to_url(settings_url), attrs={'class': 'cancel-link'} ) request.include('fontpreview') if form.submitted(request): form.populate_obj(self) request.success(_('Your changes were saved')) return request.redirect(settings_url) elif request.method == 'GET': form.process(obj=self) return { 'layout': layout, 'title': title, 'form': form, 'subtitle': subtitle }
# General Settings @OrgApp.setting_form( model=Organisation, name='organisation-settings', template='form.pt', permission=Secret, form=OrganisationProfileSettingsForm, setting=_('Organisation Profile'), icon='fa-building', order=0, category='General')
[docs] def handle_organisation_settings( self: Organisation, request: OrgRequest, form: OrganisationProfileSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings(self, request, form, _('Organisation Profile'), layout)
@OrgApp.setting_form( model=Organisation, name='ticket-settings', template='form.pt', permission=Secret, form=OrgTicketSettingsForm, setting=_('Tickets'), order=10, icon='fa-ticket' )
[docs] def handle_ticket_settings( self: Organisation, request: OrgRequest, form: OrgTicketSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: resp = handle_generic_settings( self, request, form, _('Tickets'), layout) return resp
@OrgApp.setting_form( model=Organisation, name='homepage-settings', template='form.pt', permission=Secret, form=HomepageSettingsForm, setting=_('Homepage'), icon='fa-home', order=20)
[docs] def handle_homepage_settings( self: Organisation, request: OrgRequest, form: HomepageSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings(self, request, form, _('Homepage'), layout)
@OrgApp.setting_form( model=Organisation, name='appearance-settings', template='form.pt', permission=Secret, form=AppearanceSettingsForm, setting=_('Appearance'), icon='fa-eye', order=30)
[docs] def handle_appearance_settings( self: Organisation, request: OrgRequest, form: AppearanceSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings(self, request, form, _('Appearance'), layout)
@OrgApp.setting_form( model=Organisation, name='header-settings', template='form.pt', permission=Secret, form=HeaderSettingsForm, setting=_('Header'), icon='fa-window-maximize', order=40)
[docs] def handle_header_settings( self: Organisation, request: OrgRequest, form: Form, layout: SettingsLayout | None = None ) -> RenderData | Response: layout = layout or SettingsLayout(self, request, _('Header')) return handle_generic_settings(self, request, form, _('Header'), layout)
@OrgApp.setting_form( model=Organisation, name='footer-settings', template='form.pt', permission=Secret, form=FooterSettingsForm, setting=_('Footer'), icon='fa-window-minimize', order=50) @OrgApp.setting_form( model=Organisation, name='vat-settings', template='form.pt', permission=Secret, form=VATSettingsForm, setting=_('Prices'), icon='fa-money', order=60)
[docs] def handle_vat_settings( self: Organisation, request: OrgRequest, form: VATSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: layout = layout or SettingsLayout(self, request, _('Prices')) return handle_generic_settings(self, request, form, _('Prices'), layout)
@OrgApp.setting_form( model=Organisation, name='data-retention-settings', template='form.pt', permission=Secret, form=DataRetentionPolicyForm, setting=_('Data Retention Policy'), icon='far fa-trash', order=70, )
[docs] def handle_ticket_data_deletion_settings( self: Organisation, request: OrgRequest, form: DataRetentionPolicyForm ) -> RenderData | Response: request.message(_('Proceed with caution. Tickets and the data they ' 'contain may be irrevocable deleted.'), 'alert') return handle_generic_settings( self, request, form, _('Data Retention Policy'), SettingsLayout(self, request), )
@OrgApp.setting_form( model=Organisation, name='access-settings', template='form.pt', permission=Secret, form=AccessSettingsForm, setting=_('Access (mTAN)'), icon='fa-lock', order=80)
[docs] def handle_access_settings( self: Organisation, request: OrgRequest, form: AccessSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings( self, request, form, _('Access (mTAN)'), layout)
@OrgApp.setting_form( model=Organisation, name='link-settings', template='form.pt', permission=Secret, form=LinksSettingsForm, setting=_('Links'), icon=' fa-link', order=90) @OrgApp.setting_form( model=Organisation, name='map-settings', template='form.pt', permission=Secret, form=MapSettingsForm, setting=_('Map'), icon='fa-map-marker', order=100)
[docs] def handle_map_settings( self: Organisation, request: OrgRequest, form: MapSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings(self, request, form, _('Map'), layout)
@OrgApp.setting_form( model=Organisation, name='analytics-settings', template='form.pt', permission=Secret, form=AnalyticsSettingsForm, setting=_('Analytics'), icon='fa-line-chart ', order=110)
[docs] def handle_analytics_settings( self: Organisation, request: OrgRequest, form: AnalyticsSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings(self, request, form, _('Analytics'), layout)
# Module Settings @OrgApp.setting_form( model=Organisation, name='module-activation-settings', template='form.pt', permission=Secret, form=ModuleActivationSettingsForm, setting=_('Optional modules'), order=0, icon='far fa-th-large', category=_('Modules') )
[docs] def handle_module_activation_settings( self: Organisation, request: OrgRequest, form: ModuleActivationSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings( self, request, form, _('Activate/deactivate modules'), layout )
@OrgApp.setting_form( model=Organisation, name='newsletter-settings', template='form.pt', permission=Secret, form=NewsletterSettingsForm, setting=_('Newsletter'), order=10, icon='far fa-paper-plane', category=_('Modules') )
[docs] def handle_newsletter_settings( self: Organisation, request: OrgRequest, form: NewsletterSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings( self, request, form, _('Newsletter'), layout )
@OrgApp.setting_form( model=Organisation, name='resource-settings', template='form.pt', permission=Secret, form=ResourceSettingsForm, setting=_('Resources'), icon='fa-building', order=20, category=_('Modules') )
[docs] def handle_resource_settings( self: Organisation, request: OrgRequest, form: ResourceSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings(self, request, form, _('Resources'), layout)
@OrgApp.setting_form( model=Organisation, name='people-settings', template='form.pt', permission=Secret, form=PeopleSettingsForm, setting=_('People directory'), icon='fa-users', order=30, )
[docs] def handle_people_settings( self: Organisation, request: OrgRequest, form: PeopleSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: layout = layout or SettingsLayout(self, request, _('People')) return handle_generic_settings( self, request, form, _('People'), layout )
@OrgApp.setting_form( model=Organisation, name='event-settings', template='form.pt', permission=Secret, form=EventSettingsForm, setting=_('Events'), icon='fa-calendar', order=40)
[docs] def handle_event_settings( self: Organisation, request: OrgRequest, form: EventSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: layout = layout or SettingsLayout(self, request, _('Events')) layout.edit_mode = True layout.editmode_links[1] = Link( text=_('Cancel'), url=request.return_to_url(request.link(self, name='settings')), attrs={'class': 'cancel-link'} ) layout.include_code_editor() request.include('fontpreview') show_force_remove = False if form.submitted(request): use_filters = form.event_filter_type.data in ( 'filters', 'tags_and_filters') if use_filters: old_keywords = { as_internal_id(k) for k in self.event_filter_configuration.get( 'keywords', []) } new_keywords = { as_internal_id(k) for k in (form.keyword_fields.data or '').splitlines() } old_field_choices = { f.id: {c.label for c in getattr(f, 'choices', ())} for f in self.event_filter_fields } new_field_choices = { f.id: {c.label for c in getattr(f, 'choices', ())} for f in parsed.flattened_fields } if (parsed := form.event_filter_parsed_definition.data) else {} removed_keywords = old_keywords - new_keywords removed_choices: dict[str, set[str]] = { keyword: removed_field_choices for keyword in old_keywords & new_keywords if (removed_field_choices := ( old_field_choices.get(keyword, set()) - new_field_choices.get(keyword, set()) )) } if form.force_remove.data: for keyword in removed_keywords: ( request.session.query(EventFilterValue) .filter_by(keyword=keyword) .delete() ) for keyword, choices in removed_choices.items(): for choice in choices: ( request.session.query(EventFilterValue) .filter_by(keyword=keyword, value=choice) .delete() ) else: blocked = False for keyword in removed_keywords: count = ( request.session.query(EventFilterValue) .filter_by(keyword=keyword) .count() ) if count: request.alert(_( 'The filter "${keyword}" is still applied to ' '${count} event(s).', mapping={'keyword': keyword, 'count': count} )) blocked = True for keyword, choices in removed_choices.items(): for choice in choices: count = ( request.session.query(EventFilterValue) .filter_by(keyword=keyword, value=choice) .count() ) if count: request.alert(_( 'The filter choice "${choice}" is still ' 'applied to ${count} event(s).', mapping={'choice': choice, 'count': count} )) blocked = True if blocked: show_force_remove = True if not show_force_remove: # populate the regular settings; the filter fields are # persisted explicitly below (keyword_fields maps into a dict # and we must not touch the stored filters when disabled) form.populate_obj(self, exclude={ 'event_filter_parsed_definition', 'keyword_fields', 'force_remove' }) if use_filters: keywords = (form.keyword_fields.data or '').splitlines() self.event_filter_parsed_definition = ( form.event_filter_parsed_definition.data) self.event_filter_configuration = { 'order': [], 'keywords': keywords } request.success(_('Your changes were saved')) return request.redirect(request.link(self, name='settings')) elif request.method == 'GET': form.process(obj=self) form.keyword_fields.data = '\r\n'.join( self.event_filter_configuration.get('keywords', [])) if not show_force_remove: form.delete_field('force_remove') return { 'layout': layout, 'title': _('Events'), 'form': form, 'form_width': 'large', }
@OrgApp.setting_form( model=Organisation, name='chat-settings', template='form.pt', permission=Secret, form=Form, setting=_('Chat'), icon='fa-window-maximize', order=50)
[docs] def handle_chat_settings( self: Organisation, request: OrgRequest, form: Form, layout: SettingsLayout | None = None ) -> RenderData | Response: layout = layout or SettingsLayout(self, request, _('Chat')) return handle_generic_settings(self, request, form, _('Chat'), layout)
# Advanced Settings @OrgApp.setting_form( model=Organisation, name='gever-credentials', template='form.pt', permission=Secret, form=GeverSettingsForm, setting='Gever API', icon='fa-key', order=30, category=_('Advanced Settings'))
[docs] def handle_gever_settings( self: Organisation, request: OrgRequest, form: GeverSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings(self, request, form, 'Gever API', layout)
@OrgApp.setting_form( model=Organisation, name='kaba-settings', template='form.pt', permission=Secret, form=KabaSettingsForm, setting='dormakaba API', icon='fa-key', order=40, category=_('Advanced Settings'))
[docs] def handle_kaba_settings( self: Organisation, request: OrgRequest, form: KabaSettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings( self, request, form, 'dormakaba API', layout)
@OrgApp.setting_form( model=Organisation, name='holiday-settings', template='form.pt', permission=Secret, form=HolidaySettingsForm, setting=_('Holidays'), icon='fa-calendar-o', order=-500)
[docs] def handle_holiday_settings( self: Organisation, request: OrgRequest, form: HolidaySettingsForm, layout: SettingsLayout | None = None ) -> RenderData | Response: return handle_generic_settings(self, request, form, _('Holidays'), layout)
@OrgApp.form(model=Organisation, name='holiday-settings-preview', permission=Secret, form=HolidaySettingsForm)
[docs] def preview_holiday_settings( self: Organisation, request: OrgRequest, form: HolidaySettingsForm, layout: DefaultLayout | None = None ) -> str: layout = layout or DefaultLayout(self, request) if form.submitted(request): holidays = SwissHolidays( cantons=form.holiday_settings.get('cantons', ()), other=form.holiday_settings.get('other', ()) ) else: holidays = SwissHolidays( cantons=form.holiday_settings.get('cantons', ()) ) if not holidays.all(layout.today().year): msg = request.translate(_('No holidays defined')) return f'<i class="holidays">{msg}</i>' return render_macro( layout.macros['holidays'], request, { 'holidays': holidays, 'layout': layout, 'year': layout.today().year, } )
@OrgApp.form(model=Organisation, name='link-settings-preview', permission=Secret, form=LinksSettingsForm) @OrgApp.setting_form( model=Organisation, name='migrate-links', template='form.pt', permission=Secret, form=LinkMigrationForm, setting=_('Link Migration'), icon='fa fa-random', order=-400) @OrgApp.form( model=Organisation, name='link-check', template='linkcheck.pt', permission=Secret, form=LinkHealthCheckForm) @OrgApp.setting_form( model=Organisation, name='api-keys', template='api_keys.pt', permission=Secret, form=OneGovApiSettingsForm, setting=_('OneGov API'), icon='fa-key', order=10, category=_('Advanced Settings'))
[docs] def handle_api_keys( self: Organisation, request: OrgRequest, form: OneGovApiSettingsForm, layout: SettingsLayout | None = None ) -> RenderData: """Handles the generation of API access keys.""" request.include('fontpreview') title = _('OneGov API') user = request.current_user if not user: raise HTTPForbidden() if form.submitted(request): assert form.name.data is not None key = ApiKey( name=form.name.data, read_only=form.read_only.data, last_used=None, key=uuid4(), user=user, ) request.session.add(key) request.session.flush() request.success(_('Your changes were saved')) layout = layout or SettingsLayout(self, request, title) def current_api_keys_by_user() -> Iterator[ApiKeyWithDeleteLink]: for api_key in user.api_keys: api_key_delete_link = Link( text=Markup('<i class="fa fa-trash" aria-hidden="True"></i>'), url=layout.csrf_protected_url( request.link(api_key, name='+delete')), traits=( Confirm( _('Do you really want to delete this API key?'), _('This action cannot be undone.'), _('Delete'), _('Cancel'), ), Intercooler( request_method='DELETE', redirect_after=request.link(self, name='api-keys'), ), ), ) # delete_link is temporarily stored on the object itself api_key = cast('ApiKeyWithDeleteLink', api_key) api_key.delete_link = api_key_delete_link yield api_key return { 'api_keys_list': list(current_api_keys_by_user()), 'title': title, 'form': form, 'layout': layout, }
@OrgApp.view( model=ApiKey, name='delete', permission=Secret, request_method='DELETE', )
[docs] def delete_api_key(self: ApiKey, request: OrgRequest) -> None: request.assert_valid_csrf_token() request.session.delete(self) request.session.flush() request.message(_('ApiKey deleted.'), 'success')