From 85dbe8f1648b49cccb8356e90df8a301a14b8afa Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 8 Apr 2026 15:25:46 +0200 Subject: [PATCH 1/9] Org: Adds an optional parent resource to reservation resources Parent resources will be blocked by children and vice versa, but the children don't block each other. TYPE: Feature LINK: OGC-2580 --- .pre-commit-config.yaml | 6 +- setup.cfg | 2 +- src/onegov/election_day/utils/common.py | 4 +- src/onegov/feriennet/views/invoice.py | 2 +- .../form/assets/css/treeselect.fixes.css | 14 ++ src/onegov/form/assets/js/treeselect-init.js | 6 +- src/onegov/form/fields.py | 14 +- .../org/assets/js/occupancycalendar.jsx | 98 +++++++------ src/onegov/org/assets/js/reservationlist.jsx | 10 +- src/onegov/org/forms/political_business.py | 2 +- src/onegov/org/forms/resource.py | 113 ++++++++++++++- src/onegov/org/forms/settings.py | 2 +- src/onegov/org/models/resource.py | 8 +- src/onegov/org/utils.py | 132 +++++++++++------- src/onegov/org/views/allocation.py | 20 +-- src/onegov/org/views/reservation.py | 37 ++++- src/onegov/org/views/resource.py | 119 ++++++++++++---- src/onegov/reservation/collection.py | 3 + src/onegov/reservation/core.py | 76 +++++++++- src/onegov/reservation/models/resource.py | 41 +++++- src/onegov/reservation/upgrade.py | 36 ++++- src/onegov/search/integration.py | 13 +- src/onegov/swissvotes/fields/dataset.py | 2 +- src/onegov/swissvotes/fields/metadata.py | 2 +- src/onegov/ticket/collection.py | 2 +- src/onegov/town6/templates/macros.pt | 10 +- .../town6/theme/styles/fullcalendar.scss | 34 ++++- src/onegov/translator_directory/custom.py | 2 +- src/onegov/user/auth/clients/saml2.py | 2 +- tests/onegov/agency/test_app.py | 2 + tests/onegov/agency/test_forms.py | 1 + tests/onegov/core/test_orm.py | 13 +- tests/onegov/directory/test_migration.py | 1 + tests/onegov/election_day/conftest.py | 2 +- .../election_day/forms/test_screen_form.py | 1 + .../onegov/election_day/models/test_screen.py | 4 + tests/onegov/election_day/models/test_vote.py | 2 + .../screen_widgets/test_generic_widgets.py | 128 ++++++++--------- .../utils/test_election_compound_utils.py | 3 +- tests/onegov/event/test_collections.py | 3 +- tests/onegov/event/test_models.py | 1 + tests/onegov/fsi/test_models.py | 2 +- tests/onegov/gis/test_fields.py | 2 + tests/onegov/landsgemeinde/test_models.py | 3 + tests/onegov/org/test_extensions.py | 5 +- tests/onegov/org/test_forms.py | 4 +- tests/onegov/org/test_layout.py | 5 +- tests/onegov/org/test_views_settings.py | 2 + tests/onegov/people/test_models.py | 2 +- tests/onegov/reservation/conftest.py | 4 +- tests/onegov/reservation/test_collection.py | 1 + tests/onegov/swissvotes/test_collections.py | 27 ++-- tests/onegov/swissvotes/test_forms.py | 1 + tests/onegov/ticket/test_model.py | 26 ++-- tests/onegov/town6/test_layout.py | 3 +- .../translator_directory/test_models.py | 9 +- tests/onegov/websockets/test_cli.py | 26 ++-- 57 files changed, 790 insertions(+), 305 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4779e761fe..d0f7f3d98f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: exclude: .pre-commit-config.yaml - id: pt_structure - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.6 + rev: v0.15.9 hooks: - id: ruff-check args: [ "--fix" ] @@ -29,7 +29,7 @@ repos: additional_dependencies: - flake8-type-checking>=3.0.0 - repo: https://github.com/thibaudcolas/pre-commit-stylelint - rev: v17.4.0 + rev: v17.6.0 hooks: - id: stylelint files: '^src/.*\.scss' @@ -37,7 +37,7 @@ repos: - stylelint@16.19.1 - stylelint-config-standard-scss@15.0.0 - repo: https://github.com/pre-commit/mirrors-eslint - rev: v10.0.3 + rev: v10.2.0 hooks: - id: eslint files: '^src/.*\.jsx?$' diff --git a/setup.cfg b/setup.cfg index 3c21188e47..3e988ed374 100644 --- a/setup.cfg +++ b/setup.cfg @@ -98,7 +98,7 @@ install_requires = kerberos lazy-object-proxy ldap3 - libres>=1 + libres>=1.1 libsass lingua-language-detector lxml diff --git a/src/onegov/election_day/utils/common.py b/src/onegov/election_day/utils/common.py index 98ac0cbc3f..adbfd1d7d6 100644 --- a/src/onegov/election_day/utils/common.py +++ b/src/onegov/election_day/utils/common.py @@ -151,7 +151,7 @@ def get_parameter[T, ParamT: (int, bool, list[Any])]( if type_ is bool: try: result = request.params[name].lower().strip() # type:ignore - return result in ('true', '1') if result else default # type: ignore[return-value] + return result in ('true', '1') if result else default except Exception: return default @@ -165,7 +165,7 @@ def get_parameter[T, ParamT: (int, bool, list[Any])]( try: result = request.params[name].split(',') # type:ignore result = [item.strip() for item in result if item.strip()] - return result if result else default # type: ignore[return-value] + return result if result else default except Exception: return default diff --git a/src/onegov/feriennet/views/invoice.py b/src/onegov/feriennet/views/invoice.py index 8764c92cfb..613bef7b86 100644 --- a/src/onegov/feriennet/views/invoice.py +++ b/src/onegov/feriennet/views/invoice.py @@ -311,7 +311,7 @@ def handle_donation( if donation: amount = f'{donation.amount:.2f}' - for key, value in form.amount.choices: # type:ignore[misc] + for key, value in form.amount.choices: # type:ignore if key == amount: form.amount.data = amount break diff --git a/src/onegov/form/assets/css/treeselect.fixes.css b/src/onegov/form/assets/css/treeselect.fixes.css index 9632748e38..c9fd9c92de 100644 --- a/src/onegov/form/assets/css/treeselect.fixes.css +++ b/src/onegov/form/assets/css/treeselect.fixes.css @@ -1,3 +1,17 @@ select.treeselect { display: none !important; } + +.treeselect-list__item--disabled { + cursor: default !important; +} + +.treeselect-list__item--disabled .treeselect-list__item-icon { + color: #999 !important; + cursor: default !important; + pointer-events: none; +} + +.treeselect-list__item--disabled .treeselect-list__item-label { + color: #999 !important; +} diff --git a/src/onegov/form/assets/js/treeselect-init.js b/src/onegov/form/assets/js/treeselect-init.js index ea618bbe57..07ad1a9dc0 100644 --- a/src/onegov/form/assets/js/treeselect-init.js +++ b/src/onegov/form/assets/js/treeselect-init.js @@ -12,10 +12,12 @@ $(document).ready(function() { saveScrollPosition: true, emptyText: $(select).data('no_results_text') || '', placeholder: $(select).data('placeholder') || '', - showTags: true, + showTags: select.multiple, searchable: true, clearable: true, - isGroupedValue: true, + expandSelected: true, + isGroupedValue: select.multiple, + grouped: select.multiple, inputCallback: function(value) { $(select).val(value); }, diff --git a/src/onegov/form/fields.py b/src/onegov/form/fields.py index ad105dee04..2736cece63 100644 --- a/src/onegov/form/fields.py +++ b/src/onegov/form/fields.py @@ -67,7 +67,6 @@ FormT, Filter, PricingRules, RawFormValue, Validators, Widget) from typing import NotRequired, TypedDict, Self from webob.request import _FieldStorageWithFile - from wtforms.fields.choices import _Choice from wtforms.form import BaseForm from wtforms.meta import ( _MultiDictLikeWithGetlist, _SupportsGettextAndNgettext, DefaultMeta) @@ -795,6 +794,8 @@ def formatted_data(self) -> str | None: class _TreeSelectMixin(_TreeSelectMixinBase): + widget: TreeSelectWidget + def __init__( self, label: str | None = None, @@ -855,9 +856,13 @@ def __init__( def flatten_choices( self, choices: Iterable[TreeSelectNode] - ) -> Iterator[_Choice]: + ) -> Iterator[tuple[str, str]]: + multiple = self.widget.multiple for choice in choices: - yield choice['value'], choice['name'] + if not choice.get('disabled', False) and ( + multiple or choice.get('isGroupSelectable', True) + ): + yield choice['value'], choice['name'] yield from self.flatten_choices(choice['children']) def set_choices(self, choices: Iterable[TreeSelectNode]) -> None: @@ -866,6 +871,9 @@ def set_choices(self, choices: Iterable[TreeSelectNode]) -> None: self.render_kw['data-choices'] = json.dumps(choices) self.choices = list(self.flatten_choices(choices)) + if not self.widget.multiple: + # NOTE: Add a blank choice so the field can be cleared + self.choices.insert(0, ('', '')) class TreeSelectField(_TreeSelectMixin, SelectField): diff --git a/src/onegov/org/assets/js/occupancycalendar.jsx b/src/onegov/org/assets/js/occupancycalendar.jsx index 8ade48c46a..e96a82eab4 100644 --- a/src/onegov/org/assets/js/occupancycalendar.jsx +++ b/src/onegov/org/assets/js/occupancycalendar.jsx @@ -98,6 +98,7 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { allDaySlot: false, height: 'auto', events: ocOptions.feed, + slotEventOverlap: false, slotMinTime: ocOptions.minTime, slotMaxTime: ocOptions.maxTime, snapDuration: '00:15', @@ -186,17 +187,17 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { // we only allow to add blockers in the future var keys = Object.keys(oc.overlappingEvents); if ( - keys.length === 1 - && oc.overlappingEvents[keys[0]].extendedProps.blockable - && oc.overlappingEvents[keys[0]].extendedProps.blockurl - && info.start >= Date.now() + keys.length === 1 && + oc.overlappingEvents[keys[0]].extendedProps.blockable && + oc.overlappingEvents[keys[0]].extendedProps.blockurl && + info.start >= Date.now() ) { return true; } else { oc.overlappingEvents = {}; return false; } - } + }; // add blockers on selection fcOptions.select = function(info) { if (oc.popupOpen) { @@ -220,7 +221,7 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { wholeDay = true; } oc.showBlockerPopup(view.calendar, $(view.calendar.el).find('.event-' + event.id).get(0) || view.calendar.el, start, end, wholeDay, event); - } + }; // edit blocker reason on click fcOptions.eventClick = function(info) { @@ -234,7 +235,12 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { }; // edit events on drag&drop, resize - fcOptions.eventOverlap = function(stillEvent, _movingEvent) { + fcOptions.eventOverlap = function(stillEvent, movingEvent) { + if (stillEvent.extendedProps.resource !== movingEvent.extendedProps.resource) { + // NOTE: This doesn't take into account the hierarchy, so it is a little bit + // too permissive right now. But the backend still covers us. + return true; + } return stillEvent.display === 'background'; }; @@ -264,7 +270,7 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { // add id to class names so we can easily find the element fcOptions.eventClassNames = function(info) { return 'event-' + info.event.id; - } + }; // render additional content lines fcOptions.eventContent = function(info, h) { @@ -272,20 +278,12 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { if (event.display === 'background') { return null; } - if (event.extendedProps.kind === 'blocker') { - return h('div', {title: event.title}, [ - event.title, - h('div', {class: 'delete-blocker', title: locale('Delete')}, [ - h('i', {class: 'fa fas fa-times'}) - ]) - ]); - } var lines = event.title.split('\n'); var attrs = {class: 'fc-title'}; // truncate title when it doesn't fit if (info.view.type === 'timeGridWeek' || info.view.type === 'timeGridDay') { attrs.title = event.title; - var max_lines = Math.max(1, Math.floor(moment(event.end).diff(moment(event.start), 'minutes') / 30)); + var max_lines = Math.max(1, Math.floor(moment(event.end).diff(moment(event.start), 'minutes') / 27)); lines = lines.slice(0, max_lines); } else if (info.view.type === 'multiMonthYear') { attrs.title = event.title; @@ -297,7 +295,25 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { } content.push(lines[i]); } - return h('div', {class: 'fc-content'}, h('span', attrs, content)); + if (event.extendedProps.kind === 'blocker') { + content[0] = h('div', {class: 'fc-blocker-reason'}, content[0]); + content.splice(1, 1); // remove the first
tag + if (event.extendedProps.deleteurl) { + content.unshift(h('div', {class: 'delete-blocker', title: locale('Delete')}, [ + h('i', {class: 'fa fas fa-times'}) + ])); + } + return h('div', {class: 'fc-blocker-title', title: event.title}, content); + } + return h( + 'div', + {class: 'fc-content'}, + h( + 'div', + {class: 'fc-reservation-title'}, + h('span', attrs, content) + ) + ); }; fcOptions.eventDidMount = function(info) { @@ -334,7 +350,7 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { // unless the event ends on the hour var end = moment(event.end); end = end.minutes() === 0 ? end.format('HH:mm') : end.startOf('hour').add(1, 'hour').format('HH:mm'); - end = end == '00:00' ? '24:00' : end; + end = end === '00:00' ? '24:00' : end; if (end > maxTime) { maxTime = end; changed = true; @@ -345,7 +361,7 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { this.setOption('slotMinTime', minTime); this.setOption('slotMaxTime', maxTime); } - } + }; // history handling oc.setupHistory(options); @@ -575,26 +591,26 @@ oc.setupViewNavigation = function(calendar, element, views, stats_url, pdf_url) url.query.end = state.end; $(this).closest('.popup').popup('hide'); $.ajax(url.toString()).done(function(data) { - var wrapper = $('
'); + var new_wrapper = $('
'); ReactDOM.render( ( -
-

{locale("Reservations")}

-

{data.range}

-

{locale("Count")}

-

{data.count} {data.pending && ( - ({data.pending} {locale("pending approval")}) - )}

-

{locale("Utilization")}

-

{data.utilization.toLocaleString(lang, { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - })}%

-
+
+

{locale("Reservations")}

+

{data.range}

+

{locale("Count")}

+

{data.count} {data.pending && ( + ({data.pending} {locale("pending approval")}) + )}

+

{locale("Utilization")}

+

{data.utilization.toLocaleString(lang, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })}%

+
), wrapper.get(0) ); - oc.showPopup(calendar, stats_btn, wrapper); + oc.showPopup(calendar, stats_btn, new_wrapper); }); } ); @@ -716,12 +732,12 @@ oc.edit_blocker = function(calendar, event, url, reason) { // popup handler implementation oc.showBlockerPopup = function(calendar, element, start, end, wholeDay, event) { var wrapper = $('
'); - var form = $('
').appendTo(wrapper); + var form_el = $('
').appendTo(wrapper); // Render the blocker form var form = oc.BlockerForm.render( calendar, - form.get(0), + form_el.get(0), start, end, wholeDay, @@ -759,7 +775,7 @@ oc.showBlockerEditPopup = function(calendar, element, event) { calendar, event, event.extendedProps.seturl, - state.reason, + state.reason ); $(this).closest('.popup').popup('hide'); } @@ -975,7 +991,6 @@ oc.setupResourceSwitch = function(options, resourcesUrl, active) { }); }; - /* Allows to fine-adjust the reservation blocker before adding it. */ @@ -1222,7 +1237,6 @@ oc.BlockerForm.render = function(calendar, element, start, end, wholeDay, event, ); }; - /* Allows to change the properties of an existing blocker. */ @@ -1290,7 +1304,7 @@ oc.BlockerEditForm = React.createClass({ oc.BlockerEditForm.render = function(element, event, onSubmit) { ReactDOM.render( , element); @@ -1303,7 +1317,7 @@ oc.ExportForm = React.createClass({ getInitialState: function() { var state = { start: this.props.start.format('YYYY-MM-DD'), - end: this.props.end.format('YYYY-MM-DD'), + end: this.props.end.format('YYYY-MM-DD') }; if (this.props.accepted) { state.accepted = true; diff --git a/src/onegov/org/assets/js/reservationlist.jsx b/src/onegov/org/assets/js/reservationlist.jsx index 7b59350040..05728f5a97 100644 --- a/src/onegov/org/assets/js/reservationlist.jsx +++ b/src/onegov/org/assets/js/reservationlist.jsx @@ -81,7 +81,8 @@ $.fn.reservationList = function(options) { event.start.format('hh:mm'), event.end.format('hh:mm'), 1, - event.wholeDay + event.wholeDay, + !singleSelect ); } e.preventDefault(); @@ -123,12 +124,13 @@ rl.post = function(list, url) { rl.request(list, url, 'ic-post-to'); }; -rl.reserve = function(list, url, start, end, quota, wholeDay) { +rl.reserve = function(list, url, start, end, quota, wholeDay, considerBlocking) { url = new Url(url); url.query.start = start; url.query.end = end; url.query.quota = quota; url.query.whole_day = wholeDay && '1' || '0'; + url.query.consider_blocking = considerBlocking && '1' || '0'; rl.post(list, url.toString()); }; @@ -157,7 +159,8 @@ rl.showActionsPopup = function(list, element, event, singleSelect) { state.start, state.end, state.quota, - state.wholeDay + state.wholeDay, + !singleSelect ); $(this).closest('.popup').popup('hide'); }); @@ -435,6 +438,7 @@ var ReservationSelection = React.createClass({ row[0].scrollIntoView(); } }, + // eslint-disable-next-line complexity render: function() { var self = this; diff --git a/src/onegov/org/forms/political_business.py b/src/onegov/org/forms/political_business.py index 49cc244404..27d8b9813c 100644 --- a/src/onegov/org/forms/political_business.py +++ b/src/onegov/org/forms/political_business.py @@ -246,7 +246,7 @@ def on_request(self) -> None: render_kw['data-no_results_text']) field.form.participant_type.meta = self.meta - field.form.participant_type.choices = [ # type: ignore[misc] + field.form.participant_type.choices = [ # type:ignore (value, self.request.translate(label) if label else label) for value, label in field.form.participant_type.choices ] diff --git a/src/onegov/org/forms/resource.py b/src/onegov/org/forms/resource.py index 4fc4a00dd5..fea5bab60b 100644 --- a/src/onegov/org/forms/resource.py +++ b/src/onegov/org/forms/resource.py @@ -8,6 +8,7 @@ from onegov.form import Form from onegov.form.errors import FormError from onegov.form.fields import ChosenSelectMultipleField +from onegov.form.fields import TreeSelectField from onegov.form.fields import MultiCheckboxField from onegov.form.filters import as_float from onegov.form.validators import ValidFormDefinition @@ -21,6 +22,9 @@ RESERVED_FIELDS, ExportToExcelWorksheets) from onegov.org.forms.util import WEEKDAYS from onegov.org.kaba import KabaApiError, KabaClient +from onegov.reservation import Resource +from sqlalchemy import func +from uuid import UUID from wtforms.fields import BooleanField from wtforms.fields import DecimalField from wtforms.fields import EmailField @@ -37,8 +41,8 @@ from typing import Any, Literal, TYPE_CHECKING if TYPE_CHECKING: from markupsafe import Markup + from onegov.form.fields import TreeSelectNode from onegov.org.request import OrgRequest - from onegov.reservation import Resource from wtforms import Field @@ -53,6 +57,18 @@ def coerce_component_tuple(value: Any) -> tuple[str, str] | None: return site_id, component +def coerce_uuid(value: object) -> UUID | None: + if isinstance(value, UUID): + return value + if value is None: + return None + if not isinstance(value, str): + raise TypeError('Value needs to be a UUID, str or None') + if value in ('', 'None'): + return None + return UUID(value) + + class ComponentSelectWidget(ChosenSelectWidget): @classmethod def render_option( @@ -92,6 +108,19 @@ class ResourceBaseForm(Form): description=_('Used to group the resource in the overview') ) + parent_id = TreeSelectField( + label=_('Parent Resource'), + description=_( + "Reservations on parent resources will block reservations " + "on this resource and vice versa. They will also show up " + "in each other's occupancy views. This will not be used for " + "grouping resources, however the parent resource will be " + "listed directly before its children if in the same group " + "regardless of title." + ), + coerce=coerce_uuid + ) + text = HtmlField( label=_('Text') ) @@ -353,16 +382,98 @@ class ResourceBaseForm(Form): def on_request(self) -> None: if hasattr(self.model, 'type'): + if self.model.type != 'room': + self.delete_field('parent_id') if self.model.type == 'daypass': self.delete_field('default_view') self.delete_field('kaba_components') return else: + if not self.request.view_name.endswith('new-room'): + self.delete_field('parent_id') if self.request.view_name.endswith('new-daypass'): self.delete_field('default_view') self.delete_field('kaba_components') + self.delete_field('parent_id') return + # NOTE: For now we only allow parent resources for rooms + if 'parent_id' in self: + default_group = self.request.translate(_('General')) + query = self.request.app.libres_resources.query().with_entities( + Resource.id, + Resource.parent_id, + Resource.title, + func.coalesce(func.nullif(Resource.group, ''), default_group), + Resource.subgroup, + ).order_by( + func.coalesce(func.nullif(Resource.group, ''), default_group), + func.coalesce( + func.nullif(Resource.subgroup, ''), + Resource.title + ), + Resource.title + ).filter(Resource.type == 'room') + if isinstance(self.model, Resource): + query = query.filter(Resource.id != self.model.id) + pruned_parent_ids = {self.model.id} + else: + pruned_parent_ids = set() + + choices: list[TreeSelectNode] = [] + current_group: TreeSelectNode | None = None + current_group_choices: list[TreeSelectNode] = [] + current_subgroup: TreeSelectNode | None = None + current_subgroup_choices: list[TreeSelectNode] = [] + for resource_id, parent_id, title, group, subgroup in query: + entry: TreeSelectNode = { + 'name': title, + 'value': str(resource_id), + 'children': () + } + # NOTE: This avoids circular references between resources + # a parent resource should not appoint one of its + # descendants as a parent. + if parent_id in pruned_parent_ids: + pruned_parent_ids.add(resource_id) + entry['disabled'] = True + + if current_group is None or current_group['name'] != group: + current_group_choices = [] + current_group = { + 'name': group, + 'value': group, + 'children': current_group_choices, + 'isGroupSelectable': False + } + choices.append(current_group) + current_subgroup = None + + if not subgroup: + current_subgroup = None + elif ( + current_subgroup is None + or current_subgroup['name'] != subgroup + ): + current_subgroup_choices = [] + current_subgroup = { + 'name': subgroup, + 'value': f'{group}__{subgroup}', + 'children': current_subgroup_choices, + 'isGroupSelectable': False + } + current_group_choices.append(current_subgroup) + + if current_subgroup is not None: + current_subgroup_choices.append(entry) + else: + current_group_choices.append(entry) + + if choices: + self.parent_id.set_choices(choices) + else: + self.delete_field('parent_id') + clients = KabaClient.from_app(self.request.app) if not clients: self.delete_field('kaba_components') diff --git a/src/onegov/org/forms/settings.py b/src/onegov/org/forms/settings.py index 8ba1b6caea..b19af553c8 100644 --- a/src/onegov/org/forms/settings.py +++ b/src/onegov/org/forms/settings.py @@ -895,7 +895,7 @@ def on_request(self) -> None: # NOTE: In order to get an OR we need to use the AND # of all the choices it can't be instead. - dependency = FieldDependency(*( # type: ignore[misc] + dependency = FieldDependency(*( # type: ignore arg for name, _ in choices if name not in providers diff --git a/src/onegov/org/models/resource.py b/src/onegov/org/models/resource.py index d5494f223b..0dabf87b87 100644 --- a/src/onegov/org/models/resource.py +++ b/src/onegov/org/models/resource.py @@ -197,13 +197,17 @@ def reservations_with_tickets_query( self, start: datetime | None = None, end: datetime | None = None, - exclude_pending: bool = True + exclude_pending: bool = True, + only_managed: bool = True ) -> Query[Reservation]: """ Returns a query which joins this resource's reservations between start and end with the tickets table. """ - query = self.scheduler.managed_reservations() + if only_managed: + query = self.scheduler.managed_reservations() + else: + query = self.scheduler.visible_reservations() if start: query = query.filter(start <= Reservation.start) if end: diff --git a/src/onegov/org/utils.py b/src/onegov/org/utils.py index f1062e395f..7eb269ec84 100644 --- a/src/onegov/org/utils.py +++ b/src/onegov/org/utils.py @@ -31,7 +31,7 @@ from onegov.reservation import Resource from onegov.ticket import Ticket, TicketCollection, TicketPermission from onegov.user import Auth, User, UserGroup -from operator import add, attrgetter +from operator import add from sqlalchemy import case, nullsfirst from webob.exc import HTTPBadRequest @@ -436,58 +436,50 @@ def as_dict(self) -> dict[str, Any]: class AllocationEventInfo: - __slots__ = ('resource', 'allocation', 'availability', 'request', - 'translate') + __slots__ = ('resource', 'allocation', 'availability', + 'partitions', 'request', 'translate') def __init__( self, resource: Resource, allocation: Allocation, availability: float, + partitions: Sequence[tuple[float, bool]], request: OrgRequest ) -> None: self.resource = resource self.allocation = allocation self.availability = availability + self.partitions = partitions self.request = request self.translate = request.translate @classmethod - def from_allocations( + def from_resource_by_range( cls, request: OrgRequest, resource: Resource, - allocations: Iterable[Allocation] + start: datetime, + end: datetime ) -> list[Self]: - events = [] - scheduler = resource.scheduler - allocations = request.exclude_invisible(allocations) - - for key, group in groupby(allocations, key=attrgetter('_start')): - grouped = tuple(group) - if len(grouped) == 1 and grouped[0].partly_available: - # in this case we might need to normalize the availability - availability = grouped[0].normalized_availability - else: - availability = scheduler.queries.availability_by_allocations( - grouped - ) - - for allocation in grouped: - if allocation.is_master: - events.append( # noqa: PERF401 - cls( - resource, - allocation, - availability, - request - ) - ) - - return events + allocations = scheduler.allocations_with_availability_by_range( + start, + end + ) + return [ + cls( + resource, + allocation, # type: ignore[arg-type] + availability, + partitions, + request + ) + for allocation, availability, partitions in allocations + if request.is_visible(allocation) + ] @property def event_start(self) -> str: @@ -682,7 +674,7 @@ def as_dict(self) -> dict[str, Any]: 'partlyAvailable': self.allocation.partly_available, 'quota': self.allocation.quota, 'quotaLeft': self.quota_left, - 'partitions': self.allocation.availability_partitions(), + 'partitions': self.partitions, 'actions': [ link(self.request) for link in self.event_actions @@ -694,16 +686,14 @@ def as_dict(self) -> dict[str, Any]: class AvailabilityEventInfo: - __slots__ = ('resource', 'allocation', 'request', 'translate') + __slots__ = ('allocation', 'request', 'translate') def __init__( self, - resource: Resource, allocation: Allocation, request: OrgRequest ) -> None: - self.resource = resource self.allocation = allocation self.request = request self.translate = request.translate @@ -712,12 +702,11 @@ def __init__( def from_allocations( cls, request: OrgRequest, - resource: Resource, allocations: Iterable[Allocation] ) -> list[Self]: return [ - cls(resource, allocation, request) + cls(allocation, request) for allocation in allocations if allocation.is_master ] @@ -760,23 +749,37 @@ def as_dict(self) -> dict[str, Any]: abs_tol=.005 ), 'wholeDay': self.allocation.whole_day, + # NOTE: We can switch to `resourceId`if we decide to include + # premium features + 'resource': str(self.allocation.mirror_of), 'kind': 'allocation', } class BlockerEventInfo: - __slots__ = ('resource', 'blocker', 'request', 'translate') + __slots__ = ( + 'resource', + 'blocker', + 'css_classes', + 'include_title', + 'request', + 'translate' + ) def __init__( self, resource: Resource, blocker: ReservationBlocker, - request: OrgRequest + request: OrgRequest, + css_classes: Collection[str] = (), + include_title: bool = False ) -> None: self.resource = resource self.blocker = blocker + self.css_classes = css_classes + self.include_title = include_title self.request = request self.translate = request.translate @@ -785,11 +788,21 @@ def from_blockers( cls, request: OrgRequest, resource: Resource, - blockers: Iterable[ReservationBlocker] + blockers: Iterable[ReservationBlocker], + blocking_resources: dict[UUID, Resource] ) -> list[Self]: + include_title = len(blocking_resources) > 0 return [ - cls(resource, blocker, request) + cls( + res := blocking_resources.get(blocker.resource, resource), + blocker, + request, + css_classes=() if res is resource else ( + 'event-blocking-resource', + ), + include_title=include_title + ) for blocker in blockers ] @@ -816,20 +829,24 @@ def editable(self) -> bool: def as_dict(self) -> dict[str, Any]: is_manager = self.request.is_manager editable = self.editable + title = reason = self.title + if self.include_title: + title = f'{title}\n{self.resource.title}' return { 'id': f'blocker-{self.blocker.id}', 'start': self.event_start, 'end': self.event_end, - 'title': self.title, + 'title': title, 'editable': editable, - 'classNames': 'event-blocker', + 'classNames': ['event-blocker', *self.css_classes], 'display': 'block', # extended properties + 'reason': reason, 'editurl': self.request.csrf_protected_url(self.request.link( self.blocker, name='adjust', query_params={'blocker-id': str(self.blocker.id)} - )) if is_manager else None, + )) if editable and is_manager else None, 'deleteurl': self.request.csrf_protected_url(self.request.link( self.blocker )) if editable and is_manager else None, @@ -838,6 +855,9 @@ def as_dict(self) -> dict[str, Any]: name='set-reason', query_params={'blocker-id': str(self.blocker.id)} )) if is_manager else None, + # NOTE: We can switch to `resourceId`if we decide to include + # premium features + 'resource': str(self.resource.id), 'kind': 'blocker', } @@ -863,6 +883,8 @@ class ReservationEventInfo: 'reservation', 'ticket', 'extra', + 'css_classes', + 'include_title', 'request', 'translate' ) @@ -874,12 +896,16 @@ def __init__( ticket: Ticket, extra: Sequence[str], request: OrgRequest, + css_classes: Collection[str] = (), + include_title: bool = False, ) -> None: self.resource = resource self.reservation = reservation self.ticket = ticket self.extra = extra + self.css_classes = css_classes + self.include_title = include_title self.request = request self.translate = request.translate @@ -888,16 +914,22 @@ def from_reservations( cls, request: OrgRequest, resource: Resource, - reservations: Iterable[tuple[Reservation, Ticket, *tuple[str, ...]]] + reservations: Iterable[tuple[Reservation, Ticket, *tuple[str, ...]]], + blocking_resources: dict[UUID, Resource] ) -> list[Self]: + include_title = len(blocking_resources) > 0 return [ cls( - resource, + res := blocking_resources.get(reservation.resource, resource), reservation, ticket, extra, - request + request, + css_classes=() if res is resource else ( + 'event-blocking-resource', + ), + include_title=include_title ) for reservation, ticket, *extra in reservations ] @@ -944,6 +976,7 @@ def event_title(self) -> str: self.ticket.tag or self.reservation.email, *self.extra, self.reservation.email if self.ticket.tag else '', + self.resource.title if self.include_title else '', self.event_time, f'{self.translate(_("Quota"))}: {self.quota}' if getattr(self.resource, 'show_quota', False) else '', @@ -958,6 +991,8 @@ def event_classes(self) -> Iterator[str]: else: yield 'event-pending' + yield from self.css_classes + @property def color(self) -> str | None: tag = self.ticket.tag @@ -1005,6 +1040,9 @@ def as_dict(self) -> dict[str, Any]: name='adjust-reservation', query_params={'reservation-id': str(self.reservation.id)} )) if is_manager else None, + # NOTE: We can switch to `resourceId`if we decide to include + # premium features + 'resource': str(self.resource.id), 'kind': 'reservation', } diff --git a/src/onegov/org/views/allocation.py b/src/onegov/org/views/allocation.py index ce073619eb..da37fbded8 100644 --- a/src/onegov/org/views/allocation.py +++ b/src/onegov/org/views/allocation.py @@ -29,7 +29,6 @@ from sedate import utcnow from sqlalchemy import or_, func from sqlalchemy.dialects.postgresql import JSON -from sqlalchemy.orm import defer, defaultload from uuid import uuid4 from webob import exc @@ -39,7 +38,6 @@ from collections.abc import Iterator from onegov.core.types import JSON_ro, RenderData from onegov.org.request import OrgRequest - from sqlalchemy.orm import Query from webob import Response type AllocationForm = ( @@ -68,23 +66,9 @@ def view_allocations_json(self: Resource, request: OrgRequest) -> JSON_ro: if not (start and end): return () - # get all allocations (including mirrors), for the availability calculation - query: Query[Allocation] - query = self.scheduler.allocations_in_range( # type:ignore[assignment] - start, end, masters_only=False) - query = query.order_by(Allocation._start) - query = query.options(defer(Allocation.data)) - query = query.options(defer(Allocation.group)) - query = query.options( - defaultload(Allocation.reserved_slots) - .defer(ReservedSlot.reservation_token) - .defer(ReservedSlot.allocation_id) - .defer(ReservedSlot.end)) - - # but only return the master allocations return tuple( - e.as_dict() for e in utils.AllocationEventInfo.from_allocations( - request, self, tuple(query) + e.as_dict() for e in utils.AllocationEventInfo.from_resource_by_range( + request, self, start, end ) ) diff --git a/src/onegov/org/views/reservation.py b/src/onegov/org/views/reservation.py index 07f28bf829..af3811d239 100644 --- a/src/onegov/org/views/reservation.py +++ b/src/onegov/org/views/reservation.py @@ -39,6 +39,7 @@ from onegov.user import Auth from onegov.user.collections import TANCollection from purl import URL +from sqlalchemy import and_, or_ from uuid import uuid4 from webob import exc, Response from wtforms import HiddenField @@ -147,6 +148,7 @@ def reserve_allocation(self: Allocation, request: OrgRequest) -> JSON_ro: quota = int(quota_str) whole_day = request.params.get('whole_day') == '1' + consider_blocking = request.params.get('consider_blocking') == '1' if self.partly_available: if self.whole_day and whole_day: @@ -223,10 +225,11 @@ def reserve_allocation(self: Allocation, request: OrgRequest) -> JSON_ro: return respond_with_error(request, err) # ...otherwise, try to reserve + scheduler = resource.scheduler try: # Todo: This entry created remained after a reservation # and the session id got lost - resource.scheduler.reserve( + scheduler.reserve( email='0xdeadbeef@example.org', # will be set later dates=(start, end), quota=quota, @@ -235,8 +238,36 @@ def reserve_allocation(self: Allocation, request: OrgRequest) -> JSON_ro: ) except LibresError as e: return respond_with_error(request, utils.get_libres_error(e, request)) - else: - return respond_with_success(request) + + if consider_blocking and scheduler.blocking_names: + blocking_resources = resource.blocking_resources() + # Remove all the temporary reservations that would overlap this one + for reservation in scheduler.session.query(Reservation).filter( + Reservation.resource.in_(blocking_resources) + ).filter(Reservation.status != 'approved').filter( + or_( + and_( + Reservation.start <= start, + start < Reservation.end + ), + and_( + start <= Reservation.start, + Reservation.start < end + ) + ) + ): + blocking_resource = blocking_resources[reservation.resource] + # we ignore reservations from different sessions + session_id = blocking_resource.bound_session_id(request) # type: ignore[attr-defined] + if reservation.session_id != session_id: + continue + + blocking_resource.scheduler.remove_reservation( + reservation.token, + reservation.id + ) + + return respond_with_success(request) @OrgApp.json( diff --git a/src/onegov/org/views/resource.py b/src/onegov/org/views/resource.py index 69b821aec2..8200325a20 100644 --- a/src/onegov/org/views/resource.py +++ b/src/onegov/org/views/resource.py @@ -12,6 +12,7 @@ from isodate import parse_date, ISO8601Error from itertools import islice from libres.db.models import ReservationBlocker +from libres.modules import rasterizer from libres.modules.errors import LibresError from math import isclose from morepath.request import Response @@ -36,7 +37,8 @@ from onegov.org.pdf.my_reservations import MyReservationsPdf from onegov.org.utils import group_by_column, keywords_first from onegov.org.views.utils import assert_citizen_logged_in -from onegov.reservation import ResourceCollection, Resource, Reservation +from onegov.reservation import Allocation, Reservation +from onegov.reservation import Resource, ResourceCollection from onegov.ticket import Ticket, TicketInvoice from operator import attrgetter, itemgetter from purl import URL @@ -55,7 +57,6 @@ from libres.db.scheduler import Scheduler from onegov.core.types import JSON_ro, RenderData from onegov.org.request import OrgRequest - from onegov.reservation import Allocation from sedate.types import DateLike from sqlalchemy.orm import Query from typing import TypedDict @@ -435,11 +436,17 @@ def spot_infos_for_free_slots( } for room in rooms: room.bind_to_libres_context(request.app.libres_context) - for allocation in request.exclude_invisible( - room.scheduler.search_allocations( + scheduler = room.scheduler + allocations = request.exclude_invisible( + scheduler.search_allocations( start, end, days=form.weekdays.data, strict=True ) - ): + ) + reserved, blocked = scheduler.reserved_slots_by_range( + min(a._start for a in allocations), + max(a._end for a in allocations), + ) + for allocation in allocations: # FIXME: libres isn't super careful about polymorphism yet # whenever we clean that up we can make Scheduler # generic and bind our subclass, so we don't have to @@ -461,7 +468,20 @@ def spot_infos_for_free_slots( ) if not allocation.partly_available: - quota_left = allocation.quota_left + quota_used = max( + ( + allocation.quota + if slot in blocked + else reserved.get(slot, 0) + for slot, __ in rasterizer.iterate_span( + allocation._start, + allocation._end, + rasterizer.MIN_RASTER + ) + ), + default=0 + ) + quota_left = max(0, allocation.quota - quota_used) availability = ( allocation.display_end() - allocation.display_start() ) / duration * 100.0 @@ -496,10 +516,20 @@ def spot_infos_for_free_slots( target_slot_end = target_slot_start + duration - free = allocation.free_slots( - target_slot_start, - target_slot_end - ) + free = [ + slot + for slot in allocation.all_slots( + target_slot_start, + target_slot_end + ) + if not any( + s in reserved or s in blocked + for s, __ in rasterizer.iterate_span( + *slot, + rasterizer.MIN_RASTER + ) + ) + ] if not free: if ( allocation.display_start() <= target_slot_start @@ -573,10 +603,20 @@ def spot_infos_for_free_slots( slots.append(spot_infos[0]) added_slots += 1 - free = allocation.free_slots( - target_start, - target_end - ) + free = [ + slot + for slot in allocation.all_slots( + target_start, + target_end + ) + if not any( + s in reserved or s in blocked + for s, __ in rasterizer.iterate_span( + *slot, + rasterizer.MIN_RASTER + ) + ) + ] if not free: continue @@ -589,7 +629,17 @@ def spot_infos_for_free_slots( # span, as long as it overlaps with our target # so people can reserve a slot that's slightly # outside their selected range if they want - allocation.free_slots(), + [ + slot + for slot in allocation.all_slots() + if not any( + s in reserved or s in blocked + for s, __ in rasterizer.iterate_span( + *slot, + rasterizer.MIN_RASTER + ) + ) + ], duration, adjustable=True ) @@ -645,9 +695,10 @@ def spot_infos_for_free_slots( auto_reserve != 'for_every_room' or len(skipped) == len(date_room_slots) ): - # date already fully reserved + # date already fully reserved, but we still add + # ourselves to the list of reserved rooms, since + # we implicitly are reserved through the other room continue - for room_id, slots in date_room_slots.items(): if ( auto_reserve == 'for_every_room' @@ -656,6 +707,17 @@ def spot_infos_for_free_slots( # already fully reserved continue + if not reserved_dates.get(date, set()).isdisjoint( + request.app.get_blocking_resource_ids(room_id) + ): + # we already reserved another room that blocks us + # since parent rooms are usually sorted before + # child rooms, this ensures we first try to + # reserve the entire thing and then fall back + # to individual subrooms + reserved_dates[date].add(room_id) + continue + for slot in slots: if isclose(slot.availability, 100.0, abs_tol=.005): try: @@ -1216,10 +1278,14 @@ def view_occupancy_json(self: Resource, request: OrgRequest) -> JSON_ro: if not (start and end): return () + scheduler = self.scheduler + # get all reservations and tickets query: Query[tuple[Reservation, Ticket, *tuple[str, ...]]] query = self.reservations_with_tickets_query( # type:ignore[attr-defined] - start, end, exclude_pending=False + start, end, + exclude_pending=False, + only_managed=False ).with_entities(Reservation, Ticket) query = query.options(undefer(Reservation.data)) if self.occupancy_fields: @@ -1232,31 +1298,33 @@ def view_occupancy_json(self: Resource, request: OrgRequest) -> JSON_ro: )) # get all blockers - blockers = self.scheduler.managed_blockers() + blockers = scheduler.visible_blockers() blockers = blockers.filter(start <= ReservationBlocker.start) blockers = blockers.filter(ReservationBlocker.end <= end) + blocking_resources = self.blocking_resources() return *( res.as_dict() for res in utils.ReservationEventInfo.from_reservations( request, self, - query + query, + blocking_resources ) ), *( blk.as_dict() for blk in utils.BlockerEventInfo.from_blockers( request, self, - blockers + blockers, + blocking_resources ) ), *( av.as_dict() for av in utils.AvailabilityEventInfo.from_allocations( request, - self, # get all all master allocations - self.scheduler.allocations_in_range(start, end) # type: ignore[arg-type] + scheduler.allocations_in_range(start, end) # type: ignore[arg-type] ) ) @@ -1278,9 +1346,10 @@ def view_occupancy_stats(self: Resource, request: OrgRequest) -> JSON_ro: if not (start and end): raise exc.HTTPBadRequest() + scheduler = self.scheduler accepted = func.coalesce(Reservation.data['accepted'] == True, False) stats = dict( - self.scheduler.managed_reservations() + scheduler.visible_reservations() .filter(Reservation.status == 'approved') .filter(or_( and_( @@ -1303,7 +1372,7 @@ def view_occupancy_stats(self: Resource, request: OrgRequest) -> JSON_ro: 'range': layout.format_date_range(start.date(), end.date()), 'count': stats.get(True, 0) + stats.get(False, 0), 'pending': stats.get(False, 0), - 'utilization': 100.0 - self.scheduler.availability(start, end), + 'utilization': 100.0 - scheduler.availability(start, end), } diff --git a/src/onegov/reservation/collection.py b/src/onegov/reservation/collection.py index b6c4b4876f..dce4f0918e 100644 --- a/src/onegov/reservation/collection.py +++ b/src/onegov/reservation/collection.py @@ -28,6 +28,9 @@ class ResourceCollection: """ Manages a list of resources. """ + + session: Session + def __init__(self, libres_context: Context): assert hasattr(libres_context, 'get_service'), """ The ResourceCollection expected the libres_contex, not the session. diff --git a/src/onegov/reservation/core.py b/src/onegov/reservation/core.py index 33915382b3..2a8be0b893 100644 --- a/src/onegov/reservation/core.py +++ b/src/onegov/reservation/core.py @@ -2,12 +2,15 @@ from libres.context.registry import create_default_registry from libres.db.models import ORMBase +from onegov.core.orm import orm_cached from onegov.reservation.collection import ResourceCollection +from onegov.reservation.models import Resource from uuid import UUID from typing import Any, TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Callable, Collection from libres.context.core import Context from libres.context.registry import Registry from onegov.core.orm.session_manager import SessionManager @@ -58,13 +61,15 @@ def configure_libres(self, **cfg: Any) -> None: self.libres_registry = create_default_registry() self.libres_context = self.libres_context_from_session_manager( self.libres_registry, - self.session_manager + self.session_manager, + self.get_blocking_resource_ids ) @staticmethod def libres_context_from_session_manager( registry: Registry, - session_manager: SessionManager + session_manager: SessionManager, + get_blocking_resource_ids: Callable[[UUID], Collection[UUID]] ) -> Context: if registry.is_existing_context('onegov.reservation'): @@ -82,8 +87,75 @@ def uuid_generator(name: UUID) -> UUID: context.set_service('uuid_generator', lambda ctx: uuid_generator) + context.set_service( + 'get_blocking_resource_ids', + lambda ctx: get_blocking_resource_ids + ) + return context @property def libres_resources(self) -> ResourceCollection: return ResourceCollection(self.libres_context) + + def get_blocking_resource_ids(self, resource: UUID) -> Collection[UUID]: + return self._blocking_resource_id_mapping.get(resource.hex, ()) + + @orm_cached(policy='on-table-change:resources') + def _blocking_resource_id_mapping(self) -> dict[str, frozenset[UUID]]: + session = self.session_manager.session() + child_to_parent: dict[UUID, UUID] = {} + parent_to_children: dict[UUID, set[UUID]] = {} + all_blocking_resources: dict[UUID, set[UUID]] = {} + for child_id, parent_id in session.query( + Resource.id, + Resource.parent_id + ): + # NOTE: libres gives us SoftUUIDs, which are not msgpack + # serializable, so we convert it to the base class + child_id = UUID(int=child_id.int) + all_blocking_resources[child_id] = set() + if parent_id is None: + continue + parent_id = UUID(int=parent_id.int) + child_to_parent[child_id] = parent_id + parent_to_children.setdefault(parent_id, set()).add(child_id) + + def walk_children(resource_id: UUID) -> None: + for child_id in parent_to_children.get(resource_id, ()): + if child_id in blocking_resources: + # NOTE: This means we have a cycle in our dependencies + # so we don't need to walk this again, cycles + # should be harmless, even if not ideal. + continue + + if child_id == target_id: + # NOTE: This could also happen with cycles, we don't + # need to explicitly block ourselves, we already + # do that, so we can ignore it. + continue + + blocking_resources.add(child_id) + walk_children(child_id) + + def walk_parents(resource_id: UUID | None) -> None: + if resource_id is None: + return + + if resource_id in blocking_resources or resource_id == target_id: + # NOTE: This means we have a cycle in our dependencies + # so we don't need to walk this again, cycles + # should be harmless, even if not ideal. + return + + blocking_resources.add(resource_id) + walk_parents(child_to_parent.get(resource_id)) + + for target_id, blocking_resources in all_blocking_resources.items(): + walk_children(target_id) + walk_parents(child_to_parent.get(target_id)) + + return { + resource_id.hex: frozenset(blocking) + for resource_id, blocking in all_blocking_resources.items() + } diff --git a/src/onegov/reservation/models/resource.py b/src/onegov/reservation/models/resource.py index 38ba99093c..2d60a5a39e 100644 --- a/src/onegov/reservation/models/resource.py +++ b/src/onegov/reservation/models/resource.py @@ -17,6 +17,7 @@ from onegov.form import parse_form from onegov.pay import InvoiceItemMeta, Price, process_payment from sedate import align_date_to_day, utcnow +from sqlalchemy import ForeignKey from sqlalchemy.orm import mapped_column, relationship, Mapped from uuid import uuid4, UUID @@ -25,7 +26,7 @@ if TYPE_CHECKING: # type gets shadowed by type in model, so we use Type as an alias from builtins import type as type_t - from collections.abc import Sequence + from collections.abc import Collection, Sequence from libres.context.core import Context from libres.db.scheduler import Scheduler from onegov.form import Form @@ -33,6 +34,7 @@ from onegov.pay import ( InvoiceDiscountMeta, Payment, PaymentError, PaymentProvider) from onegov.pay.types import PaymentMethod + from sqlalchemy.orm import Session type DeadlineUnit = Literal['d', 'h'] @@ -42,6 +44,7 @@ # created a subclass class _OurScheduler(Scheduler): name: UUID # type:ignore[assignment] + blocking_names: Collection[UUID] # type:ignore[assignment] @lru_cache(maxsize=1) @@ -83,6 +86,11 @@ class Resource(ORMBase, ModelBase, ContentMixin, default=uuid4 ) + #: the id of the parent resource (optional) + parent_id: Mapped[UUID | None] = mapped_column( + ForeignKey('resources.id', ondelete='SET NULL') + ) + #: a nice id for the url, readable by humans # FIXME: This probably should've been nullable=False name: Mapped[str | None] = mapped_column(unique=True) @@ -248,6 +256,36 @@ def highlight_allocations( self.date = allocations[0].start.date() + def blocking_resource_ids( + self, + libres_context: Context | None = None + ) -> set[UUID]: + assert self.id, 'the id needs to be set' + if libres_context is None: + assert hasattr( + self, 'libres_context' + ), 'not bound to libres context' + libres_context = self.libres_context + + return libres_context.get_service('get_blocking_resource_ids')(self.id) + + def blocking_resources(self) -> dict[UUID, Resource]: + resource_ids = self.blocking_resource_ids() + if not resource_ids: + return {} + session: Session = self.libres_context.get_service( + 'session_provider' + ).session() + blocking_resources = { + resource.id: resource + for resource in session.query(Resource).filter( + Resource.id.in_(resource_ids) + ) + } + for resource in blocking_resources.values(): + resource.bind_to_libres_context(self.libres_context) + return blocking_resources + def get_scheduler(self, libres_context: Context) -> _OurScheduler: assert self.id, 'the id needs to be set' assert self.timezone, 'the timezone needs to be set' @@ -258,6 +296,7 @@ def get_scheduler(self, libres_context: Context) -> _OurScheduler: libres_context, self.id, # type:ignore[arg-type] self.timezone, + blocking_names=self.blocking_resource_ids(libres_context), # type:ignore[arg-type] **extra_scheduler_arguments() ) diff --git a/src/onegov/reservation/upgrade.py b/src/onegov/reservation/upgrade.py index 6ed0cf857d..b0463a7dfd 100644 --- a/src/onegov/reservation/upgrade.py +++ b/src/onegov/reservation/upgrade.py @@ -10,7 +10,7 @@ from onegov.core.upgrade import upgrade_task from onegov.reservation import LibresIntegration from onegov.reservation import Resource -from sqlalchemy import text, Column, Enum, Text +from sqlalchemy import text, Column, Enum, ForeignKey, Text, UUID from typing import TYPE_CHECKING @@ -216,3 +216,37 @@ def make_allocation_and_reservation_type_not_nullable( WHERE type IS NULL; """)) context.operations.alter_column('reservations', 'type', nullable=False) + + +@upgrade_task('Add resource parent_id column') +def add_resource_parent_id_column(context: UpgradeContext) -> None: + if ( + context.has_table('resources') + and not context.has_column('resources', 'parent_id') + ): + context.operations.add_column( + 'resources', + Column( + 'parent_id', + UUID(as_uuid=True), + ForeignKey('resources.id', ondelete='SET NULL'), + nullable=True + ) + ) + + +@upgrade_task('Add additional indeces to reserved_slots') +def add_reserved_slots_indeces(context: UpgradeContext) -> None: + context.operations.create_index( + 'ix_reserved_slots_source_type', + 'reserved_slots', + columns=['source_type'], + if_not_exists=True + ) + context.operations.create_index( + 'start_end_tsrange_ix', + 'reserved_slots', + columns=[text('tsrange(start, "end")')], + postgresql_using='gist', + if_not_exists=True + ) diff --git a/src/onegov/search/integration.py b/src/onegov/search/integration.py index 2b9cd0e5f9..1a31853494 100644 --- a/src/onegov/search/integration.py +++ b/src/onegov/search/integration.py @@ -136,12 +136,13 @@ def fts_create_search_configurations( dict_name = 'german_unaccent' else: try: - connection.execute(text(""" - CREATE TEXT SEARCH DICTIONARY german_unaccent ( - template = unaccent, - rules = 'german' - ) - """)) + with connection.begin_nested(): + connection.execute(text(""" + CREATE TEXT SEARCH DICTIONARY german_unaccent ( + template = unaccent, + rules = 'german' + ) + """)) except Exception: index_log.exception( 'Failed to create german_unaccent dictionary ' diff --git a/src/onegov/swissvotes/fields/dataset.py b/src/onegov/swissvotes/fields/dataset.py index 0b0389486d..7b55a6d3e4 100644 --- a/src/onegov/swissvotes/fields/dataset.py +++ b/src/onegov/swissvotes/fields/dataset.py @@ -129,7 +129,7 @@ def post_validate( raise ValidationError(_('No data.')) headers = [column.value for column in next(sheet.rows)] - missing = set(mapper.columns.values()) - set(headers) # type:ignore + missing = set(mapper.columns.values()) - set(headers) if missing: raise ValidationError(_( 'Some columns are missing: ${columns}.', diff --git a/src/onegov/swissvotes/fields/metadata.py b/src/onegov/swissvotes/fields/metadata.py index 9a347793e6..58906aabd2 100644 --- a/src/onegov/swissvotes/fields/metadata.py +++ b/src/onegov/swissvotes/fields/metadata.py @@ -125,7 +125,7 @@ def post_validate( raise ValidationError(_('No data.')) headers = [column.value for column in next(sheet.rows)] - missing = set(mapper.columns.values()) - set(headers) # type:ignore + missing = set(mapper.columns.values()) - set(headers) if missing: raise ValidationError(_( 'Some columns are missing: ${columns}.', diff --git a/src/onegov/ticket/collection.py b/src/onegov/ticket/collection.py index f592b1cf52..d6be27b602 100644 --- a/src/onegov/ticket/collection.py +++ b/src/onegov/ticket/collection.py @@ -295,7 +295,7 @@ def get_count(self, excl_archived: bool = True) -> TicketCount: query = query.group_by(Ticket.state) - return TicketCount(**dict(query.tuples())) # type: ignore[misc] + return TicketCount(**dict(query.tuples())) def by_handler_data_id( self, diff --git a/src/onegov/town6/templates/macros.pt b/src/onegov/town6/templates/macros.pt index 79b35ea0a8..9a2a22d96c 100644 --- a/src/onegov/town6/templates/macros.pt +++ b/src/onegov/town6/templates/macros.pt @@ -2527,13 +2527,9 @@ ${record.title} - -

-

-
-
+
+

${lead}

+
${hint}
diff --git a/src/onegov/town6/theme/styles/fullcalendar.scss b/src/onegov/town6/theme/styles/fullcalendar.scss index fefd9f2951..a1d5a1f0c1 100644 --- a/src/onegov/town6/theme/styles/fullcalendar.scss +++ b/src/onegov/town6/theme/styles/fullcalendar.scss @@ -37,10 +37,17 @@ & .fc-event-main { position: relative; - padding: 2px 4px !important; - padding-right: 20px !important; - font-weight: bold; font-size: .875rem; + + .fc-blocker-title { + margin: 2px 4px; + overflow: hidden; + } + + .fc-blocker-reason { + font-weight: bold; + overflow: hidden; + } } & .delete-blocker { @@ -54,10 +61,18 @@ &:hover { opacity: .75; } + + + .fc-blocker-reason { + margin-right: 16px; + } } } } + .fc-event.event-blocking-resource { + opacity: .75; + } + .fc-event, .fc-event-main { padding: 0 !important; } @@ -65,12 +80,17 @@ .fc-event .fc-content { position: relative; z-index: 2; - padding: 2px 4px; + overflow: hidden; border-top-right-radius: 1.2rem; - white-space: nowrap; - &::first-line { - font-weight: bold; + .fc-reservation-title { + margin: 2px 4px; + white-space: nowrap; + overflow: hidden; + + &::first-line { + font-weight: bold; + } } } diff --git a/src/onegov/translator_directory/custom.py b/src/onegov/translator_directory/custom.py index 009e12ed58..4b6dc98546 100644 --- a/src/onegov/translator_directory/custom.py +++ b/src/onegov/translator_directory/custom.py @@ -81,7 +81,7 @@ def get_accountant_ticket_count( .tuples() ) - return TicketCount(**dict(query)) # type: ignore[misc] + return TicketCount(**dict(query)) def get_global_tools( diff --git a/src/onegov/user/auth/clients/saml2.py b/src/onegov/user/auth/clients/saml2.py index afdf777d65..1aae8b5cc0 100644 --- a/src/onegov/user/auth/clients/saml2.py +++ b/src/onegov/user/auth/clients/saml2.py @@ -42,7 +42,7 @@ def handle_logout_request( # redirect binding to be used supported_bindings = [BINDING_HTTP_REDIRECT] success = False - if logout_req.message.name_id == name_id: + if name_id and logout_req.message.name_id == name_id: try: if conn.local_logout(name_id): status = success_status_factory() diff --git a/tests/onegov/agency/test_app.py b/tests/onegov/agency/test_app.py index a796c3193d..0ae4c010dd 100644 --- a/tests/onegov/agency/test_app.py +++ b/tests/onegov/agency/test_app.py @@ -106,6 +106,7 @@ def test_app_root_pdf(agency_app: AgencyApp) -> None: assert agency_app.root_pdf_exists is False agency_app.root_pdf = BytesIO(b'PDF') + agency_app = agency_app # undo narrowing assert agency_app.root_pdf == b'PDF' assert agency_app.root_pdf_exists is True @@ -120,6 +121,7 @@ def test_app_pdf_class(agency_app: AgencyApp) -> None: assert agency_app.pdf_class == AgencyPdfAr agency_app.org.meta['pdf_layout'] = 'zg' + agency_app = agency_app # undo narrowing assert agency_app.pdf_class == AgencyPdfZg agency_app.org.meta['pdf_layout'] = '' diff --git a/tests/onegov/agency/test_forms.py b/tests/onegov/agency/test_forms.py index 743d02d707..220924b270 100644 --- a/tests/onegov/agency/test_forms.py +++ b/tests/onegov/agency/test_forms.py @@ -262,6 +262,7 @@ def test_move_agency_form(session: Session) -> None: form = MoveAgencyForm(DummyPostData({'parent_id': '10'})) form.request = DummyRequest(session, permissions=all_permissions) form.update_model(model) + model = model # undo narrowing assert model.parent_id == 10 # update with rename diff --git a/tests/onegov/core/test_orm.py b/tests/onegov/core/test_orm.py index 9cc7c4233f..3fecc16d84 100644 --- a/tests/onegov/core/test_orm.py +++ b/tests/onegov/core/test_orm.py @@ -480,7 +480,7 @@ class Test(Base): session.add(test) transaction.commit() - assert session.query(Test).one().session_manager.__repr__.__self__ is mgr # type: ignore[attr-defined] + assert session.query(Test).one().session_manager.__repr__.__self__ is mgr mgr.dispose() @@ -1666,9 +1666,9 @@ def secret_document(self) -> int | None: assert app.secret_document is None # NOTE: Undo mypy narrowing for app.first_document - app2 = app - assert app2.first_document is not None - assert app2.first_document.title == 'Public' + app = app + assert app.first_document is not None + assert app.first_document.title == 'Public' assert app.untitled_documents == [] assert app.documents[0].title == 'Public' @@ -1685,9 +1685,12 @@ def secret_document(self) -> int | None: app.session().add(Document(id=2, title='Secret', body='Geheim')) transaction.commit() + # NOTE: Undo mypy narrowing for app.first_document + app = app assert app.request_cache == {} assert app.secret_document == 2 - assert app2.first_document.title == 'Public' + assert app.first_document is not None + assert app.first_document.title == 'Public' assert app.untitled_documents == [] assert len(app.documents) == 2 diff --git a/tests/onegov/directory/test_migration.py b/tests/onegov/directory/test_migration.py index e4eb8b9ff8..4628949cb0 100644 --- a/tests/onegov/directory/test_migration.py +++ b/tests/onegov/directory/test_migration.py @@ -751,6 +751,7 @@ def test_directory_migration_for_select(session: Session) -> None: assert migration.possible migration.execute() + zoo = zoo # undo narrowing assert zoo.values['general_landscapes'] == [] assert zoo.values['general_animals'] == [] diff --git a/tests/onegov/election_day/conftest.py b/tests/onegov/election_day/conftest.py index 95f8a3fe6f..7b7108e79c 100644 --- a/tests/onegov/election_day/conftest.py +++ b/tests/onegov/election_day/conftest.py @@ -438,7 +438,7 @@ def import_elections_internal( principal_obj = create_principal(principal, municipality) session.add(election) session.flush() - errors = function_mapping[election_type]( # type: ignore[operator] + errors = function_mapping[election_type]( election, principal_obj, BytesIO(csv_file.read()), mimetype, ) assert election.title is not None diff --git a/tests/onegov/election_day/forms/test_screen_form.py b/tests/onegov/election_day/forms/test_screen_form.py index 25a44809fa..6798712e98 100644 --- a/tests/onegov/election_day/forms/test_screen_form.py +++ b/tests/onegov/election_day/forms/test_screen_form.py @@ -236,6 +236,7 @@ def test_screen_form_update_apply(session: Session) -> None: form.update_model(model) session.flush() session.expire(model) + model = model # undo narrowing assert model.type == 'election_compound_part' assert model.vote_id is None assert model.election_id is None diff --git a/tests/onegov/election_day/models/test_screen.py b/tests/onegov/election_day/models/test_screen.py index e430c793e7..55009c45cd 100644 --- a/tests/onegov/election_day/models/test_screen.py +++ b/tests/onegov/election_day/models/test_screen.py @@ -84,6 +84,7 @@ def test_screen(session: Session) -> None: screen.type = 'election_compound' session.flush() + screen = screen # undo narrowing assert screen.type == 'election_compound' assert screen.vote is None assert screen.election is None @@ -99,6 +100,7 @@ def test_screen(session: Session) -> None: screen.domain = 'domain' screen.domain_segment = 'segment' + screen = screen # undo narrowing assert screen.type == 'election_compound_part' assert screen.vote is None assert screen.election is None @@ -121,6 +123,7 @@ def test_screen(session: Session) -> None: screen.type = 'simple_vote' session.flush() + screen = screen # undo narrowing assert screen.type == 'simple_vote' assert screen.vote == vote assert screen.election is None @@ -134,6 +137,7 @@ def test_screen(session: Session) -> None: screen.type = 'complex_vote' session.flush() + screen = screen # undo narrowing assert screen.type == 'complex_vote' assert screen.vote == vote assert screen.election is None diff --git a/tests/onegov/election_day/models/test_vote.py b/tests/onegov/election_day/models/test_vote.py index 332e08236f..e2a7c5f9d0 100644 --- a/tests/onegov/election_day/models/test_vote.py +++ b/tests/onegov/election_day/models/test_vote.py @@ -160,6 +160,8 @@ def test_ballot_answer_simple(session: Session) -> None: # set results to counted for result in vote.proposal.results: result.counted = True + + vote = vote # undo narrowing assert vote.proposal.answer == 'accepted' assert vote.answer == 'accepted' diff --git a/tests/onegov/election_day/screen_widgets/test_generic_widgets.py b/tests/onegov/election_day/screen_widgets/test_generic_widgets.py index 251d6bfab9..3c4c41ed16 100644 --- a/tests/onegov/election_day/screen_widgets/test_generic_widgets.py +++ b/tests/onegov/election_day/screen_widgets/test_generic_widgets.py @@ -76,71 +76,73 @@ def test_generic_widgets() -> None: row = next(xml.iterchildren()) assert row.tag == 'div' - assert row.attrib == { # type: ignore[comparison-overlap] - 'class': 'row my-row', - 'style': 'max-width: none' - } + if not TYPE_CHECKING: + assert row.attrib == { + 'class': 'row my-row', + 'style': 'max-width: none' + } columns = list(row.iterchildren()) - assert columns[0].tag == 'div' - assert columns[0].attrib == { # type: ignore[comparison-overlap] - 'class': 'small-12 medium-1 columns my-first-column' - } - assert columns[1].tag == 'div' - assert columns[1].attrib == { # type: ignore[comparison-overlap] - 'class': 'small-12 medium-1 columns my-second-column' - } - assert columns[2].tag == 'div' - assert columns[2].attrib == { # type: ignore[comparison-overlap] - 'class': 'small-12 medium-1 columns my-third-column' - } - assert columns[3].tag == 'div' - assert columns[3].attrib == { # type: ignore[comparison-overlap] - 'class': 'small-12 medium-1 columns my-fourth-column' - } - assert columns[4].tag == 'div' - assert columns[4].attrib == { # type: ignore[comparison-overlap] - 'class': 'small-12 medium-1 columns my-fifth-column' - } - - h1 = next(columns[0].iterchildren()) - assert h1.tag == 'h1' - assert h1.attrib == { # type: ignore[comparison-overlap] - 'class': 'my-first-header' - } - - h2 = next(h1.iterchildren()) - assert h2.tag == 'h2' - assert h2.attrib == { # type: ignore[comparison-overlap] - 'class': 'my-second-header' - } - - h3 = next(h2.iterchildren()) - assert h3.tag == 'h3' - assert h3.attrib == { # type: ignore[comparison-overlap] - 'class': 'my-third-header' - } - assert h3.text == 'Title' - - hr = next(columns[1].iterchildren()) - assert hr.tag == 'hr' - assert hr.attrib == { # type: ignore[comparison-overlap] - 'class': 'my-hr' - } - - logo = next(columns[2].iterchildren()) - assert logo.tag == 'img' - assert logo.attrib == { # type: ignore[comparison-overlap] - 'class': 'my-logo', - 'src': 'logo.svg' - } - - text = next(columns[3].iterchildren()) - assert text.tag == 'p' - assert text.attrib == { # type: ignore[comparison-overlap] - 'class': 'my-text' - } - assert text.text == 'Lorem' + if not TYPE_CHECKING: + assert columns[0].tag == 'div' + assert columns[0].attrib == { + 'class': 'small-12 medium-1 columns my-first-column' + } + assert columns[1].tag == 'div' + assert columns[1].attrib == { + 'class': 'small-12 medium-1 columns my-second-column' + } + assert columns[2].tag == 'div' + assert columns[2].attrib == { + 'class': 'small-12 medium-1 columns my-third-column' + } + assert columns[3].tag == 'div' + assert columns[3].attrib == { + 'class': 'small-12 medium-1 columns my-fourth-column' + } + assert columns[4].tag == 'div' + assert columns[4].attrib == { + 'class': 'small-12 medium-1 columns my-fifth-column' + } + + h1 = next(columns[0].iterchildren()) + assert h1.tag == 'h1' + assert h1.attrib == { + 'class': 'my-first-header' + } + + h2 = next(h1.iterchildren()) + assert h2.tag == 'h2' + assert h2.attrib == { + 'class': 'my-second-header' + } + + h3 = next(h2.iterchildren()) + assert h3.tag == 'h3' + assert h3.attrib == { + 'class': 'my-third-header' + } + assert h3.text == 'Title' + + hr = next(columns[1].iterchildren()) + assert hr.tag == 'hr' + assert hr.attrib == { + 'class': 'my-hr' + } + + logo = next(columns[2].iterchildren()) + assert logo.tag == 'img' + assert logo.attrib == { + 'class': 'my-logo', + 'src': 'logo.svg' + } + + text = next(columns[3].iterchildren()) + assert text.tag == 'p' + assert text.attrib == { + 'class': 'my-text' + } + assert text.text == 'Lorem' qr_codes = list(columns[4].iterchildren()) assert len(qr_codes) == 2 diff --git a/tests/onegov/election_day/utils/test_election_compound_utils.py b/tests/onegov/election_day/utils/test_election_compound_utils.py index 2340b7857a..2030f09c97 100644 --- a/tests/onegov/election_day/utils/test_election_compound_utils.py +++ b/tests/onegov/election_day/utils/test_election_compound_utils.py @@ -702,7 +702,8 @@ def assert_node( assert parties['0']['2014']['votes']['total'] == 43062 deltas = get_party_results_deltas(election_compound, years, parties) - assert deltas[1]['2014'][0][2] == 43062 # type: ignore[comparison-overlap] + if not TYPE_CHECKING: + assert deltas[1]['2014'][0][2] == 43062 data = get_party_results_data(election_compound, False) assert isinstance(data, dict) diff --git a/tests/onegov/event/test_collections.py b/tests/onegov/event/test_collections.py index 19bd7b014b..2bd4418c2b 100644 --- a/tests/onegov/event/test_collections.py +++ b/tests/onegov/event/test_collections.py @@ -1470,7 +1470,8 @@ def test_from_ical(session: Session) -> None: transaction.commit() event = events.query().one() assert sorted(event.tags) == ['Sport'] - assert event.filter_keywords == None + if not TYPE_CHECKING: + assert event.filter_keywords is None # default keywords events.from_ical('\n'.join([ diff --git a/tests/onegov/event/test_models.py b/tests/onegov/event/test_models.py index c8fa3b2027..71972f8058 100644 --- a/tests/onegov/event/test_models.py +++ b/tests/onegov/event/test_models.py @@ -165,6 +165,7 @@ def test_event_image(test_app: TestApp, path: str) -> None: event.set_image(BytesIO(content), 'file.png') session.flush() + event = event # undo narrowing assert event.image is not None assert event.image.reference.file.read() == content diff --git a/tests/onegov/fsi/test_models.py b/tests/onegov/fsi/test_models.py index 2a6078001c..589d07e194 100644 --- a/tests/onegov/fsi/test_models.py +++ b/tests/onegov/fsi/test_models.py @@ -49,7 +49,7 @@ def test_attendee( assert subscription.attendee == attendee_ # Check the event of the the subscription - assert attendee_.subscriptions[0].course_event == course_event_ # type: ignore[union-attr] + assert attendee_.subscriptions[0].course_event == course_event_ # delete the subscription attendee_.subscriptions.remove(subscription) diff --git a/tests/onegov/gis/test_fields.py b/tests/onegov/gis/test_fields.py index 76c75d03fe..72d782bb1d 100644 --- a/tests/onegov/gis/test_fields.py +++ b/tests/onegov/gis/test_fields.py @@ -36,6 +36,7 @@ def test_coordinates_field() -> None: }).encode('ascii') ).decode('ascii')]) + field = field # undo narrowing assert field.data.lat == 47.05183585 assert field.data.lon == 8.30576869173879 assert field.data.zoom == 10 @@ -54,6 +55,7 @@ def test_coordinates_field() -> None: 'zoom': None }) + field = field # undo narrowing assert field.data.lat == 47.05183585 assert field.data.lon == 8.30576869173879 assert field.data.zoom is None diff --git a/tests/onegov/landsgemeinde/test_models.py b/tests/onegov/landsgemeinde/test_models.py index 0dcc0bc948..15f5b29143 100644 --- a/tests/onegov/landsgemeinde/test_models.py +++ b/tests/onegov/landsgemeinde/test_models.py @@ -93,10 +93,12 @@ def test_models(session: Session, assembly: Assembly) -> None: agenda_item.start_time = time(11, 10, 7) votum.start_time = time(12, 11, 5) + agenda_item, votum = agenda_item, votum # undo narrowing assert agenda_item.calculated_timestamp == '1h9m2s' assert votum.calculated_timestamp == '2h10m' assembly.start_time = None + agenda_item, votum = agenda_item, votum # undo narrowing assert agenda_item.calculated_timestamp is None assert votum.calculated_timestamp is None @@ -105,6 +107,7 @@ def test_models(session: Session, assembly: Assembly) -> None: assert votum.video_url is None assembly.video_url = 'url' + agenda_item, votum = agenda_item, votum # undo narrowing assert agenda_item.video_url == 'url' assert votum.video_url == 'url' diff --git a/tests/onegov/org/test_extensions.py b/tests/onegov/org/test_extensions.py index e6748ee006..bf4f8774df 100644 --- a/tests/onegov/org/test_extensions.py +++ b/tests/onegov/org/test_extensions.py @@ -371,6 +371,8 @@ class TopicForm(Form): form.populate_obj(topic) + topic = topic # undo narrowing + assert topic.contact == ( "Steve Jobs\n" "steve@apple.com\n" @@ -433,13 +435,12 @@ class TopicForm(Form): form.populate_obj(topic) + topic = topic # undo narrowing assert topic.contact == ( "longdomain GmbH\n" "hello@website.agency\n" "https://custom.longdomain" ) - # undo mypy narrowing - topic = topic html = topic.contact_html assert html is not None assert ' None: assert form.start_time.data == time(8, 0) assert form.end_time.data == time(9, 0) assert not form.rooms - assert 'for_every_room' not in ( # type: ignore[misc] + assert 'for_every_room' not in ( # type: ignore value for value, _label in form.auto_reserve_available_slots.choices ) @@ -962,7 +962,7 @@ def test_ticket_assignment_form(session: Session) -> None: form.request = request form.on_request() - assert sorted(name for id_, name in form.user.choices) == ['a', 'e'] # type: ignore[misc] + assert sorted(name for id_, name in form.user.choices) == ['a', 'e'] # type: ignore assert form.username == 'a' diff --git a/tests/onegov/org/test_layout.py b/tests/onegov/org/test_layout.py index 7eb4c75079..ea5ec8cbe6 100644 --- a/tests/onegov/org/test_layout.py +++ b/tests/onegov/org/test_layout.py @@ -58,8 +58,9 @@ def test_layout() -> None: # basic tests that can be done by mocking layout = DefaultLayout(MockModel(), MockRequest()) # type: ignore[arg-type] - layout.request.app = 'test' # type: ignore[assignment] - assert layout.app == 'test' # type: ignore[comparison-overlap] + if not TYPE_CHECKING: + layout.request.app = 'test' + assert layout.app == 'test' layout = DefaultLayout(MockModel(), MockRequest()) # type: ignore[arg-type] layout.request.path_info = '/' diff --git a/tests/onegov/org/test_views_settings.py b/tests/onegov/org/test_views_settings.py index d2c7cb95b0..f74ae31473 100644 --- a/tests/onegov/org/test_views_settings.py +++ b/tests/onegov/org/test_views_settings.py @@ -40,6 +40,8 @@ def test_settings(client: Client) -> None: settings = client.get('/general-settings') assert "Ungültige Farbe." not in settings.text + # undo narrowing + client = client # Form was populated with user_options default before submitting assert client.app.font_family == HELVETICA diff --git a/tests/onegov/people/test_models.py b/tests/onegov/people/test_models.py index 3d69f576cf..7b92b5dab0 100644 --- a/tests/onegov/people/test_models.py +++ b/tests/onegov/people/test_models.py @@ -134,7 +134,7 @@ def test_vcard(session: Session) -> None: assert "NOTE;CHARSET=utf-8:Has bad vision." in vcard assert "END:VCARD" in vcard - vcard = person.memberships[0].vcard() # type: ignore[union-attr] + vcard = person.memberships[0].vcard() assert "BEGIN:VCARD" in vcard assert "VERSION:3.0" in vcard assert "ADR;CHARSET=utf-8:;;Fakestreet 1;Kappel am Albis;;1234;" in vcard diff --git a/tests/onegov/reservation/conftest.py b/tests/onegov/reservation/conftest.py index 2cd858519d..108b20a390 100644 --- a/tests/onegov/reservation/conftest.py +++ b/tests/onegov/reservation/conftest.py @@ -22,5 +22,7 @@ def libres_context(session_manager: SessionManager) -> Iterator[Context]: registry = create_default_registry() yield LibresIntegration.libres_context_from_session_manager( - registry, session_manager + registry, + session_manager, + lambda resource: () ) diff --git a/tests/onegov/reservation/test_collection.py b/tests/onegov/reservation/test_collection.py index d10a935746..d691ec6075 100644 --- a/tests/onegov/reservation/test_collection.py +++ b/tests/onegov/reservation/test_collection.py @@ -69,6 +69,7 @@ def test_resource_highlight_allocations(libres_context: Context) -> None: resource.highlight_allocations(allocations) + resource = resource # undo narrowing assert resource.date == date(2015, 8, 5) assert resource.highlights_min == allocations[0].id assert resource.highlights_min == allocations[-1].id diff --git a/tests/onegov/swissvotes/test_collections.py b/tests/onegov/swissvotes/test_collections.py index ad0096f968..2f4ddcf145 100644 --- a/tests/onegov/swissvotes/test_collections.py +++ b/tests/onegov/swissvotes/test_collections.py @@ -118,19 +118,20 @@ def test_votes_default(swissvotes_app: TestApp) -> None: sort_by=13, # type: ignore[arg-type] sort_order=14 # type: ignore[arg-type] ) - assert votes.page == 2 - assert votes.from_date == 3 - assert votes.to_date == 4 - assert votes.legal_form == 5 # type: ignore[comparison-overlap] - assert votes.result == 6 # type: ignore[comparison-overlap] - assert votes.policy_area == 7 # type: ignore[comparison-overlap] - assert votes.term == 8 # type: ignore[comparison-overlap] - assert votes.full_text == 9 - assert votes.position_federal_council == 10 # type: ignore[comparison-overlap] - assert votes.position_national_council == 11 # type: ignore[comparison-overlap] - assert votes.position_council_of_states == 12 # type: ignore[comparison-overlap] - assert votes.sort_by == 13 # type: ignore[comparison-overlap] - assert votes.sort_order == 14 # type: ignore[comparison-overlap] + if not TYPE_CHECKING: + assert votes.page == 2 + assert votes.from_date == 3 + assert votes.to_date == 4 + assert votes.legal_form == 5 + assert votes.result == 6 + assert votes.policy_area == 7 + assert votes.term == 8 + assert votes.full_text == 9 + assert votes.position_federal_council == 10 + assert votes.position_national_council == 11 + assert votes.position_council_of_states == 12 + assert votes.sort_by == 13 + assert votes.sort_order == 14 votes = votes.default() assert votes.page == 0 diff --git a/tests/onegov/swissvotes/test_forms.py b/tests/onegov/swissvotes/test_forms.py index 32e66481a7..cdd6dbfd61 100644 --- a/tests/onegov/swissvotes/test_forms.py +++ b/tests/onegov/swissvotes/test_forms.py @@ -435,6 +435,7 @@ def test_search_form(swissvotes_app: TestApp) -> None: form.apply_model(votes) + form = form # undo narrowing assert form.from_date.data == date(2010, 1, 1) assert form.to_date.data == date(2010, 12, 31) assert form.legal_form.data == [1, 2] diff --git a/tests/onegov/ticket/test_model.py b/tests/onegov/ticket/test_model.py index cfec14db34..e534426774 100644 --- a/tests/onegov/ticket/test_model.py +++ b/tests/onegov/ticket/test_model.py @@ -37,44 +37,44 @@ def test_transitions(session: Session) -> None: ticket.accept_ticket(user) # undo mypy narrowing of state - ticket2 = ticket - assert ticket2.state == 'pending' + ticket = ticket + assert ticket.state == 'pending' assert ticket.user == user ticket.accept_ticket(user) # idempotent.. - assert ticket2.state == 'pending' + assert ticket.state == 'pending' assert ticket.user == user with pytest.raises(InvalidStateChange): ticket.accept_ticket(User()) # ..unless it's another user ticket.reopen_ticket(user) # idempotent as well -> would lead to no change - assert ticket2.state == 'pending' + assert ticket.state == 'pending' assert ticket.user == user # undo mypy narrowing of state - ticket2 = ticket + ticket = ticket ticket.close_ticket() - assert ticket2.state == 'closed' + assert ticket.state == 'closed' assert ticket.user == user ticket.close_ticket() # idempotent - assert ticket2.state == 'closed' + assert ticket.state == 'closed' assert ticket.user == user with pytest.raises(InvalidStateChange): ticket.accept_ticket(user) # undo mypy narrowing of state - ticket2 = ticket + ticket = ticket another_user = User() ticket.reopen_ticket(another_user) - assert ticket2.state == 'pending' - assert ticket2.user is another_user + assert ticket.state == 'pending' + assert ticket.user is another_user ticket.reopen_ticket(another_user) # idempotent.. - assert ticket2.state == 'pending' - assert ticket2.user is another_user + assert ticket.state == 'pending' + assert ticket.user is another_user with pytest.raises(InvalidStateChange): ticket.reopen_ticket(user) # ..unless it's another user @@ -103,6 +103,7 @@ def test_process_time(session: Session) -> None: ticket.accept_ticket(user) + ticket = ticket # undo narrowing assert ticket.reaction_time == 10 assert ticket.process_time is None assert ticket.current_process_time == 0 @@ -117,6 +118,7 @@ def test_process_time(session: Session) -> None: ticket.close_ticket() + ticket = ticket # undo narrowing assert ticket.reaction_time == 10 assert ticket.process_time == 10 assert ticket.current_process_time == 10 diff --git a/tests/onegov/town6/test_layout.py b/tests/onegov/town6/test_layout.py index 195227571d..cf21d0f546 100644 --- a/tests/onegov/town6/test_layout.py +++ b/tests/onegov/town6/test_layout.py @@ -58,7 +58,8 @@ def test_layout() -> None: layout = DefaultLayout(MockModel(), MockRequest()) # type: ignore[arg-type] layout.request.app = 'test' # type: ignore[assignment] - assert layout.app == 'test' # type: ignore[comparison-overlap] + if not TYPE_CHECKING: + assert layout.app == 'test' # type: ignore[comparison-overlap] layout = DefaultLayout(MockModel(), MockRequest()) # type: ignore[arg-type] layout.request.path_info = '/' diff --git a/tests/onegov/translator_directory/test_models.py b/tests/onegov/translator_directory/test_models.py index 92f1583663..7bd70f42f4 100644 --- a/tests/onegov/translator_directory/test_models.py +++ b/tests/onegov/translator_directory/test_models.py @@ -85,11 +85,13 @@ def test_translator_model(translator_app: TestApp) -> None: ) translator.expertise_professional_guilds_other = ['Psychologie'] + translator = translator # undo narrowing assert translator.expertise_professional_guilds_all == ( 'economy', 'art_leisure', 'Psychologie' ) translator.expertise_professional_guilds = [] + translator = translator # undo narrowing assert translator.expertise_professional_guilds_all == ('Psychologie', ) @@ -116,12 +118,14 @@ def test_translator_user(session: Session) -> None: session.flush() session.expire_all() assert translator.user == user_a + user_a, user_b = user_a, user_b # undo narrowing assert user_a.translator == translator # type: ignore[attr-defined] assert user_b.translator is None # type: ignore[attr-defined] translator.email = 'b@example.org' session.flush() session.expire_all() + user_a, user_b = user_a, user_b # undo narrowing assert translator.user == user_b assert user_a.translator is None # type: ignore[attr-defined] assert user_b.translator == translator # type: ignore[attr-defined] @@ -389,7 +393,8 @@ def test_translator_mutation(session: Session) -> None: assert translator.self_employed is False assert translator.gender == 'M' assert translator.date_of_birth == date(1970, 1, 1) - assert translator.nationalities == 'nationalities' # type: ignore[comparison-overlap] + if not TYPE_CHECKING: + assert translator.nationalities == 'nationalities' assert translator.coordinates == Coordinates(1, 2) assert translator.address == 'Street and house number' assert translator.zip_code == '8000' @@ -471,7 +476,7 @@ def test_accreditation(translator_app: TestApp) -> None: with freeze_time('2026-01-01') as today: accreditation.grant() # undo mypy narrowing - translator = translator + translator, ticket = translator, ticket assert ticket.handler.state == 'granted' assert translator.state == 'published' assert translator.date_of_decision == today().date() diff --git a/tests/onegov/websockets/test_cli.py b/tests/onegov/websockets/test_cli.py index 8d53be355c..074728df4f 100644 --- a/tests/onegov/websockets/test_cli.py +++ b/tests/onegov/websockets/test_cli.py @@ -189,6 +189,7 @@ def test_cli_broadcast( assert connect.call_args[0][0] == 'wss://govikon.org/ws' assert authenticate.call_count == 2 assert authenticate.call_args[0][1] == 'not-so-secret-token' + broadcast = broadcast # undo narrowing assert broadcast.call_count == 2 assert broadcast.call_args[0][1] == 'foo-baz' assert broadcast.call_args[0][2] == 'one' @@ -207,13 +208,12 @@ def test_cli_broadcast( assert connect.call_args[0][0] == websocket_config['url'] assert authenticate.call_count == 3 assert authenticate.call_args[0][1] == 'super-super-secret-token' - # NOTE: undo mypy narrowing of call_args - broadcast2 = broadcast - assert broadcast2.call_count == 3 - assert broadcast2.call_args[0][1] == 'foo-bar' - assert broadcast2.call_args[0][2] - assert broadcast2.call_args[0][3] == {'a': 'b'} - assert f'foo-bar-{broadcast2.call_args[0][2]}' in result.output + broadcast = broadcast # undo narrowing + assert broadcast.call_count == 3 + assert broadcast.call_args[0][1] == 'foo-bar' + assert broadcast.call_args[0][2] + assert broadcast.call_args[0][3] == {'a': 'b'} + assert f'foo-bar-{broadcast.call_args[0][2]}' in result.output @patch('onegov.websockets.cli.connect') @@ -251,6 +251,7 @@ def test_cli_listen( assert result.exit_code == 0 assert connect.call_count == 2 assert connect.call_args[0][0] == 'wss://govikon.org/ws' + register = register # undo narrowing assert register.call_count == 2 assert register.call_args[0][1] == 'foo-baz' assert register.call_args[0][2] == 'one' @@ -265,9 +266,8 @@ def test_cli_listen( assert result.exit_code == 0 assert connect.call_count == 3 assert connect.call_args[0][0] == websocket_config['url'] - # NOTE: undo mypy narrowing of call_args - register2 = register - assert register2.call_count == 3 - assert register2.call_args[0][1] == 'foo-bar' - assert register2.call_args[0][2] - assert f'foo-bar-{register2.call_args[0][2]}' in result.output + register = register # undo narrowing + assert register.call_count == 3 + assert register.call_args[0][1] == 'foo-bar' + assert register.call_args[0][2] + assert f'foo-bar-{register.call_args[0][2]}' in result.output From 58411ef8bc47ca7261d8186ab6f4210626da0c6a Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 8 Apr 2026 16:06:38 +0200 Subject: [PATCH 2/9] Fixes regressions --- src/onegov/core/orm/session_manager.py | 17 +++++ .../onboarding/models/town_assistant.py | 70 ++++++++++--------- src/onegov/org/forms/resource.py | 1 - src/onegov/org/views/resource.py | 3 + 4 files changed, 56 insertions(+), 35 deletions(-) diff --git a/src/onegov/core/orm/session_manager.py b/src/onegov/core/orm/session_manager.py index ffca3c03c4..07c141d762 100644 --- a/src/onegov/core/orm/session_manager.py +++ b/src/onegov/core/orm/session_manager.py @@ -289,6 +289,7 @@ def on_delete(schema, session, obj): self._ignore_bulk_updates = False self._ignore_bulk_deletes = False + self._change_signals_disabled = False self.on_schema_init = Signal() self.on_transaction_join = Signal() @@ -383,6 +384,16 @@ def ignore_bulk_deletes(self) -> Iterator[Self]: finally: self._ignore_bulk_deletes = previous_state + @contextmanager + def disable_change_signals(self) -> Iterator[Self]: + """ Disables the insert/update/delete signals temporarily. """ + previous_state = self._change_signals_disabled + self._change_signals_disabled = True + try: + yield self + finally: + self._change_signals_disabled = previous_state + def register_engine(self, engine: Engine) -> None: """ Takes the given engine and registers it with the schema switching mechanism. Maybe used to register external engines with @@ -470,6 +481,9 @@ def augment_bulk_updates_and_inserts( if not orm_execution_state.is_orm_statement: return None + if self._change_signals_disabled: + return None + stmt = orm_execution_state.statement if isinstance(stmt, Update): if self._ignore_bulk_updates: @@ -557,6 +571,9 @@ def on_after_flush( session: Session, flush_context: Any ) -> None: + if self._change_signals_disabled: + return None + if self.on_insert.receivers: for obj in session.new: self.on_insert.send(self.current_schema, obj=obj) diff --git a/src/onegov/onboarding/models/town_assistant.py b/src/onegov/onboarding/models/town_assistant.py index 544bba5df8..74819c05a6 100644 --- a/src/onegov/onboarding/models/town_assistant.py +++ b/src/onegov/onboarding/models/town_assistant.py @@ -177,40 +177,42 @@ def add_town( schema = self.get_schema(name) custom_config = self.config['configuration'] - self.app.session_manager.set_current_schema(schema) - session = self.app.session_manager.session() - - if session.query(Organisation).first(): - raise AlreadyExistsError - - with self.app.temporary_depot(schema, **custom_config): - create_new_organisation(self.app, name=name, reply_to=user) - - org = session.query(Organisation).first() - assert org is not None and org.theme_options is not None - org.theme_options['primary-color-ui'] = color - - users = UserCollection(self.app.session_manager.session()) - assert not users.query().first() - - users.add(user, password, 'admin') - - title = request.translate(_('Welcome to OneGov Cloud')) - welcome_mail = render_template('mail_welcome.pt', request, { - 'url': 'https://{}'.format(self.get_domain(name)), - 'mail': user, - 'layout': MailLayout(self, request), - 'title': title, - 'org': name - }) - - self.app.perform_reindex() - self.app.send_transactional_email( - subject=title, - receivers=(user, ), - content=welcome_mail, - reply_to='onegov@seantis.ch' - ) + with self.app.session_manager.disable_change_signals(): + self.app.session_manager.set_current_schema(schema) + session = self.app.session_manager.session() + + if session.query(Organisation).first(): + raise AlreadyExistsError + + with self.app.temporary_depot(schema, **custom_config): + create_new_organisation(self.app, name=name, reply_to=user) + + org = session.query(Organisation).first() + assert org is not None and org.theme_options is not None + org.theme_options['primary-color-ui'] = color + + users = UserCollection(self.app.session_manager.session()) + assert not users.query().first() + + users.add(user, password, 'admin') + + title = request.translate(_('Welcome to OneGov Cloud')) + welcome_mail = render_template('mail_welcome.pt', request, { + 'url': 'https://{}'.format(self.get_domain(name)), + 'mail': user, + 'layout': MailLayout(self, request), + 'title': title, + 'org': name + }) + + self.app.perform_reindex() + self.app.send_transactional_email( + subject=title, + receivers=(user, ), + content=welcome_mail, + reply_to='onegov@seantis.ch' + ) + session.flush() finally: self.app.session_manager.set_current_schema(current_schema) diff --git a/src/onegov/org/forms/resource.py b/src/onegov/org/forms/resource.py index fea5bab60b..189ccb1b8a 100644 --- a/src/onegov/org/forms/resource.py +++ b/src/onegov/org/forms/resource.py @@ -394,7 +394,6 @@ def on_request(self) -> None: if self.request.view_name.endswith('new-daypass'): self.delete_field('default_view') self.delete_field('kaba_components') - self.delete_field('parent_id') return # NOTE: For now we only allow parent resources for rooms diff --git a/src/onegov/org/views/resource.py b/src/onegov/org/views/resource.py index 8200325a20..2dd6373dff 100644 --- a/src/onegov/org/views/resource.py +++ b/src/onegov/org/views/resource.py @@ -442,6 +442,9 @@ def spot_infos_for_free_slots( start, end, days=form.weekdays.data, strict=True ) ) + if not allocations: + continue + reserved, blocked = scheduler.reserved_slots_by_range( min(a._start for a in allocations), max(a._end for a in allocations), From 739ac349ced6d8364fbfebecd635c558d52a1214 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 8 Apr 2026 16:18:45 +0200 Subject: [PATCH 3/9] Updates stubtest_allowlist for new mypy version [skip ci] --- tests/stubtest/morepath_allowlist.txt | 8 ++++++++ tests/stubtest/reg_allowlist.txt | 1 - tests/stubtest/webtest_allowlist.txt | 2 -- tests/stubtest/wtforms_allowlist.txt | 17 ----------------- 4 files changed, 8 insertions(+), 20 deletions(-) diff --git a/tests/stubtest/morepath_allowlist.txt b/tests/stubtest/morepath_allowlist.txt index 4ab6a6dacf..2ccd3a8761 100644 --- a/tests/stubtest/morepath_allowlist.txt +++ b/tests/stubtest/morepath_allowlist.txt @@ -25,6 +25,14 @@ morepath\.(app\.)?App\.verify_identity morepath\.(app\.)?App\.dump_json morepath\.(app\.)?App\.link_prefix +# Error: is inconsistent +# ====================== +# dispatch_methods generate custom code for efficiency, but we use +# a descriptor to simulate the semantics, which looks different than +# the generated function +morepath\.(app\.)?App\._permits +morepath\.(app\.)?App\.get_view + # Error: is not present at runtime # ====================== # These __getattr__ need to be there so mypy is aware that these objects diff --git a/tests/stubtest/reg_allowlist.txt b/tests/stubtest/reg_allowlist.txt index dbd0ad8132..ec4490bfe0 100644 --- a/tests/stubtest/reg_allowlist.txt +++ b/tests/stubtest/reg_allowlist.txt @@ -2,7 +2,6 @@ # ====================== # this method gets auto generated when the class is initalized # as such stubtest can't know it's there -reg.Dispatch.call reg.dispatch.Dispatch.call # Error: failed to find stubs # ====================== diff --git a/tests/stubtest/webtest_allowlist.txt b/tests/stubtest/webtest_allowlist.txt index abc066459e..53b37bbb2f 100644 --- a/tests/stubtest/webtest_allowlist.txt +++ b/tests/stubtest/webtest_allowlist.txt @@ -17,7 +17,6 @@ webtest.utils # quite accurate, but necessary for inferred covariance with # PEP-695 auto-variance webtest.app.TestApp.app -webtest.TestApp.app # error: variable differs from runtime type # ========================================= @@ -25,4 +24,3 @@ webtest.TestApp.app # normal use of WebTest, so it seems more pragmatic to treat # it as always non-`None` webtest.response.TestResponse.request -webtest.TestResponse.request diff --git a/tests/stubtest/wtforms_allowlist.txt b/tests/stubtest/wtforms_allowlist.txt index 67e79d6199..4fbdc324bf 100644 --- a/tests/stubtest/wtforms_allowlist.txt +++ b/tests/stubtest/wtforms_allowlist.txt @@ -2,17 +2,11 @@ # ============================= # This is hack to get around Field.__new__ not being able to return # UnboundField -wtforms.Field.__get__ -wtforms.fields.Field.__get__ wtforms.fields.core.Field.__get__ # Since DefaultMeta can contain arbitrary values we added __getattr__ # to let mypy know that arbitrary attribute access is possible wtforms.meta.DefaultMeta.__getattr__ # The API intends for these to be settable -wtforms.Flags.__delattr__ -wtforms.Flags.__setattr__ -wtforms.fields.Flags.__delattr__ -wtforms.fields.Flags.__setattr__ wtforms.fields.core.Flags.__delattr__ wtforms.fields.core.Flags.__setattr__ @@ -25,22 +19,11 @@ wtforms.fields.core.Flags.__setattr__ # it will always be there and on the class it depends, so maybe this # should use a dummy descriptor? For now we just pretend it's set. # The behavior is documented in FormMeta, so I think it's fine. -wtforms.Form._unbound_fields wtforms.form.Form._unbound_fields # widget is both used as a ClassVar and instance variable and does # not necessarily reflect an upper bound on Widget, so we always use # our Widget Protocol definition that's contravariant on Self -wtforms.Field.widget -wtforms.FormField.widget -wtforms.SelectField.widget -wtforms.SelectMultipleField.widget -wtforms.TextAreaField.widget -wtforms.fields.Field.widget -wtforms.fields.FormField.widget -wtforms.fields.SelectField.widget -wtforms.fields.SelectMultipleField.widget -wtforms.fields.TextAreaField.widget wtforms.fields.choices.SelectField.widget wtforms.fields.choices.SelectMultipleField.widget wtforms.fields.core.Field.widget From 95ed8e7144598d75eb836257e60b85a12333277e Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 8 Apr 2026 16:32:54 +0200 Subject: [PATCH 4/9] Updates translations --- .../locale/de_CH/LC_MESSAGES/onegov.org.po | 20 +++++++++++++++++-- .../locale/fr_CH/LC_MESSAGES/onegov.org.po | 20 +++++++++++++++++-- .../locale/it_CH/LC_MESSAGES/onegov.org.po | 20 +++++++++++++++++-- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po index a39d413953..dd48928dfd 100644 --- a/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2026-03-31 12:57+0200\n" +"POT-Creation-Date: 2026-04-08 16:32+0200\n" "PO-Revision-Date: 2022-03-15 10:21+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: German\n" @@ -197,8 +197,8 @@ msgstr "Täglicher Newsletter ${time}" msgid "${org} OneGov Cloud Status" msgstr "${org} OneGov Cloud Status" -#. get the resource titles and ids #. +#. get the resource titles and ids msgid "General" msgstr "Allgemein" @@ -1751,6 +1751,22 @@ msgstr "Dient zur Gruppierung in der Übersicht" msgid "Subgroup" msgstr "Untergruppe" +msgid "Parent Resource" +msgstr "Übergeordnete Ressource" + +msgid "" +"Reservations on parent resources will block reservations on this resource " +"and vice versa. They will also show up in each other's occupancy views. This " +"will not be used for grouping resources, however the parent resource will be " +"listed directly before its children if in the same group regardless of title." +msgstr "" +"Reservationen auf der übergeordneten Ressource blockieren den reservierten " +"Bereich für diese Ressource und umgekehrt. Zudem erscheinen Reservationen " +"von den über- und untergeordneten Ressourcen in der Belegungsansicht. Diese " +"Einstellung wird nicht zur Gruppierung von Ressourcen verwendet, allerdings " +"werden Überresourcen in der gleichen Gruppe/Untergruppe vor den " +"dazugehörigen Unterresourcen sortiert." + msgid "Additional information for confirmed reservations" msgstr "Zusätzliche Informationen für bestätigte Reservationen" diff --git a/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po index 8221908ab2..ae98cd2372 100644 --- a/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2026-03-31 12:57+0200\n" +"POT-Creation-Date: 2026-04-08 16:32+0200\n" "PO-Revision-Date: 2022-03-15 10:50+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: French\n" @@ -196,8 +196,8 @@ msgstr "Newsletter quotidienne ${time}" msgid "${org} OneGov Cloud Status" msgstr "Statut du OneGov Cloud de ${org}" -#. get the resource titles and ids #. +#. get the resource titles and ids msgid "General" msgstr "Général" @@ -1751,6 +1751,22 @@ msgstr "Utilisé pour grouper la ressource dans la vue d'ensemble" msgid "Subgroup" msgstr "Sous-groupe" +msgid "Parent Resource" +msgstr "Ressource supérieure" + +msgid "" +"Reservations on parent resources will block reservations on this resource " +"and vice versa. They will also show up in each other's occupancy views. This " +"will not be used for grouping resources, however the parent resource will be " +"listed directly before its children if in the same group regardless of title." +msgstr "" +"Les réservations effectuées sur une super-ressource bloqueront les " +"réservations sur cette ressource, et inversement. Elles apparaîtront " +"également dans les vues d'occupation de chacune d'entre elles. Cette " +"fonctionnalité ne servira pas à regrouper les ressources ; toutefois, la " +"super-ressource sera répertoriée juste avant ses sous-ressources si elles " +"appartiennent au même groupe, quel que soit leur titre." + msgid "Additional information for confirmed reservations" msgstr "Informations supplémentaires pour les réservations confirmées" diff --git a/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po index f0003564a7..7ff338a2bd 100644 --- a/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2026-03-31 12:57+0200\n" +"POT-Creation-Date: 2026-04-08 16:32+0200\n" "PO-Revision-Date: 2022-03-15 10:52+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -198,8 +198,8 @@ msgstr "Newsletter giornaliera ${time}" msgid "${org} OneGov Cloud Status" msgstr "${org} OneGov Cloud Status" -#. get the resource titles and ids #. +#. get the resource titles and ids msgid "General" msgstr "Generale" @@ -1754,6 +1754,22 @@ msgstr "Utilizzato per raggruppare la risorsa nella panoramica" msgid "Subgroup" msgstr "Sottogruppo" +msgid "Parent Resource" +msgstr "Risorsa superiore" + +msgid "" +"Reservations on parent resources will block reservations on this resource " +"and vice versa. They will also show up in each other's occupancy views. This " +"will not be used for grouping resources, however the parent resource will be " +"listed directly before its children if in the same group regardless of title." +msgstr "" +"Le prenotazioni sulle risorse superiori bloccheranno quelle su questa " +"risorsa e viceversa. Inoltre, appariranno nelle rispettive visualizzazioni " +"di occupazione. Ciò non verrà utilizzato per raggruppare le risorse; " +"tuttavia, la risorsa principale verrà elencata immediatamente prima delle " +"risorse subordinate se appartenenti allo stesso gruppo, indipendentemente " +"dal titolo." + msgid "Additional information for confirmed reservations" msgstr "Informazioni aggiuntive per prenotazioni confermate" From 57650233d68f693b8134d2474a59175d2a84905e Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 8 Apr 2026 17:20:45 +0200 Subject: [PATCH 5/9] Fixes sorting of sub-resources in resources view --- src/onegov/org/views/resource.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/onegov/org/views/resource.py b/src/onegov/org/views/resource.py index 2dd6373dff..109ad3d225 100644 --- a/src/onegov/org/views/resource.py +++ b/src/onegov/org/views/resource.py @@ -191,9 +191,11 @@ def from_grouped( for group, items in grouped.items(): entries: dict[str, Any] = {} group_has_find_your_spot = False + rooms: dict[tuple[UUID, str], Resource] = {} for item in items: is_room = isinstance(item, Resource) and item.type == 'room' if is_room: + rooms[item.id, item.subgroup or ''] = item # type: ignore group_has_find_your_spot = True if subgroup_name := getattr(item, 'subgroup', None): @@ -212,12 +214,36 @@ def from_grouped( title = f'{title} ' entries[title] = item + # sorts parents before their children in the same subgroup + def subgroup_sort_key(item: Any) -> tuple[str, ...]: + key = [item.title] + if isinstance(item, Resource): + seen = {item.id} + while ( + item.parent_id is not None + # avoid infinite loop when there is a cycle + and item.parent_id not in seen + and (parent := rooms.get(( # noqa: B023 + item.parent_id, + item.subgroup or '' + ))) is not None + ): + item = parent + seen.add(item.id) + key.append(item.title) + return tuple(reversed(key)) + + def group_sort_key(item: tuple[str, Any]) -> tuple[str, ...]: + if isinstance(item[1], Resource): + return subgroup_sort_key(item[1]) + return (item[0],) + result.append(cls( title=group, entries=[ ResourceSubgroup( title=subgroup, - entries=sorted(entry[1], key=attrgetter('title')), + entries=sorted(entry[1], key=subgroup_sort_key), find_your_spot=FindYourSpotCollection( request.app.libres_context, group=None if group == default_group else group, @@ -226,7 +252,7 @@ def from_grouped( ) if isinstance(entry, tuple) else entry for subgroup, entry in sorted( entries.items(), - key=itemgetter(0) + key=group_sort_key ) ], find_your_spot=FindYourSpotCollection( From 70dc7bc5d8bad211782212550b6ba34a31e21907 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 9 Apr 2026 07:58:44 +0200 Subject: [PATCH 6/9] Revert "Fixes a bunch of unrelated new mypy errors." This reverts commit 293064f5cecd5e2c514d144c023272a58858ca2a. --- src/onegov/election_day/utils/common.py | 4 ++-- src/onegov/feriennet/views/invoice.py | 2 +- src/onegov/org/forms/political_business.py | 2 +- src/onegov/org/forms/settings.py | 2 +- src/onegov/swissvotes/fields/dataset.py | 2 +- src/onegov/swissvotes/fields/metadata.py | 2 +- src/onegov/ticket/collection.py | 2 +- src/onegov/translator_directory/custom.py | 2 +- src/onegov/user/auth/clients/saml2.py | 2 +- tests/onegov/agency/test_app.py | 4 ++-- tests/onegov/agency/test_forms.py | 2 +- tests/onegov/core/test_orm.py | 4 ++-- tests/onegov/directory/test_migration.py | 2 +- tests/onegov/election_day/conftest.py | 2 +- .../election_day/forms/test_screen_form.py | 2 +- .../onegov/election_day/models/test_screen.py | 4 ++-- tests/onegov/election_day/models/test_vote.py | 2 +- .../screen_widgets/test_generic_widgets.py | 24 +++++++++---------- .../utils/test_election_compound_utils.py | 2 +- tests/onegov/event/test_collections.py | 2 +- tests/onegov/event/test_models.py | 2 +- tests/onegov/fsi/test_models.py | 2 +- tests/onegov/gis/test_fields.py | 4 ++-- tests/onegov/landsgemeinde/test_models.py | 2 +- tests/onegov/org/test_extensions.py | 4 ++-- tests/onegov/org/test_forms.py | 4 ++-- tests/onegov/org/test_layout.py | 4 ++-- tests/onegov/org/test_views_settings.py | 2 +- tests/onegov/people/test_models.py | 2 +- tests/onegov/reservation/test_collection.py | 2 +- tests/onegov/swissvotes/test_collections.py | 16 ++++++------- tests/onegov/swissvotes/test_forms.py | 2 +- tests/onegov/ticket/test_model.py | 4 ++-- tests/onegov/town6/test_layout.py | 4 ++-- .../translator_directory/test_models.py | 18 +++++++------- tests/onegov/websockets/test_cli.py | 4 ++-- 36 files changed, 73 insertions(+), 73 deletions(-) diff --git a/src/onegov/election_day/utils/common.py b/src/onegov/election_day/utils/common.py index adbfd1d7d6..98ac0cbc3f 100644 --- a/src/onegov/election_day/utils/common.py +++ b/src/onegov/election_day/utils/common.py @@ -151,7 +151,7 @@ def get_parameter[T, ParamT: (int, bool, list[Any])]( if type_ is bool: try: result = request.params[name].lower().strip() # type:ignore - return result in ('true', '1') if result else default + return result in ('true', '1') if result else default # type: ignore[return-value] except Exception: return default @@ -165,7 +165,7 @@ def get_parameter[T, ParamT: (int, bool, list[Any])]( try: result = request.params[name].split(',') # type:ignore result = [item.strip() for item in result if item.strip()] - return result if result else default + return result if result else default # type: ignore[return-value] except Exception: return default diff --git a/src/onegov/feriennet/views/invoice.py b/src/onegov/feriennet/views/invoice.py index 530669f54c..8764c92cfb 100644 --- a/src/onegov/feriennet/views/invoice.py +++ b/src/onegov/feriennet/views/invoice.py @@ -311,7 +311,7 @@ def handle_donation( if donation: amount = f'{donation.amount:.2f}' - for key, value in form.amount.choices: # type:ignore[misc,str-unpack] + for key, value in form.amount.choices: # type:ignore[misc] if key == amount: form.amount.data = amount break diff --git a/src/onegov/org/forms/political_business.py b/src/onegov/org/forms/political_business.py index c33d6d1582..49cc244404 100644 --- a/src/onegov/org/forms/political_business.py +++ b/src/onegov/org/forms/political_business.py @@ -246,7 +246,7 @@ def on_request(self) -> None: render_kw['data-no_results_text']) field.form.participant_type.meta = self.meta - field.form.participant_type.choices = [ # type: ignore[misc,str-unpack] + field.form.participant_type.choices = [ # type: ignore[misc] (value, self.request.translate(label) if label else label) for value, label in field.form.participant_type.choices ] diff --git a/src/onegov/org/forms/settings.py b/src/onegov/org/forms/settings.py index 53f2f0a791..8ba1b6caea 100644 --- a/src/onegov/org/forms/settings.py +++ b/src/onegov/org/forms/settings.py @@ -895,7 +895,7 @@ def on_request(self) -> None: # NOTE: In order to get an OR we need to use the AND # of all the choices it can't be instead. - dependency = FieldDependency(*( # type: ignore[misc,str-unpack] + dependency = FieldDependency(*( # type: ignore[misc] arg for name, _ in choices if name not in providers diff --git a/src/onegov/swissvotes/fields/dataset.py b/src/onegov/swissvotes/fields/dataset.py index 7b55a6d3e4..0b0389486d 100644 --- a/src/onegov/swissvotes/fields/dataset.py +++ b/src/onegov/swissvotes/fields/dataset.py @@ -129,7 +129,7 @@ def post_validate( raise ValidationError(_('No data.')) headers = [column.value for column in next(sheet.rows)] - missing = set(mapper.columns.values()) - set(headers) + missing = set(mapper.columns.values()) - set(headers) # type:ignore if missing: raise ValidationError(_( 'Some columns are missing: ${columns}.', diff --git a/src/onegov/swissvotes/fields/metadata.py b/src/onegov/swissvotes/fields/metadata.py index 58906aabd2..9a347793e6 100644 --- a/src/onegov/swissvotes/fields/metadata.py +++ b/src/onegov/swissvotes/fields/metadata.py @@ -125,7 +125,7 @@ def post_validate( raise ValidationError(_('No data.')) headers = [column.value for column in next(sheet.rows)] - missing = set(mapper.columns.values()) - set(headers) + missing = set(mapper.columns.values()) - set(headers) # type:ignore if missing: raise ValidationError(_( 'Some columns are missing: ${columns}.', diff --git a/src/onegov/ticket/collection.py b/src/onegov/ticket/collection.py index d6be27b602..f592b1cf52 100644 --- a/src/onegov/ticket/collection.py +++ b/src/onegov/ticket/collection.py @@ -295,7 +295,7 @@ def get_count(self, excl_archived: bool = True) -> TicketCount: query = query.group_by(Ticket.state) - return TicketCount(**dict(query.tuples())) + return TicketCount(**dict(query.tuples())) # type: ignore[misc] def by_handler_data_id( self, diff --git a/src/onegov/translator_directory/custom.py b/src/onegov/translator_directory/custom.py index 4b6dc98546..009e12ed58 100644 --- a/src/onegov/translator_directory/custom.py +++ b/src/onegov/translator_directory/custom.py @@ -81,7 +81,7 @@ def get_accountant_ticket_count( .tuples() ) - return TicketCount(**dict(query)) + return TicketCount(**dict(query)) # type: ignore[misc] def get_global_tools( diff --git a/src/onegov/user/auth/clients/saml2.py b/src/onegov/user/auth/clients/saml2.py index 6e3aa9a7e3..afdf777d65 100644 --- a/src/onegov/user/auth/clients/saml2.py +++ b/src/onegov/user/auth/clients/saml2.py @@ -42,7 +42,7 @@ def handle_logout_request( # redirect binding to be used supported_bindings = [BINDING_HTTP_REDIRECT] success = False - if logout_req.message.name_id == name_id and name_id: + if logout_req.message.name_id == name_id: try: if conn.local_logout(name_id): status = success_status_factory() diff --git a/tests/onegov/agency/test_app.py b/tests/onegov/agency/test_app.py index 5a8e18bb09..a796c3193d 100644 --- a/tests/onegov/agency/test_app.py +++ b/tests/onegov/agency/test_app.py @@ -107,7 +107,7 @@ def test_app_root_pdf(agency_app: AgencyApp) -> None: agency_app.root_pdf = BytesIO(b'PDF') assert agency_app.root_pdf == b'PDF' - assert agency_app.root_pdf_exists is True # type: ignore[unreachable] + assert agency_app.root_pdf_exists is True def test_app_pdf_class(agency_app: AgencyApp) -> None: @@ -120,7 +120,7 @@ def test_app_pdf_class(agency_app: AgencyApp) -> None: assert agency_app.pdf_class == AgencyPdfAr agency_app.org.meta['pdf_layout'] = 'zg' - assert agency_app.pdf_class == AgencyPdfZg # type: ignore[comparison-overlap] + assert agency_app.pdf_class == AgencyPdfZg agency_app.org.meta['pdf_layout'] = '' assert agency_app.pdf_class == AgencyPdfDefault diff --git a/tests/onegov/agency/test_forms.py b/tests/onegov/agency/test_forms.py index c0a3ec417d..743d02d707 100644 --- a/tests/onegov/agency/test_forms.py +++ b/tests/onegov/agency/test_forms.py @@ -265,7 +265,7 @@ def test_move_agency_form(session: Session) -> None: assert model.parent_id == 10 # update with rename - agency_a_2_2 = agencies.add(title="a.2", parent=agency_a_2) # type: ignore[unreachable] + agency_a_2_2 = agencies.add(title="a.2", parent=agency_a_2) form = MoveAgencyForm(DummyPostData({'parent_id': agency_a_2.parent_id})) form.request = DummyRequest(session, permissions=all_permissions) form.update_model(agency_a_2_2) diff --git a/tests/onegov/core/test_orm.py b/tests/onegov/core/test_orm.py index 487e926fef..9cc7c4233f 100644 --- a/tests/onegov/core/test_orm.py +++ b/tests/onegov/core/test_orm.py @@ -480,7 +480,7 @@ class Test(Base): session.add(test) transaction.commit() - assert session.query(Test).one().session_manager.__repr__.__self__ is mgr + assert session.query(Test).one().session_manager.__repr__.__self__ is mgr # type: ignore[attr-defined] mgr.dispose() @@ -1687,7 +1687,7 @@ def secret_document(self) -> int | None: assert app.request_cache == {} assert app.secret_document == 2 - assert app2.first_document.title == 'Public' # type: ignore[unreachable] + assert app2.first_document.title == 'Public' assert app.untitled_documents == [] assert len(app.documents) == 2 diff --git a/tests/onegov/directory/test_migration.py b/tests/onegov/directory/test_migration.py index 4d09b76c21..e4eb8b9ff8 100644 --- a/tests/onegov/directory/test_migration.py +++ b/tests/onegov/directory/test_migration.py @@ -752,7 +752,7 @@ def test_directory_migration_for_select(session: Session) -> None: migration.execute() assert zoo.values['general_landscapes'] == [] - assert zoo.values['general_animals'] == [] # type: ignore[unreachable] + assert zoo.values['general_animals'] == [] # type change checkbox -> radio not possible new_structure = """ diff --git a/tests/onegov/election_day/conftest.py b/tests/onegov/election_day/conftest.py index 7b7108e79c..95f8a3fe6f 100644 --- a/tests/onegov/election_day/conftest.py +++ b/tests/onegov/election_day/conftest.py @@ -438,7 +438,7 @@ def import_elections_internal( principal_obj = create_principal(principal, municipality) session.add(election) session.flush() - errors = function_mapping[election_type]( + errors = function_mapping[election_type]( # type: ignore[operator] election, principal_obj, BytesIO(csv_file.read()), mimetype, ) assert election.title is not None diff --git a/tests/onegov/election_day/forms/test_screen_form.py b/tests/onegov/election_day/forms/test_screen_form.py index 747f425858..25a44809fa 100644 --- a/tests/onegov/election_day/forms/test_screen_form.py +++ b/tests/onegov/election_day/forms/test_screen_form.py @@ -242,7 +242,7 @@ def test_screen_form_update_apply(session: Session) -> None: assert model.election_compound_id == compound.id assert model.election_compound_part == part assert model.domain == 'domain' - assert model.domain_segment == 'segment' # type: ignore[unreachable] + assert model.domain_segment == 'segment' form = ScreenForm() form.apply_model(model) # undo mypy narrowing diff --git a/tests/onegov/election_day/models/test_screen.py b/tests/onegov/election_day/models/test_screen.py index 5ba8000639..e430c793e7 100644 --- a/tests/onegov/election_day/models/test_screen.py +++ b/tests/onegov/election_day/models/test_screen.py @@ -89,8 +89,8 @@ def test_screen(session: Session) -> None: assert screen.election is None assert screen.election_compound == election_compound assert screen.model == election_compound - assert screen.screen_type.categories == ('generic', 'election_compound') # type: ignore[comparison-overlap] - assert screen.last_modified == datetime(2020, 1, 4, 4, tzinfo=timezone.utc) # type: ignore[unreachable] + assert screen.screen_type.categories == ('generic', 'election_compound') + assert screen.last_modified == datetime(2020, 1, 4, 4, tzinfo=timezone.utc) election_compound_part = ElectionCompoundPart( election_compound, 'domain', 'segment' diff --git a/tests/onegov/election_day/models/test_vote.py b/tests/onegov/election_day/models/test_vote.py index f37049de72..332e08236f 100644 --- a/tests/onegov/election_day/models/test_vote.py +++ b/tests/onegov/election_day/models/test_vote.py @@ -161,7 +161,7 @@ def test_ballot_answer_simple(session: Session) -> None: for result in vote.proposal.results: result.counted = True assert vote.proposal.answer == 'accepted' - assert vote.answer == 'accepted' # type: ignore[unreachable] + assert vote.answer == 'accepted' # if there are as many nays as yeas, we default to 'rejected' - in reality # this is very unlikely to happen diff --git a/tests/onegov/election_day/screen_widgets/test_generic_widgets.py b/tests/onegov/election_day/screen_widgets/test_generic_widgets.py index 7567ba6f4b..251d6bfab9 100644 --- a/tests/onegov/election_day/screen_widgets/test_generic_widgets.py +++ b/tests/onegov/election_day/screen_widgets/test_generic_widgets.py @@ -81,63 +81,63 @@ def test_generic_widgets() -> None: 'style': 'max-width: none' } - columns = list(row.iterchildren()) # type: ignore[unreachable] + columns = list(row.iterchildren()) assert columns[0].tag == 'div' - assert columns[0].attrib == { + assert columns[0].attrib == { # type: ignore[comparison-overlap] 'class': 'small-12 medium-1 columns my-first-column' } assert columns[1].tag == 'div' - assert columns[1].attrib == { + assert columns[1].attrib == { # type: ignore[comparison-overlap] 'class': 'small-12 medium-1 columns my-second-column' } assert columns[2].tag == 'div' - assert columns[2].attrib == { + assert columns[2].attrib == { # type: ignore[comparison-overlap] 'class': 'small-12 medium-1 columns my-third-column' } assert columns[3].tag == 'div' - assert columns[3].attrib == { + assert columns[3].attrib == { # type: ignore[comparison-overlap] 'class': 'small-12 medium-1 columns my-fourth-column' } assert columns[4].tag == 'div' - assert columns[4].attrib == { + assert columns[4].attrib == { # type: ignore[comparison-overlap] 'class': 'small-12 medium-1 columns my-fifth-column' } h1 = next(columns[0].iterchildren()) assert h1.tag == 'h1' - assert h1.attrib == { + assert h1.attrib == { # type: ignore[comparison-overlap] 'class': 'my-first-header' } h2 = next(h1.iterchildren()) assert h2.tag == 'h2' - assert h2.attrib == { + assert h2.attrib == { # type: ignore[comparison-overlap] 'class': 'my-second-header' } h3 = next(h2.iterchildren()) assert h3.tag == 'h3' - assert h3.attrib == { + assert h3.attrib == { # type: ignore[comparison-overlap] 'class': 'my-third-header' } assert h3.text == 'Title' hr = next(columns[1].iterchildren()) assert hr.tag == 'hr' - assert hr.attrib == { + assert hr.attrib == { # type: ignore[comparison-overlap] 'class': 'my-hr' } logo = next(columns[2].iterchildren()) assert logo.tag == 'img' - assert logo.attrib == { + assert logo.attrib == { # type: ignore[comparison-overlap] 'class': 'my-logo', 'src': 'logo.svg' } text = next(columns[3].iterchildren()) assert text.tag == 'p' - assert text.attrib == { + assert text.attrib == { # type: ignore[comparison-overlap] 'class': 'my-text' } assert text.text == 'Lorem' diff --git a/tests/onegov/election_day/utils/test_election_compound_utils.py b/tests/onegov/election_day/utils/test_election_compound_utils.py index 5bd0d65056..2340b7857a 100644 --- a/tests/onegov/election_day/utils/test_election_compound_utils.py +++ b/tests/onegov/election_day/utils/test_election_compound_utils.py @@ -704,7 +704,7 @@ def assert_node( deltas = get_party_results_deltas(election_compound, years, parties) assert deltas[1]['2014'][0][2] == 43062 # type: ignore[comparison-overlap] - data = get_party_results_data(election_compound, False) # type: ignore[unreachable] + data = get_party_results_data(election_compound, False) assert isinstance(data, dict) assert data['results'][0]['value']['back'] == 13.8 diff --git a/tests/onegov/event/test_collections.py b/tests/onegov/event/test_collections.py index 0af97e9e6e..19bd7b014b 100644 --- a/tests/onegov/event/test_collections.py +++ b/tests/onegov/event/test_collections.py @@ -1473,7 +1473,7 @@ def test_from_ical(session: Session) -> None: assert event.filter_keywords == None # default keywords - events.from_ical('\n'.join([ # type: ignore[unreachable] + events.from_ical('\n'.join([ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//OneGov//onegov.event//', diff --git a/tests/onegov/event/test_models.py b/tests/onegov/event/test_models.py index cdfd1b2eac..c8fa3b2027 100644 --- a/tests/onegov/event/test_models.py +++ b/tests/onegov/event/test_models.py @@ -166,7 +166,7 @@ def test_event_image(test_app: TestApp, path: str) -> None: event.set_image(BytesIO(content), 'file.png') session.flush() assert event.image is not None - assert event.image.reference.file.read() == content # type: ignore[unreachable] + assert event.image.reference.file.read() == content with open(f'{path}/event.jpg', 'rb') as file: content = file.read() diff --git a/tests/onegov/fsi/test_models.py b/tests/onegov/fsi/test_models.py index 589d07e194..2a6078001c 100644 --- a/tests/onegov/fsi/test_models.py +++ b/tests/onegov/fsi/test_models.py @@ -49,7 +49,7 @@ def test_attendee( assert subscription.attendee == attendee_ # Check the event of the the subscription - assert attendee_.subscriptions[0].course_event == course_event_ + assert attendee_.subscriptions[0].course_event == course_event_ # type: ignore[union-attr] # delete the subscription attendee_.subscriptions.remove(subscription) diff --git a/tests/onegov/gis/test_fields.py b/tests/onegov/gis/test_fields.py index d364177a2d..76c75d03fe 100644 --- a/tests/onegov/gis/test_fields.py +++ b/tests/onegov/gis/test_fields.py @@ -37,11 +37,11 @@ def test_coordinates_field() -> None: ).decode('ascii')]) assert field.data.lat == 47.05183585 - assert field.data.lon == 8.30576869173879 # type: ignore[unreachable] + assert field.data.lon == 8.30576869173879 assert field.data.zoom == 10 # which again holds true for the rendered field - coordinate = json.loads(b64decode(value.search(field()).group(1))) + coordinate = json.loads(b64decode(value.search(field()).group(1))) # type: ignore[union-attr] assert coordinate.lat == 47.05183585 assert coordinate.lon == 8.30576869173879 diff --git a/tests/onegov/landsgemeinde/test_models.py b/tests/onegov/landsgemeinde/test_models.py index 53b0fe8695..0dcc0bc948 100644 --- a/tests/onegov/landsgemeinde/test_models.py +++ b/tests/onegov/landsgemeinde/test_models.py @@ -94,7 +94,7 @@ def test_models(session: Session, assembly: Assembly) -> None: agenda_item.start_time = time(11, 10, 7) votum.start_time = time(12, 11, 5) assert agenda_item.calculated_timestamp == '1h9m2s' - assert votum.calculated_timestamp == '2h10m' # type: ignore[unreachable] + assert votum.calculated_timestamp == '2h10m' assembly.start_time = None assert agenda_item.calculated_timestamp is None diff --git a/tests/onegov/org/test_extensions.py b/tests/onegov/org/test_extensions.py index d183191ce5..e6748ee006 100644 --- a/tests/onegov/org/test_extensions.py +++ b/tests/onegov/org/test_extensions.py @@ -377,7 +377,7 @@ class TopicForm(Form): "https://www.apple.com" ) - assert topic.contact_html == ( # type: ignore[unreachable] + assert topic.contact_html == ( '

' 'Steve Jobs

' '

steve@apple.com
' @@ -439,7 +439,7 @@ class TopicForm(Form): "https://custom.longdomain" ) # undo mypy narrowing - topic = topic # type: ignore[unreachable] + topic = topic html = topic.contact_html assert html is not None assert ' None: assert form.start_time.data == time(8, 0) assert form.end_time.data == time(9, 0) assert not form.rooms - assert 'for_every_room' not in ( # type: ignore[misc,str-unpack] + assert 'for_every_room' not in ( # type: ignore[misc] value for value, _label in form.auto_reserve_available_slots.choices ) @@ -962,7 +962,7 @@ def test_ticket_assignment_form(session: Session) -> None: form.request = request form.on_request() - assert sorted(name for id_, name in form.user.choices) == ['a', 'e'] # type: ignore[misc,str-unpack] + assert sorted(name for id_, name in form.user.choices) == ['a', 'e'] # type: ignore[misc] assert form.username == 'a' diff --git a/tests/onegov/org/test_layout.py b/tests/onegov/org/test_layout.py index a25f6ae845..7eb4c75079 100644 --- a/tests/onegov/org/test_layout.py +++ b/tests/onegov/org/test_layout.py @@ -61,11 +61,11 @@ def test_layout() -> None: layout.request.app = 'test' # type: ignore[assignment] assert layout.app == 'test' # type: ignore[comparison-overlap] - layout = DefaultLayout(MockModel(), MockRequest()) # type: ignore[unreachable] + layout = DefaultLayout(MockModel(), MockRequest()) # type: ignore[arg-type] layout.request.path_info = '/' assert layout.page_id == 'page-root' - layout = DefaultLayout(MockModel(), MockRequest()) + layout = DefaultLayout(MockModel(), MockRequest()) # type: ignore[arg-type] layout.request.path_info = '/foo/bar/' assert layout.page_id == 'page-foo-bar' diff --git a/tests/onegov/org/test_views_settings.py b/tests/onegov/org/test_views_settings.py index 986d0021a8..d2c7cb95b0 100644 --- a/tests/onegov/org/test_views_settings.py +++ b/tests/onegov/org/test_views_settings.py @@ -43,7 +43,7 @@ def test_settings(client: Client) -> None: # Form was populated with user_options default before submitting assert client.app.font_family == HELVETICA - settings.form['logo_url'] = 'https://seantis.ch/logo.img' # type: ignore[unreachable] + settings.form['logo_url'] = 'https://seantis.ch/logo.img' settings.form['reply_to'] = 'info@govikon.ch' settings.form['custom_css'] = 'h1 { text-decoration: underline; }' settings.form.submit() diff --git a/tests/onegov/people/test_models.py b/tests/onegov/people/test_models.py index 7b92b5dab0..3d69f576cf 100644 --- a/tests/onegov/people/test_models.py +++ b/tests/onegov/people/test_models.py @@ -134,7 +134,7 @@ def test_vcard(session: Session) -> None: assert "NOTE;CHARSET=utf-8:Has bad vision." in vcard assert "END:VCARD" in vcard - vcard = person.memberships[0].vcard() + vcard = person.memberships[0].vcard() # type: ignore[union-attr] assert "BEGIN:VCARD" in vcard assert "VERSION:3.0" in vcard assert "ADR;CHARSET=utf-8:;;Fakestreet 1;Kappel am Albis;;1234;" in vcard diff --git a/tests/onegov/reservation/test_collection.py b/tests/onegov/reservation/test_collection.py index c1bf3bbc90..d10a935746 100644 --- a/tests/onegov/reservation/test_collection.py +++ b/tests/onegov/reservation/test_collection.py @@ -71,7 +71,7 @@ def test_resource_highlight_allocations(libres_context: Context) -> None: assert resource.date == date(2015, 8, 5) assert resource.highlights_min == allocations[0].id - assert resource.highlights_min == allocations[-1].id # type: ignore[unreachable] + assert resource.highlights_min == allocations[-1].id def test_resource_form_definition(libres_context: Context) -> None: diff --git a/tests/onegov/swissvotes/test_collections.py b/tests/onegov/swissvotes/test_collections.py index 7bfd3b7802..ad0096f968 100644 --- a/tests/onegov/swissvotes/test_collections.py +++ b/tests/onegov/swissvotes/test_collections.py @@ -122,15 +122,15 @@ def test_votes_default(swissvotes_app: TestApp) -> None: assert votes.from_date == 3 assert votes.to_date == 4 assert votes.legal_form == 5 # type: ignore[comparison-overlap] - assert votes.result == 6 # type: ignore[unreachable] - assert votes.policy_area == 7 - assert votes.term == 8 + assert votes.result == 6 # type: ignore[comparison-overlap] + assert votes.policy_area == 7 # type: ignore[comparison-overlap] + assert votes.term == 8 # type: ignore[comparison-overlap] assert votes.full_text == 9 - assert votes.position_federal_council == 10 - assert votes.position_national_council == 11 - assert votes.position_council_of_states == 12 - assert votes.sort_by == 13 - assert votes.sort_order == 14 + assert votes.position_federal_council == 10 # type: ignore[comparison-overlap] + assert votes.position_national_council == 11 # type: ignore[comparison-overlap] + assert votes.position_council_of_states == 12 # type: ignore[comparison-overlap] + assert votes.sort_by == 13 # type: ignore[comparison-overlap] + assert votes.sort_order == 14 # type: ignore[comparison-overlap] votes = votes.default() assert votes.page == 0 diff --git a/tests/onegov/swissvotes/test_forms.py b/tests/onegov/swissvotes/test_forms.py index 67d12dfa35..32e66481a7 100644 --- a/tests/onegov/swissvotes/test_forms.py +++ b/tests/onegov/swissvotes/test_forms.py @@ -440,7 +440,7 @@ def test_search_form(swissvotes_app: TestApp) -> None: assert form.legal_form.data == [1, 2] assert form.result.data == [0] assert form.policy_area.data == ['6', '7.75', '10.103.1035'] - assert form.term.data == 'term' # type: ignore[unreachable] + assert form.term.data == 'term' assert form.full_text.data == 0 assert form.position_federal_council.data == [2, 3] assert form.position_national_council.data == [3] diff --git a/tests/onegov/ticket/test_model.py b/tests/onegov/ticket/test_model.py index b2a494a5aa..cfec14db34 100644 --- a/tests/onegov/ticket/test_model.py +++ b/tests/onegov/ticket/test_model.py @@ -41,7 +41,7 @@ def test_transitions(session: Session) -> None: assert ticket2.state == 'pending' assert ticket.user == user - ticket.accept_ticket(user) # type: ignore[unreachable] # idempotent.. + ticket.accept_ticket(user) # idempotent.. assert ticket2.state == 'pending' assert ticket.user == user @@ -104,7 +104,7 @@ def test_process_time(session: Session) -> None: ticket.accept_ticket(user) assert ticket.reaction_time == 10 - assert ticket.process_time is None # type: ignore[unreachable] + assert ticket.process_time is None assert ticket.current_process_time == 0 assert ticket.last_state_change == utcnow() diff --git a/tests/onegov/town6/test_layout.py b/tests/onegov/town6/test_layout.py index d4b59cd9d2..195227571d 100644 --- a/tests/onegov/town6/test_layout.py +++ b/tests/onegov/town6/test_layout.py @@ -60,11 +60,11 @@ def test_layout() -> None: layout.request.app = 'test' # type: ignore[assignment] assert layout.app == 'test' # type: ignore[comparison-overlap] - layout = DefaultLayout(MockModel(), MockRequest()) # type: ignore[unreachable] + layout = DefaultLayout(MockModel(), MockRequest()) # type: ignore[arg-type] layout.request.path_info = '/' assert layout.page_id == 'page-root' - layout = DefaultLayout(MockModel(), MockRequest()) + layout = DefaultLayout(MockModel(), MockRequest()) # type: ignore[arg-type] layout.request.path_info = '/foo/bar/' assert layout.page_id == 'page-foo-bar' diff --git a/tests/onegov/translator_directory/test_models.py b/tests/onegov/translator_directory/test_models.py index db541ae0e0..92f1583663 100644 --- a/tests/onegov/translator_directory/test_models.py +++ b/tests/onegov/translator_directory/test_models.py @@ -85,11 +85,11 @@ def test_translator_model(translator_app: TestApp) -> None: ) translator.expertise_professional_guilds_other = ['Psychologie'] - assert translator.expertise_professional_guilds_all == ( # type: ignore[comparison-overlap] + assert translator.expertise_professional_guilds_all == ( 'economy', 'art_leisure', 'Psychologie' ) - translator.expertise_professional_guilds = [] # type: ignore[unreachable] + translator.expertise_professional_guilds = [] assert translator.expertise_professional_guilds_all == ('Psychologie', ) @@ -117,14 +117,14 @@ def test_translator_user(session: Session) -> None: session.expire_all() assert translator.user == user_a assert user_a.translator == translator # type: ignore[attr-defined] - assert user_b.translator is None # type: ignore[unreachable] + assert user_b.translator is None # type: ignore[attr-defined] translator.email = 'b@example.org' session.flush() session.expire_all() assert translator.user == user_b - assert user_a.translator is None - assert user_b.translator == translator + assert user_a.translator is None # type: ignore[attr-defined] + assert user_b.translator == translator # type: ignore[attr-defined] session.delete(user_b) session.flush() @@ -137,7 +137,7 @@ def test_translator_user(session: Session) -> None: session.flush() session.expire_all() assert translator.user == user - assert user.translator == translator + assert user.translator == translator # type: ignore[attr-defined] session.delete(translator) session.flush() @@ -390,7 +390,7 @@ def test_translator_mutation(session: Session) -> None: assert translator.gender == 'M' assert translator.date_of_birth == date(1970, 1, 1) assert translator.nationalities == 'nationalities' # type: ignore[comparison-overlap] - assert translator.coordinates == Coordinates(1, 2) # type: ignore[unreachable] + assert translator.coordinates == Coordinates(1, 2) assert translator.address == 'Street and house number' assert translator.zip_code == '8000' assert translator.city == 'City' @@ -473,11 +473,11 @@ def test_accreditation(translator_app: TestApp) -> None: # undo mypy narrowing translator = translator assert ticket.handler.state == 'granted' - assert translator.state == 'published' # type: ignore[unreachable] + assert translator.state == 'published' assert translator.date_of_decision == today().date() assert session.query(Translator).count() == 1 - with freeze_time('2025-01-01') as today: # type: ignore[unreachable] + with freeze_time('2025-01-01') as today: accreditation.refuse() assert ticket.handler.state == 'refused' assert session.query(Translator).count() == 0 diff --git a/tests/onegov/websockets/test_cli.py b/tests/onegov/websockets/test_cli.py index 1b84884fad..8d53be355c 100644 --- a/tests/onegov/websockets/test_cli.py +++ b/tests/onegov/websockets/test_cli.py @@ -192,7 +192,7 @@ def test_cli_broadcast( assert broadcast.call_count == 2 assert broadcast.call_args[0][1] == 'foo-baz' assert broadcast.call_args[0][2] == 'one' - assert broadcast.call_args[0][3] == {'a': 'b'} # type: ignore[unreachable] + assert broadcast.call_args[0][3] == {'a': 'b'} assert '{"a": "b"} sent to foo-baz-one' in result.output result = runner.invoke(cli, [ @@ -254,7 +254,7 @@ def test_cli_listen( assert register.call_count == 2 assert register.call_args[0][1] == 'foo-baz' assert register.call_args[0][2] == 'one' - assert 'Listing on wss://govikon.org/ws @ foo-baz-one' in result.output # type: ignore[unreachable] + assert 'Listing on wss://govikon.org/ws @ foo-baz-one' in result.output result = runner.invoke(cli, [ '--config', cfg_path, From 196dedf94a200a17928a5598d2e14be3e8b27e0a Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 9 Apr 2026 08:45:58 +0200 Subject: [PATCH 7/9] Fixes room sorting in find my spot view Fixes small regression in occupancycalendar --- .../org/assets/js/occupancycalendar.jsx | 2 +- src/onegov/org/views/resource.py | 33 ++++++++++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/onegov/org/assets/js/occupancycalendar.jsx b/src/onegov/org/assets/js/occupancycalendar.jsx index e96a82eab4..9d891a1ae3 100644 --- a/src/onegov/org/assets/js/occupancycalendar.jsx +++ b/src/onegov/org/assets/js/occupancycalendar.jsx @@ -608,7 +608,7 @@ oc.setupViewNavigation = function(calendar, element, views, stats_url, pdf_url) })}%

), - wrapper.get(0) + new_wrapper.get(0) ); oc.showPopup(calendar, stats_btn, new_wrapper); }); diff --git a/src/onegov/org/views/resource.py b/src/onegov/org/views/resource.py index 109ad3d225..2fc2b879ad 100644 --- a/src/onegov/org/views/resource.py +++ b/src/onegov/org/views/resource.py @@ -367,14 +367,31 @@ def view_find_your_spot( form.action += '#results' room_slots: dict[date_t, RoomSlots] | None = None missing_dates: dict[date_t, list[Resource] | None] | None = None - rooms = sorted( - request.exclude_invisible(self.query()), - key=attrgetter('title') - ) + rooms = request.exclude_invisible(self.query()) if not rooms: # we'll treat categories without rooms as non-existant raise exc.HTTPNotFound() + # NOTE: We make sure to sort parent rooms before their children + # so they get picked first when auto-reserving + rooms_dict = {room.id: room for room in rooms} + + def sort_key(item: Resource) -> tuple[str, ...]: + key = [item.title] + seen = {item.id} + while ( + item.parent_id is not None + # avoid infinite loop when there is a cycle + and item.parent_id not in seen + and (parent := rooms_dict.get(item.parent_id)) is not None + ): + item = parent + seen.add(item.id) + key.append(item.title) + return tuple(reversed(key)) + + rooms.sort(key=sort_key) + form.apply_rooms(rooms) if form.submitted(request): assert form.start.data is not None @@ -724,9 +741,7 @@ def spot_infos_for_free_slots( auto_reserve != 'for_every_room' or len(skipped) == len(date_room_slots) ): - # date already fully reserved, but we still add - # ourselves to the list of reserved rooms, since - # we implicitly are reserved through the other room + # date already fully reserved continue for room_id, slots in date_room_slots.items(): if ( @@ -743,7 +758,9 @@ def spot_infos_for_free_slots( # since parent rooms are usually sorted before # child rooms, this ensures we first try to # reserve the entire thing and then fall back - # to individual subrooms + # to individual subrooms, but we still add + # ourselves to the list of reserved rooms, since + # we implicitly are reserved through the other room reserved_dates[date].add(room_id) continue From 93c881b9fdeb70142afbf989f16827e1ab0bdb26 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 9 Apr 2026 09:50:03 +0200 Subject: [PATCH 8/9] Adds parent resources to find-your-spot test. Fixes small bug --- src/onegov/org/views/resource.py | 17 +++-- tests/onegov/org/test_views_resources.py | 92 ++++++++++++++++++------ 2 files changed, 82 insertions(+), 27 deletions(-) diff --git a/src/onegov/org/views/resource.py b/src/onegov/org/views/resource.py index 2fc2b879ad..3245b9eeec 100644 --- a/src/onegov/org/views/resource.py +++ b/src/onegov/org/views/resource.py @@ -736,31 +736,36 @@ def spot_infos_for_free_slots( ) else room_slots.items() ): skipped = skipped_due_to_existing_reservation[date] - reserved_dates[date] = skipped + reserved_dates[date] = set() if skipped and ( auto_reserve != 'for_every_room' or len(skipped) == len(date_room_slots) ): # date already fully reserved + reserved_dates[date] = skipped continue + for room_id, slots in date_room_slots.items(): if ( auto_reserve == 'for_every_room' and room_id in skipped ): # already fully reserved + reserved_dates[date].add(room_id) continue - if not reserved_dates.get(date, set()).isdisjoint( + if not reserved_dates[date].isdisjoint( request.app.get_blocking_resource_ids(room_id) ): # we already reserved another room that blocks us # since parent rooms are usually sorted before # child rooms, this ensures we first try to # reserve the entire thing and then fall back - # to individual subrooms, but we still add - # ourselves to the list of reserved rooms, since - # we implicitly are reserved through the other room + # to individual subrooms. + # if we didn't skip due to existing reservations + # we add ourselves to the list of reserved rooms + # since we implicitly are reserved through the other + # room reserved_dates[date].add(room_id) continue @@ -791,7 +796,7 @@ def spot_infos_for_free_slots( # no slot reserved, move on to the next room continue - reserved_dates.setdefault(date, set()).add(room_id) + reserved_dates[date].add(room_id) # since we managed to reserve a slot and we're not # making a reservation for every room, we need to diff --git a/tests/onegov/org/test_views_resources.py b/tests/onegov/org/test_views_resources.py index b1d567b2c2..b680a95d5b 100644 --- a/tests/onegov/org/test_views_resources.py +++ b/tests/onegov/org/test_views_resources.py @@ -255,7 +255,7 @@ def test_find_your_spot(client: Client) -> None: resources = client.get('/resources') new = resources.click('Raum') - new.form['title'] = 'Meeting 1' + new.form['title'] = 'Grand Meeting Room' new.form['group'] = 'Meeting Rooms' new.form.submit().follow() @@ -266,8 +266,16 @@ def test_find_your_spot(client: Client) -> None: assert 'An Feiertagen' not in find_your_spot assert 'Während Schulferien' not in find_your_spot + resources = client.get('/resources') + new = resources.click('Raum') + new.form['title'] = 'Meeting 1' + new.form['group'] = 'Meeting Rooms' + new.form.select('parent_id', text='Grand Meeting Room') + new.form.submit().follow() + new.form['title'] = 'Meeting 2' new.form['group'] = 'Meeting Rooms' + new.form.select('parent_id', text='Grand Meeting Room') new.form.submit().follow() find_your_spot = client.get('/find-your-spot?group=Meeting+Rooms') @@ -339,6 +347,20 @@ def test_find_your_spot(client: Client) -> None: # create a blocked and an unblocked allocation transaction.begin() + scheduler_parent = ( + ResourceCollection(client.app.libres_context) # type: ignore[union-attr] + .by_name('grand-meeting-room') + .get_scheduler(client.app.libres_context) + ) + scheduler_parent.allocate( + dates=( + (datetime(2020, 1, 1), datetime(2020, 1, 1)), + (datetime(2020, 1, 2), datetime(2020, 1, 2)), + ), + whole_day=True, + partly_available=True + ) + scheduler_1 = ( ResourceCollection(client.app.libres_context) # type: ignore[union-attr] .by_name('meeting-1') @@ -385,7 +407,7 @@ def test_find_your_spot(client: Client) -> None: ).json assert len(result['reservations']) == 1 reservation = result['reservations'][0] - assert reservation['resource'] == 'meeting-1' + assert reservation['resource'] == 'grand-meeting-room' assert reservation['date'].startswith('2020-01-01') assert reservation['time'] == '07:00 - 08:00' @@ -397,7 +419,7 @@ def test_find_your_spot(client: Client) -> None: ).json assert len(result['reservations']) == 1 reservation = result['reservations'][0] - assert reservation['resource'] == 'meeting-1' + assert reservation['resource'] == 'grand-meeting-room' assert reservation['date'].startswith('2020-01-01') assert reservation['time'] == '07:00 - 08:00' @@ -408,11 +430,17 @@ def test_find_your_spot(client: Client) -> None: '/find-your-spot/reservations?group=Meeting+Rooms' ).json assert len(result['reservations']) == 5 - for idx, reservation in enumerate(result['reservations'][:-1]): - assert reservation['resource'] == 'meeting-1' + # the first two go to the grand meeting room + for idx, reservation in enumerate(result['reservations'][:2]): + assert reservation['resource'] == 'grand-meeting-room' assert reservation['date'].startswith(f'2020-01-0{idx+1}') assert reservation['time'] == '07:00 - 08:00' - # the final reservation only works for the other room + # the next two go to the first partition + for idx, reservation in enumerate(result['reservations'][2:4]): + assert reservation['resource'] == 'meeting-1' + assert reservation['date'].startswith(f'2020-01-0{idx+3}') + assert reservation['time'] == '07:00 - 08:00' + # the final reservation only works for the the second partition reservation = result['reservations'][-1] assert reservation['resource'] == 'meeting-2' assert reservation['date'].startswith('2020-01-05') @@ -425,11 +453,17 @@ def test_find_your_spot(client: Client) -> None: '/find-your-spot/reservations?group=Meeting+Rooms' ).json assert len(result['reservations']) == 5 - for idx, reservation in enumerate(result['reservations'][:-1]): - assert reservation['resource'] == 'meeting-1' + # the first two go to the grand meeting room + for idx, reservation in enumerate(result['reservations'][:2]): + assert reservation['resource'] == 'grand-meeting-room' assert reservation['date'].startswith(f'2020-01-0{idx+1}') assert reservation['time'] == '07:00 - 08:00' - # the final reservation only works for the other room + # the next two go to the first partition + for idx, reservation in enumerate(result['reservations'][2:4]): + assert reservation['resource'] == 'meeting-1' + assert reservation['date'].startswith(f'2020-01-0{idx+3}') + assert reservation['time'] == '07:00 - 08:00' + # the final reservation only works for the the second partition reservation = result['reservations'][-1] assert reservation['resource'] == 'meeting-2' assert reservation['date'].startswith('2020-01-05') @@ -441,16 +475,24 @@ def test_find_your_spot(client: Client) -> None: result = client.get( '/find-your-spot/reservations?group=Meeting+Rooms' ).json - assert len(result['reservations']) == 8 - for idx, reservation in enumerate(result['reservations'][::2]): - assert reservation['resource'] == 'meeting-1' + assert len(result['reservations']) == 6 + # the first two days go into the grand meeting room + for idx, reservation in enumerate(result['reservations'][:2]): + assert reservation['resource'] == 'grand-meeting-room' assert reservation['date'].startswith(f'2020-01-0{idx+1}') assert reservation['time'] == '07:00 - 08:00' - for idx, reservation in enumerate(result['reservations'][1:-1:2]): - assert reservation['resource'] == 'meeting-2' - assert reservation['date'].startswith(f'2020-01-0{idx+1}') + # the third and fourth day go the first partition + for idx, reservation in enumerate(result['reservations'][2::2]): + assert reservation['resource'] == 'meeting-1' + assert reservation['date'].startswith(f'2020-01-0{idx+3}') assert reservation['time'] == '07:00 - 08:00' + + # the third and fifth day go the second partition + reservation = result['reservations'][-3] + assert reservation['resource'] == 'meeting-2' + assert reservation['date'].startswith('2020-01-03') + assert reservation['time'] == '07:00 - 08:00' reservation = result['reservations'][-1] assert reservation['resource'] == 'meeting-2' assert reservation['date'].startswith('2020-01-05') @@ -462,16 +504,24 @@ def test_find_your_spot(client: Client) -> None: result = client.get( '/find-your-spot/reservations?group=Meeting+Rooms' ).json - assert len(result['reservations']) == 8 - for idx, reservation in enumerate(result['reservations'][::2]): - assert reservation['resource'] == 'meeting-1' + assert len(result['reservations']) == 6 + # the first two days go into the grand meeting room + for idx, reservation in enumerate(result['reservations'][:2]): + assert reservation['resource'] == 'grand-meeting-room' assert reservation['date'].startswith(f'2020-01-0{idx+1}') assert reservation['time'] == '07:00 - 08:00' - for idx, reservation in enumerate(result['reservations'][1:-1:2]): - assert reservation['resource'] == 'meeting-2' - assert reservation['date'].startswith(f'2020-01-0{idx+1}') + # the third and fourth day go the first partition + for idx, reservation in enumerate(result['reservations'][2::2]): + assert reservation['resource'] == 'meeting-1' + assert reservation['date'].startswith(f'2020-01-0{idx+3}') assert reservation['time'] == '07:00 - 08:00' + + # the third and fifth day go the second partition + reservation = result['reservations'][-3] + assert reservation['resource'] == 'meeting-2' + assert reservation['date'].startswith('2020-01-03') + assert reservation['time'] == '07:00 - 08:00' reservation = result['reservations'][-1] assert reservation['resource'] == 'meeting-2' assert reservation['date'].startswith('2020-01-05') From bad8d6cc010927f0fadb3ca229894ab0ec2ff311 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 9 Apr 2026 10:07:20 +0200 Subject: [PATCH 9/9] Makes find-your-spot auto-reserving more robust --- src/onegov/org/views/resource.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/onegov/org/views/resource.py b/src/onegov/org/views/resource.py index 3245b9eeec..3129314217 100644 --- a/src/onegov/org/views/resource.py +++ b/src/onegov/org/views/resource.py @@ -736,13 +736,13 @@ def spot_infos_for_free_slots( ) else room_slots.items() ): skipped = skipped_due_to_existing_reservation[date] - reserved_dates[date] = set() + reserved_dates[date] = skipped.copy() + blocked_rooms = skipped.copy() if skipped and ( auto_reserve != 'for_every_room' or len(skipped) == len(date_room_slots) ): # date already fully reserved - reserved_dates[date] = skipped continue for room_id, slots in date_room_slots.items(): @@ -751,10 +751,9 @@ def spot_infos_for_free_slots( and room_id in skipped ): # already fully reserved - reserved_dates[date].add(room_id) continue - if not reserved_dates[date].isdisjoint( + if not blocked_rooms.isdisjoint( request.app.get_blocking_resource_ids(room_id) ): # we already reserved another room that blocks us @@ -765,7 +764,9 @@ def spot_infos_for_free_slots( # if we didn't skip due to existing reservations # we add ourselves to the list of reserved rooms # since we implicitly are reserved through the other - # room + # room, but we don't add to the set of blocked + # rooms, since we otherwise might block rooms + # we're not supposed to block reserved_dates[date].add(room_id) continue @@ -796,6 +797,7 @@ def spot_infos_for_free_slots( # no slot reserved, move on to the next room continue + blocked_rooms.add(room_id) reserved_dates[date].add(room_id) # since we managed to reserve a slot and we're not