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/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/feriennet/views/invoice.py b/src/onegov/feriennet/views/invoice.py
index 530669f54c..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,str-unpack]
+ 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/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/assets/js/occupancycalendar.jsx b/src/onegov/org/assets/js/occupancycalendar.jsx
index 8ade48c46a..9d891a1ae3 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)
+ new_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 c33d6d1582..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,str-unpack]
+ 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..189ccb1b8a 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,97 @@ 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')
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 53f2f0a791..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,str-unpack]
+ dependency = FieldDependency(*( # type: ignore
arg
for name, _ in choices
if name not in providers
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"
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..3129314217 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
@@ -190,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):
@@ -211,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,
@@ -225,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(
@@ -340,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
@@ -435,11 +479,20 @@ 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
)
- ):
+ )
+ 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),
+ )
+ 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 +514,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 +562,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 +649,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 +675,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
)
@@ -640,7 +736,8 @@ def spot_infos_for_free_slots(
) else room_slots.items()
):
skipped = skipped_due_to_existing_reservation[date]
- reserved_dates[date] = skipped
+ reserved_dates[date] = skipped.copy()
+ blocked_rooms = skipped.copy()
if skipped and (
auto_reserve != 'for_every_room'
or len(skipped) == len(date_room_slots)
@@ -656,6 +753,23 @@ def spot_infos_for_free_slots(
# already fully reserved
continue
+ if not blocked_rooms.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.
+ # 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, 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
+
for slot in slots:
if isclose(slot.availability, 100.0, abs_tol=.005):
try:
@@ -683,7 +797,8 @@ def spot_infos_for_free_slots(
# no slot reserved, move on to the next room
continue
- reserved_dates.setdefault(date, set()).add(room_id)
+ blocked_rooms.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
@@ -1216,10 +1331,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 +1351,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 +1399,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 +1425,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/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/user/auth/clients/saml2.py b/src/onegov/user/auth/clients/saml2.py
index 6e3aa9a7e3..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 and 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 5a8e18bb09..0ae4c010dd 100644
--- a/tests/onegov/agency/test_app.py
+++ b/tests/onegov/agency/test_app.py
@@ -106,8 +106,9 @@ 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 # type: ignore[unreachable]
+ assert agency_app.root_pdf_exists is True
def test_app_pdf_class(agency_app: AgencyApp) -> None:
@@ -120,7 +121,8 @@ 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]
+ agency_app = agency_app # undo narrowing
+ 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..220924b270 100644
--- a/tests/onegov/agency/test_forms.py
+++ b/tests/onegov/agency/test_forms.py
@@ -262,10 +262,11 @@ 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
- 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..3fecc16d84 100644
--- a/tests/onegov/core/test_orm.py
+++ b/tests/onegov/core/test_orm.py
@@ -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' # type: ignore[unreachable]
+ 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 4d09b76c21..4628949cb0 100644
--- a/tests/onegov/directory/test_migration.py
+++ b/tests/onegov/directory/test_migration.py
@@ -751,8 +751,9 @@ 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'] == [] # type: ignore[unreachable]
+ assert zoo.values['general_animals'] == []
# type change checkbox -> radio not possible
new_structure = """
diff --git a/tests/onegov/election_day/forms/test_screen_form.py b/tests/onegov/election_day/forms/test_screen_form.py
index 747f425858..6798712e98 100644
--- a/tests/onegov/election_day/forms/test_screen_form.py
+++ b/tests/onegov/election_day/forms/test_screen_form.py
@@ -236,13 +236,14 @@ 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
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..55009c45cd 100644
--- a/tests/onegov/election_day/models/test_screen.py
+++ b/tests/onegov/election_day/models/test_screen.py
@@ -84,13 +84,14 @@ 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
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'
@@ -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 f37049de72..e2a7c5f9d0 100644
--- a/tests/onegov/election_day/models/test_vote.py
+++ b/tests/onegov/election_day/models/test_vote.py
@@ -160,8 +160,10 @@ 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' # 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..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'
- }
-
- columns = list(row.iterchildren()) # type: ignore[unreachable]
- 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'
+ if not TYPE_CHECKING:
+ assert row.attrib == {
+ 'class': 'row my-row',
+ 'style': 'max-width: none'
+ }
+
+ columns = list(row.iterchildren())
+ 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 5bd0d65056..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,9 +702,10 @@ 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) # 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..2bd4418c2b 100644
--- a/tests/onegov/event/test_collections.py
+++ b/tests/onegov/event/test_collections.py
@@ -1470,10 +1470,11 @@ 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([ # 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..71972f8058 100644
--- a/tests/onegov/event/test_models.py
+++ b/tests/onegov/event/test_models.py
@@ -165,8 +165,9 @@ 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 # 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/gis/test_fields.py b/tests/onegov/gis/test_fields.py
index d364177a2d..72d782bb1d 100644
--- a/tests/onegov/gis/test_fields.py
+++ b/tests/onegov/gis/test_fields.py
@@ -36,12 +36,13 @@ 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 # 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
@@ -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 53b0fe8695..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' # type: ignore[unreachable]
+ 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 d183191ce5..bf4f8774df 100644
--- a/tests/onegov/org/test_extensions.py
+++ b/tests/onegov/org/test_extensions.py
@@ -371,13 +371,15 @@ class TopicForm(Form):
form.populate_obj(topic)
+ topic = topic # undo narrowing
+
assert topic.contact == (
"Steve Jobs\n"
"steve@apple.com\n"
"https://www.apple.com"
)
- assert topic.contact_html == ( # type: ignore[unreachable]
+ assert topic.contact_html == (
''
'Steve Jobs
'
'steve@apple.com
'
@@ -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 # type: ignore[unreachable]
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
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
assert form.username == 'a'
diff --git a/tests/onegov/org/test_layout.py b/tests/onegov/org/test_layout.py
index a25f6ae845..ea5ec8cbe6 100644
--- a/tests/onegov/org/test_layout.py
+++ b/tests/onegov/org/test_layout.py
@@ -58,14 +58,15 @@ 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[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_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')
diff --git a/tests/onegov/org/test_views_settings.py b/tests/onegov/org/test_views_settings.py
index 986d0021a8..f74ae31473 100644
--- a/tests/onegov/org/test_views_settings.py
+++ b/tests/onegov/org/test_views_settings.py
@@ -40,10 +40,12 @@ 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
- 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/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 c1bf3bbc90..d691ec6075 100644
--- a/tests/onegov/reservation/test_collection.py
+++ b/tests/onegov/reservation/test_collection.py
@@ -69,9 +69,10 @@ 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 # 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..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[unreachable]
- 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
+ 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 67d12dfa35..cdd6dbfd61 100644
--- a/tests/onegov/swissvotes/test_forms.py
+++ b/tests/onegov/swissvotes/test_forms.py
@@ -435,12 +435,13 @@ 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]
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..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) # type: ignore[unreachable] # idempotent..
- assert ticket2.state == 'pending'
+ ticket.accept_ticket(user) # idempotent..
+ 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,8 +103,9 @@ 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 # type: ignore[unreachable]
+ assert ticket.process_time is None
assert ticket.current_process_time == 0
assert ticket.last_state_change == utcnow()
@@ -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 d4b59cd9d2..cf21d0f546 100644
--- a/tests/onegov/town6/test_layout.py
+++ b/tests/onegov/town6/test_layout.py
@@ -58,13 +58,14 @@ 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[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..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']
- assert translator.expertise_professional_guilds_all == ( # type: ignore[comparison-overlap]
+ translator = translator # undo narrowing
+ assert translator.expertise_professional_guilds_all == (
'economy', 'art_leisure', 'Psychologie'
)
- translator.expertise_professional_guilds = [] # type: ignore[unreachable]
+ translator.expertise_professional_guilds = []
+ translator = translator # undo narrowing
assert translator.expertise_professional_guilds_all == ('Psychologie', )
@@ -116,15 +118,17 @@ 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[unreachable]
+ 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
- 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 +141,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()
@@ -389,8 +393,9 @@ 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]
- assert translator.coordinates == Coordinates(1, 2) # type: ignore[unreachable]
+ 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'
assert translator.city == 'City'
@@ -471,13 +476,13 @@ 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' # 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..074728df4f 100644
--- a/tests/onegov/websockets/test_cli.py
+++ b/tests/onegov/websockets/test_cli.py
@@ -189,10 +189,11 @@ 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'
- 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, [
@@ -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,10 +251,11 @@ 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'
- 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,
@@ -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
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