From 0535a721c9ee648fd04568f46d2ed2de256e3eb9 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 15 Jan 2026 17:18:54 +0100 Subject: [PATCH 1/4] Org: Adds administrative reservation blockers in the occupancy calendar TYPE: Feature LINK: OGC-2780 --- setup.cfg | 3 +- src/onegov/org/assets/js/locale.js | 15 + .../org/assets/js/occupancycalendar.jsx | 472 +++++++++++++++++- .../org/assets/js/reservationcalendar.jsx | 6 +- src/onegov/org/path.py | 22 + src/onegov/org/theme/styles/org.scss | 49 +- src/onegov/org/utils.py | 105 +++- src/onegov/org/views/reservation_blocker.py | 242 +++++++++ src/onegov/org/views/resource.py | 15 +- src/onegov/reservation/collection.py | 7 +- src/onegov/reservation/upgrade.py | 27 +- .../town6/theme/styles/fullcalendar.scss | 49 +- 12 files changed, 976 insertions(+), 36 deletions(-) create mode 100644 src/onegov/org/views/reservation_blocker.py diff --git a/setup.cfg b/setup.cfg index 0fecb67338..69cfdd3d45 100644 --- a/setup.cfg +++ b/setup.cfg @@ -98,8 +98,7 @@ install_requires = kerberos lazy-object-proxy ldap3 - # NOTE: Upper-bound Will be removed once OGC-2780 is merged - libres>=0.9.0,<=0.10.0 + libres>=0.10.0 libsass lingua-language-detector lxml diff --git a/src/onegov/org/assets/js/locale.js b/src/onegov/org/assets/js/locale.js index 5771369e55..84ab2c1e5a 100644 --- a/src/onegov/org/assets/js/locale.js +++ b/src/onegov/org/assets/js/locale.js @@ -1,7 +1,12 @@ var locales = { de: { "Allocation": "Verfügbarkeit", + "Blocker": "Sperrzeit", + "Reason": "Grund für Sperrung", "Add": "Hinzufügen", + "Update": "Aktualisieren", + "Delete": "Löschen", + "Failed to delete": "Löschen fehlgeschlagen", "Count": "Anzahl", "Dates": "Termine", "From": "Von", @@ -34,7 +39,12 @@ var locales = { }, fr: { "Allocation": "Allocation", + "Blocker": "Bloqueur", + "Reason": "Raison", "Add": "Ajouter", + "Update": "Actualiser", + "Delete": "Supprimer", + "Failed to delete": "Impossible de supprimer", "Count": "Nombre", "Dates": "Dates", "From": "De", @@ -67,7 +77,12 @@ var locales = { }, it: { "Allocation": "Allocazione", + "Blocker": "Bloccante", + "Reason": "Motivo", "Add": "Aggiungi", + "Update": "Aggiorna", + "Delete": "Elimina", + "Failed to delete": "Impossibile eliminare", "Count": "Conta", "Dates": "Date", "From": "A partire dal", diff --git a/src/onegov/org/assets/js/occupancycalendar.jsx b/src/onegov/org/assets/js/occupancycalendar.jsx index 730526f378..80f59c0741 100644 --- a/src/onegov/org/assets/js/occupancycalendar.jsx +++ b/src/onegov/org/assets/js/occupancycalendar.jsx @@ -53,6 +53,8 @@ oc.events = [ 'oc-reservations-changed' ]; +oc.overlappingEvents = {}; + oc.passEventsToCalendar = function(calendar, target) { var cal = $(calendar); @@ -77,7 +79,7 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { snapDuration: '00:15', editable: ocOptions.editable, eventResizableFromStart: ocOptions.editable, - selectable: false, + selectable: ocOptions.add_blocker_url !== null && ocOptions.editable, initialView: ocOptions.view, locale: window.locale.language, multiMonthMaxColumns: 1 @@ -143,21 +145,81 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { // implements editing if (ocOptions.editable) { + // add blockers on selection + fcOptions.selectMirror = true; + fcOptions.unselectCancel = '.popup'; + fcOptions.selectOverlap = function(event) { + if (event.display === 'background') { + oc.overlappingEvents[event.id] = event; + return true; + } else { + oc.overlappingEvents = {}; + return false; + } + }; + fcOptions.selectAllow = function(info) { + // we only know what to do if we overlap a single valid allocation + // 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() + ) { + return true; + } else { + oc.overlappingEvents = {}; + return false; + } + } + // add blockers on selection + fcOptions.select = function(info) { + var keys = Object.keys(oc.overlappingEvents); + if (keys.length !== 1) { + // this shouldn't happen, but when it does just cancel + oc.overlappingEvents = {}; + info.view.calendar.unselect(); + return; + } + var event = oc.overlappingEvents[keys[0]]; + oc.overlappingEvents = {}; + var view = info.view; + var start = moment(info.start); + var end = moment(info.end); + var wholeDay = false; + if (view.type === "dayGridMonth" || view.type === "multiMonthYear") { + end = end.subtract(1, 'days'); + 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) { + if (info.event.extendedProps.kind !== 'blocker') { + return; + } + if (!info.event.extendedProps.seturl) { + return; + } + oc.showBlockerEditPopup(info.view.calendar, info.el, info.event); + }; + // edit events on drag&drop, resize fcOptions.eventOverlap = function(stillEvent, _movingEvent) { return stillEvent.display === 'background'; }; - // edit events on drag&drop, resize fcOptions.eventDrop = fcOptions.eventResize = function(info) { var event = info.event; var url = new Url(event.extendedProps.editurl); url.query.start = event.startStr; url.query.end = event.endStr; - var calendar = $(info.el).closest('.fc'); + var calendar = $(info.el).closest('.fc').get(0) || $('.fc').get(0); oc.post(calendar, url.toString(), function(_evt, _elt, _status, str, _xhr) { info.revert(); - oc.showErrorPopup(calendar, calendar.find('.event-' + event.id), str); + oc.showErrorPopup(calendar, $(calendar).find('.event-' + event.id), str); }); }; @@ -170,6 +232,12 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { // after event rendering options.eventRenderers.push(oc.highlightEvents); options.eventRenderers.push(oc.addEventBackground); + options.eventRenderers.push(oc.addDeleteBlockerHandler); + + // 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) { @@ -177,6 +245,14 @@ 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 @@ -401,7 +477,7 @@ oc.setupViewNavigation = function(calendar, element, views, pdf_url) { } ); - rc.showPopup(calendar, pdf_btn, wrapper); + oc.showPopup(calendar, pdf_btn, wrapper); }); pdf_btn.appendTo(view_group); @@ -431,7 +507,7 @@ oc.highlightEvents = function(event, element, view) { // adds a fc-bg div to the views where we need it oc.addEventBackground = function(event, element, view) { - if (event.display === 'background') { + if (event.extendedProps.kind !== 'reservation') { return; } @@ -441,9 +517,29 @@ oc.addEventBackground = function(event, element, view) { $('
').insertAfter($('.fc-content', element)); }; +// adds a click handler to the delete button +oc.addDeleteBlockerHandler = function(event, element, view) { + if (event.extendedProps.kind !== 'blocker' || !event.extendedProps.deleteurl) { + return; + } + + $(element).find('.delete-blocker').click(function(ev) { + ev.stopPropagation(); + $.ajax( + event.extendedProps.deleteurl, + {method: 'DELETE'} + ).done(function() { + view.calendar.refetchEvents(); + }).fail(function() { + oc.showErrorPopup($(element).closest('.fc'), element, locale('Failed to delete')); + }); + }); +}; + oc.setupReservationsRefetch = function(calendar) { $(window).on('oc-reservations-changed', function() { calendar.refetchEvents(); + calendar.unselect(); }); }; @@ -479,6 +575,74 @@ oc.post = function(calendar, url, onerror) { oc.request(calendar, url, 'ic-post-to', onerror); }; +oc.add_blocker = function(calendar, url, start, end, reason, wholeDay) { + url = new Url(url); + url.query.start = start; + url.query.end = end; + url.query.reason = reason; + url.query.whole_day = wholeDay && '1' || '0'; + + oc.post(calendar, url.toString()); +}; + +oc.edit_blocker = function(calendar, url, reason) { + url = new Url(url); + url.query.reason = reason; + + oc.post(calendar, url.toString()); +}; + +// popup handler implementation +oc.showBlockerPopup = function(calendar, element, start, end, wholeDay, event) { + var wrapper = $('
'); + var form = $('
').appendTo(wrapper); + + // Render the blocker form + oc.BlockerForm.render( + form.get(0), + start, + end, + wholeDay, + event, + function(state) { + oc.targetEvent = $(element); + oc.add_blocker( + calendar, + event.extendedProps.blockurl, + state.start, + state.end, + state.reason, + state.wholeDay + ); + $(this).closest('.popup').popup('hide'); + } + ); + + oc.showPopup(calendar, element, wrapper); +}; + +oc.showBlockerEditPopup = function(calendar, element, event) { + var wrapper = $('
'); + var form = $('
').appendTo(wrapper); + + // Render the blocker form + oc.BlockerEditForm.render( + form.get(0), + event, + function(state) { + oc.targetEvent = $(element); + oc.edit_blocker( + calendar, + event.extendedProps.seturl, + state.reason, + ); + $(this).closest('.popup').popup('hide'); + } + ); + + oc.showPopup(calendar, element, wrapper); +}; + oc.showErrorPopup = function(calendar, element, message) { oc.showPopup(calendar, element, message, 'top', ['error']); }; @@ -633,6 +797,302 @@ oc.bustIECache = function(originalUrl) { return url.toString(); }; + +/* + Allows to fine-adjust the reservation blocker before adding it. +*/ +oc.BlockerForm = React.createClass({ + getInitialState: function() { + var state = {reason: null}; + if (this.props.wholeDay && this.props.wholeDayDefault && this.props.fullyAvailable) { + state.start = ""; + state.end = ""; + state.wholeDay = true; + } else { + state.start = this.props.start.format('HH:mm'); + state.end = this.props.end.format('HH:mm'); + state.wholeDay = false; + } + + state.end = state.end === '00:00' && '24:00' || state.end; + + return state; + }, + componentDidMount: function() { + var node = $(ReactDOM.findDOMNode(this)); + + // the timeout is set to 100ms because the popup will do its own focusing + // after 50ms (we could use it, but we want to focus AND select) + setTimeout(function() { + node.find('input:first').focus().select(); + }, 100); + }, + handleInputChange: function(e) { + var state = _.extend({}, this.state); + var name = e.target.getAttribute('name'); + + switch (name) { + case 'reserve-whole-day': + state.wholeDay = e.target.value === 'yes'; + break; + case 'start': + state.start = e.target.value; + break; + case 'end': + state.end = e.target.value === '00:00' && '24:00' || e.target.value; + break; + case 'reason': + state.reason = e.target.value || null; + break; + default: + throw Error("Unknown input element: " + name); + } + + this.setState(state); + }, + handleButton: function(e) { + var node = ReactDOM.findDOMNode(this); + var self = this; + + $(node).find('input').each(function(_ix, el) { + $(el).blur(); + }); + + setTimeout(function() { + self.props.onSubmit.call(node, self.state); + }, 0); + + e.preventDefault(); + }, + handleTimeInputFocus: function(e) { + if (!Modernizr.inputtypes.time) { + e.target.select(); + e.preventDefault(); + } + }, + handleTimeInputMouseUp: function(e) { + if (!Modernizr.inputtypes.time) { + e.preventDefault(); + } + }, + handleTimeInputBlur: function(e) { + if (!Modernizr.inputtypes.time) { + e.target.value = OneGov.utils.inferTime(e.target.value); + this.handleInputChange(e); + } + }, + parseTime: function(date, time) { + time = OneGov.utils.inferTime(time); + + if (!time.match(/^[0-2]{1}[0-9]{1}:?[0-5]{1}[0-9]{1}$/)) { + return null; + } + + var hour = parseInt(time.split(':')[0], 10); + var minute = parseInt(time.split(':')[1], 10); + + if (hour < 0 || 24 < hour) { + return null; + } + + if (minute < 0 || 60 < minute) { + return null; + } + + date.hour(hour); + date.minute(minute); + + return date; + }, + isValidStart: function(start) { + var startdate = this.parseTime(this.props.start.clone(), start); + return startdate !== null && this.props.minStart <= startdate; + }, + isValidEnd: function(end) { + var enddate = this.parseTime(this.props.start.clone(), end); + return enddate !== null && enddate <= this.props.maxEnd; + }, + isValidState: function() { + if (this.props.partlyAvailable) { + if (this.props.wholeDay && this.state.wholeDay) { + return true; + } else { + return this.isValidStart(this.state.start) && this.isValidEnd(this.state.end); + } + } + }, + // eslint-disable-next-line complexity + render: function() { + var buttonEnabled = this.isValidState(); + var showWholeDay = this.props.partlyAvailable && this.props.wholeDay; + var showTimeRange = this.props.partlyAvailable && (!this.props.wholeDay || !this.state.wholeDay); + + return ( +
+

{locale("Blocker")}

+ {showWholeDay && ( +
+ {locale("Whole day")} + + + + + +
+ )} + + {showTimeRange && ( +
+
+ + +
+
+ + +
+
+ )} + +
+
+ + +
+
+ + +
+ ); + + } +}); + +oc.BlockerForm.render = function(element, start, end, wholeDay, event, onSubmit) { + + var partlyAvailable = event.extendedProps.partlyAvailable; + var fullyAvailable = event.extendedProps.fullyAvailable; + var minStart = moment.max(moment(event.start), moment()); + var maxEnd = moment(event.end); + + ReactDOM.render( + , + element); +}; + + +/* + Allows to change the properties of an existing blocker. +*/ +oc.BlockerEditForm = React.createClass({ + getInitialState: function() { + return {reason: this.props.reason}; + }, + componentDidMount: function() { + var node = $(ReactDOM.findDOMNode(this)); + + // the timeout is set to 100ms because the popup will do its own focusing + // after 50ms (we could use it, but we want to focus AND select) + setTimeout(function() { + node.find('input:first').focus().select(); + }, 100); + }, + handleInputChange: function(e) { + var state = _.extend({}, this.state); + var name = e.target.getAttribute('name'); + + switch (name) { + case 'reason': + state.reason = e.target.value || null; + break; + default: + throw Error("Unknown input element: " + name); + } + + this.setState(state); + }, + handleButton: function(e) { + var node = ReactDOM.findDOMNode(this); + var self = this; + + $(node).find('input').each(function(_ix, el) { + $(el).blur(); + }); + + setTimeout(function() { + self.props.onSubmit.call(node, self.state); + }, 0); + + e.preventDefault(); + }, + render: function() { + return ( +
+
+
+ + +
+
+ + +
+ ); + + } +}); + +oc.BlockerEditForm.render = function(element, event, onSubmit) { + ReactDOM.render( + , + element); +}; + /* Allows to fine-adjust the date range for the PDF export. */ diff --git a/src/onegov/org/assets/js/reservationcalendar.jsx b/src/onegov/org/assets/js/reservationcalendar.jsx index e2acf3a47f..915d4a3c01 100644 --- a/src/onegov/org/assets/js/reservationcalendar.jsx +++ b/src/onegov/org/assets/js/reservationcalendar.jsx @@ -748,8 +748,6 @@ rc.renderPartitions = function(event, element, view) { } var calendar = view.calendar; - var free = _.template('
'); - var used = _.template('
'); // build the individual partitions var event_partitions = rc.adjustPartitions( @@ -762,9 +760,9 @@ rc.renderPartitions = function(event, element, view) { _.each(event_partitions, function(partition) { var reserved = partition[1]; if (reserved === false) { - partitions += free({height: partition[0]}); + partitions += '
'; } else { - partitions += used({height: partition[0]}); + partitions += '
'; } }); diff --git a/src/onegov/org/path.py b/src/onegov/org/path.py index f6e1b1cb0a..4ecb6be31a 100644 --- a/src/onegov/org/path.py +++ b/src/onegov/org/path.py @@ -3,6 +3,7 @@ import sedate from datetime import date, datetime +from libres.db.models import ReservationBlocker from onegov.api.models import ApiKey from onegov.chat import MessageCollection from onegov.chat import TextModule @@ -598,6 +599,27 @@ def get_reservation( return None +@OrgApp.path( + model=ReservationBlocker, + path='/reservation-blocker/{resource}/{id}', + converters={'resource': UUID, 'id': int} +) +def get_reservation_blocker( + app: OrgApp, + resource: UUID, + id: int +) -> ReservationBlocker | None: + + res = app.libres_resources.by_id(resource) + + if res is not None: + query = res.scheduler.managed_blockers() + query = query.filter(ReservationBlocker.id == id) + + return query.first() + return None + + @OrgApp.path(model=Clipboard, path='/clipboard/copy/{token}') def get_clipboard(request: OrgRequest, token: str) -> Clipboard | None: clipboard = Clipboard(request, token) diff --git a/src/onegov/org/theme/styles/org.scss b/src/onegov/org/theme/styles/org.scss index ff869e672b..26c316e488 100644 --- a/src/onegov/org/theme/styles/org.scss +++ b/src/onegov/org/theme/styles/org.scss @@ -3146,20 +3146,55 @@ button { } .occupancy-calendar { - .fc-view:not(.fc-list), .fc-view:not(.fc-list) + .fc-popover { - .event-accepted, - .event-accepted .fc-bg, - .event-accepted .fc-content { + .fc-view:not(.fc-list) .fc-event, + .fc-view:not(.fc-list) + .fc-popover .fc-event, + .fc-view-harness:has(.fc-view:not(.fc-list)) ~ .fc-event-dragging { + &.event-accepted, + &.event-accepted .fc-bg, + &.event-accepted .fc-content { border-width: 0; background-color: $blue-dark; } - .event-pending, - .event-pending .fc-bg, - .event-pending .fc-content { + &.event-pending, + &.event-pending .fc-bg, + &.event-pending .fc-content { border-width: 0; background-color: lighten(desaturate($blue-dark, 50%), 10%); } + + &.event-blocker, &.fc-event-mirror:not(.event-accepted,.event-pending) { + border-width: 0; + background-color: $red; + background-image: repeating-linear-gradient( + 45deg, + transparent, + transparent 10px, + rgba(0, 0, 0, 0.1) 10px, + rgba(0, 0, 0, 0.1) 20px + ); + + & .fc-event-main { + position: relative; + padding: 2px 4px !important; + padding-right: 20px !important; + font-weight: bold; + font-size: .875rem; + } + + & .delete-blocker { + display: inline-block; + position: absolute; + padding: 2px 6px; + top: 0; + right: 0; + font-size: .75rem; + + &:hover { + opacity: .75; + } + } + } } .fc-event, .fc-event-main { diff --git a/src/onegov/org/utils.py b/src/onegov/org/utils.py index a5e41e5ea7..347c849fd7 100644 --- a/src/onegov/org/utils.py +++ b/src/onegov/org/utils.py @@ -38,6 +38,7 @@ if TYPE_CHECKING: from _typeshed import SupportsRichComparison from collections.abc import Callable, Iterable, Iterator, Sequence + from libres.db.models import ReservationBlocker from lxml.etree import _Element from onegov.core.request import CoreRequest from onegov.form import Form, FormSubmission @@ -710,13 +711,110 @@ def event_start(self) -> str: def event_end(self) -> str: return self.allocation.display_end().isoformat() + @property + def blockable(self) -> bool: + if not self.request.is_manager: + return False + if self.allocation.quota == 1: + if self.allocation.partly_available: + return self.allocation.availability > 0.0 + return not self.allocation.reserved_slots + return self.allocation.quota_left == self.allocation.quota + def as_dict(self) -> dict[str, Any]: + blockable = self.blockable return { 'id': self.allocation.id, 'start': self.event_start, 'end': self.event_end, 'editable': False, 'display': 'background', + # extended properties + 'blockable': blockable, + 'blockurl': self.request.csrf_protected_url( + self.request.link(self.allocation, name='add-blocker') + ) if blockable else None, + 'partlyAvailable': self.allocation.partly_available, + 'fullyAvailable': self.allocation.availability == 100.0, + 'kind': 'allocation', + } + + +class BlockerEventInfo: + + __slots__ = ('resource', 'blocker', 'request', 'translate') + + def __init__( + self, + resource: Resource, + blocker: ReservationBlocker, + request: OrgRequest + ) -> None: + + self.resource = resource + self.blocker = blocker + self.request = request + self.translate = request.translate + + @classmethod + def from_blockers( + cls, + request: OrgRequest, + resource: Resource, + blockers: Iterable[ReservationBlocker] + ) -> list[Self]: + + return [ + cls(resource, blocker, request) + for blocker in blockers + ] + + @property + def title(self) -> str: + if self.blocker.reason is None: + return self.request.translate(_('Blocker')) + return self.blocker.reason + + @property + def event_start(self) -> str: + return self.blocker.display_start().isoformat() + + @property + def event_end(self) -> str: + return self.blocker.display_end().isoformat() + + @property + def editable(self) -> bool: + if not self.request.is_manager: + return False + return self.blocker.display_start() >= sedate.utcnow() + + def as_dict(self) -> dict[str, Any]: + is_manager = self.request.is_manager + editable = self.editable + return { + 'id': f'blocker-{self.blocker.id}', + 'start': self.event_start, + 'end': self.event_end, + 'title': self.title, + 'editable': editable, + 'classNames': 'event-blocker', + 'display': 'block', + # extended properties + '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, + 'deleteurl': self.request.csrf_protected_url(self.request.link( + self.blocker + )) if editable and is_manager else None, + 'seturl': self.request.csrf_protected_url(self.request.link( + self.blocker, + name='set-reason', + query_params={'blocker-id': str(self.blocker.id)} + )) if is_manager else None, + 'kind': 'blocker', } @@ -836,11 +934,6 @@ def event_classes(self) -> Iterator[str]: else: yield 'event-pending' - # HACK: Find event element by id, it would be better if fullcalendar - # generated an element id we could use, but we'll work with - # what we have. - yield f'event-{self.reservation.id}' - @property def color(self) -> str | None: tag = self.ticket.tag @@ -888,6 +981,7 @@ def as_dict(self) -> dict[str, Any]: name='adjust-reservation', query_params={'reservation-id': str(self.reservation.id)} )) if is_manager else None, + 'kind': 'reservation', } @@ -1009,6 +1103,7 @@ def as_dict(self) -> dict[str, Any]: 'wholeDay': self.whole_day, 'editable': False, 'editurl': None, + 'kind': 'reservation', } diff --git a/src/onegov/org/views/reservation_blocker.py b/src/onegov/org/views/reservation_blocker.py new file mode 100644 index 0000000000..11245561d9 --- /dev/null +++ b/src/onegov/org/views/reservation_blocker.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import isodate +import pytz +import sedate +import transaction + +from datetime import time +from libres.db.models import ReservationBlocker +from libres.modules.errors import LibresError +from onegov.core.custom import json +from onegov.core.security import Private +from onegov.org import _, OrgApp +from onegov.org import utils +from onegov.reservation import Allocation +from webob import exc, Response + + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from onegov.core.types import JSON_ro + from onegov.org.request import OrgRequest + from onegov.reservation import Reservation + + +def respond_with_success(request: OrgRequest) -> JSON_ro: + @request.after + def trigger_calendar_update(response: Response) -> None: + response.headers.add('X-IC-Trigger', 'oc-reservations-changed') + + return { + 'success': True + } + + +def respond_with_error(request: OrgRequest, error: str) -> JSON_ro: + if type(error) is not str: + error = request.translate(error) + + message: JSON_ro = { + 'message': error, + 'success': False + } + + @request.after + def trigger(response: Response) -> None: + response.headers.add('X-IC-Trigger', 'oc-reservation-error') + response.headers.add( + 'X-IC-Trigger-Data', + json.dumps(message, ensure_ascii=True) + ) + + return message + + +@OrgApp.json( + model=Allocation, + name='add-blocker', + request_method='POST', + permission=Private +) +def block_allocation(self: Allocation, request: OrgRequest) -> JSON_ro: + """ Adds a single blocker to the given allocation. """ + + request.assert_valid_csrf_token() + + # the blocker is defined through query parameters + start_str = request.params.get('start') or f'{self.start:%H:%M}' + end_str = request.params.get('end') or f'{self.end:%H:%M}' + if not isinstance(start_str, str) or not isinstance(end_str, str): + raise exc.HTTPBadRequest() + + reason = request.params.get('reason') + if reason is not None and not isinstance(reason, str): + raise exc.HTTPBadRequest() + + whole_day = request.params.get('whole_day') == '1' + + if self.partly_available: + if self.whole_day and whole_day: + start_time = time(0, 0) + end_time = time(23, 59) + else: + start_time = sedate.parse_time(start_str) + end_time = sedate.parse_time(end_str) + + try: + start, end = sedate.get_date_range( + self.display_start(), + start_time, + end_time, + raise_non_existent=True + ) + except pytz.NonExistentTimeError: + err = request.translate(_( + 'The selected time does not exist on this date due to ' + 'the switch from standard time to daylight saving time.' + )) + return respond_with_error(request, err) + else: + start, end = self.start, self.end + + resource = request.app.libres_resources.by_allocation(self) + assert resource is not None + + # if the allocation is in the past, disable it... + if end < sedate.utcnow(): + err = request.translate(_('This date lies in the past')) + + return respond_with_error(request, err) + + # ...otherwise, try to add a blocker + try: + resource.scheduler.add_blocker( + dates=(start, end), + reason=reason + ) + except LibresError as e: + return respond_with_error(request, utils.get_libres_error(e, request)) + else: + return respond_with_success(request) + + +@OrgApp.json( + model=ReservationBlocker, + request_method='DELETE', + permission=Private +) +def delete_blocker(self: ReservationBlocker, request: OrgRequest) -> JSON_ro: + + request.assert_valid_csrf_token() + + resource = request.app.libres_resources.by_blocker(self) + assert resource is not None + + try: + resource.scheduler.remove_blocker(self.token, self.id) + except LibresError as e: + return respond_with_error(request, utils.get_libres_error(e, request)) + else: + return respond_with_success(request) + + +@OrgApp.json( + model=ReservationBlocker, + name='adjust', + request_method='POST', + permission=Private +) +def adjust_blocker( + self: Reservation, + request: OrgRequest +) -> JSON_ro: + + request.assert_valid_csrf_token() + try: + new_start = isodate.parse_datetime(request.GET['start']) + new_end = isodate.parse_datetime(request.GET['end']) + except Exception: + raise exc.HTTPBadRequest() from None + + token = self.token + resource = request.app.libres_resources.by_reservation(self) + assert resource is not None + blocker_id_str = request.params.get('blocker-id') + if isinstance(blocker_id_str, str) and blocker_id_str.isdigit(): + blocker_id = int(blocker_id_str) + else: + raise exc.HTTPNotFound() + + blocker: ReservationBlocker | None = ( + resource.scheduler.blockers_by_token(token) + .filter(ReservationBlocker.id == blocker_id) + .one_or_none() + ) + if blocker is None or blocker.display_start() < sedate.utcnow(): + return respond_with_error( + request, + _('Blocker not adjustable') + ) + + if min(new_start, new_end) < sedate.utcnow(): + return respond_with_error( + request, + _('Cannot move blocker into the past') + ) + + savepoint = transaction.savepoint() + try: + resource.scheduler.change_blocker( + token, + blocker.id, + new_start, + new_end, + ) + except LibresError as e: + # rollback previous changes + request.session.flush() + savepoint.rollback() + return respond_with_error(request, utils.get_libres_error(e, request)) + + return respond_with_success(request) + + +@OrgApp.json( + model=ReservationBlocker, + name='set-reason', + request_method='POST', + permission=Private +) +def set_reason( + self: Reservation, + request: OrgRequest +) -> JSON_ro: + + request.assert_valid_csrf_token() + new_reason = request.GET.get('reason') or None + if new_reason is not None and not isinstance(new_reason, str): + raise exc.HTTPBadRequest() + + token = self.token + resource = request.app.libres_resources.by_reservation(self) + assert resource is not None + blocker_id_str = request.params.get('blocker-id') + if isinstance(blocker_id_str, str) and blocker_id_str.isdigit(): + blocker_id = int(blocker_id_str) + else: + raise exc.HTTPNotFound() + + blocker: ReservationBlocker | None = ( + resource.scheduler.blockers_by_token(token) + .filter(ReservationBlocker.id == blocker_id) + .one_or_none() + ) + if blocker is None: + return respond_with_error( + request, + _('Blocker no longer exists') + ) + + resource.scheduler.change_blocker_reason(token, new_reason) + return respond_with_success(request) diff --git a/src/onegov/org/views/resource.py b/src/onegov/org/views/resource.py index 3f5f8ec222..3e7972a85b 100644 --- a/src/onegov/org/views/resource.py +++ b/src/onegov/org/views/resource.py @@ -11,7 +11,7 @@ from datetime import date as date_t, datetime, time, timedelta from isodate import parse_date, ISO8601Error from itertools import islice - +from libres.db.models import ReservationBlocker from libres.modules.errors import LibresError from morepath.request import Response from onegov.core.security import Public, Private, Personal @@ -43,7 +43,6 @@ from sedate import utcnow, standardize_date from sqlalchemy import and_, func, select, cast as sa_cast, Boolean from sqlalchemy.orm import undefer, joinedload, Session - from webob import exc @@ -1200,6 +1199,11 @@ def view_occupancy_json(self: Resource, request: OrgRequest) -> JSON_ro: for field in self.occupancy_fields )) + # get all blockers + blockers = self.scheduler.managed_blockers() + blockers = blockers.filter(start <= ReservationBlocker.start) + blockers = blockers.filter(ReservationBlocker.end <= end) + return *( res.as_dict() for res in utils.ReservationEventInfo.from_reservations( @@ -1207,6 +1211,13 @@ def view_occupancy_json(self: Resource, request: OrgRequest) -> JSON_ro: self, query ) + ), *( + blk.as_dict() + for blk in utils.BlockerEventInfo.from_blockers( + request, + self, + blockers + ) ), *( av.as_dict() for av in utils.AvailabilityEventInfo.from_allocations( diff --git a/src/onegov/reservation/collection.py b/src/onegov/reservation/collection.py index be6613fb04..5b4fe707a5 100644 --- a/src/onegov/reservation/collection.py +++ b/src/onegov/reservation/collection.py @@ -6,12 +6,12 @@ from onegov.reservation.models import Resource from uuid import uuid4, UUID -from typing import overload, Any, Literal, TypeVar, TYPE_CHECKING +from typing import overload, Any, Literal, TypeVar, TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable from libres.context.core import Context - from libres.db.models import Allocation, Reservation + from libres.db.models import Allocation, Reservation, ReservationBlocker from libres.db.scheduler import Scheduler from sqlalchemy.orm import Query, Session @@ -124,6 +124,9 @@ def by_allocation(self, allocation: Allocation) -> Resource | None: def by_reservation(self, reservation: Reservation) -> Resource | None: return self.by_id(reservation.resource) + def by_blocker(self, blocker: ReservationBlocker) -> Resource | None: + return self.by_id(blocker.resource) + def ordered_by_type(self) -> Query[Resource]: return self.query().order_by(Resource.type, Resource.title) diff --git a/src/onegov/reservation/upgrade.py b/src/onegov/reservation/upgrade.py index 97834c1a21..32be2b54bf 100644 --- a/src/onegov/reservation/upgrade.py +++ b/src/onegov/reservation/upgrade.py @@ -9,7 +9,7 @@ from onegov.core.upgrade import upgrade_task from onegov.reservation import LibresIntegration from onegov.reservation import Resource -from sqlalchemy import Column, Text +from sqlalchemy import Column, Enum, Text from typing import TYPE_CHECKING @@ -164,3 +164,28 @@ def translate_default_views_to_their_new_names( content, '{default_view}', '"timeGridWeek"' ) WHERE content->>'default_view' = 'agendaWeek'; """) + + +@upgrade_task('Add source_type column to reserved_slots') +def add_source_type_column_to_reserved_slots(context: UpgradeContext) -> None: + if ( + context.has_table('reserved_slots') + and not context.has_column('reserved_slots', 'source_type') + ): + context.operations.add_column( + 'reserved_slots', + Column( + 'source_type', + Enum( + 'reservation', 'blocker', + name='reserved_slot_source_type' + ), + nullable=False, + server_default='reservation' + ) + ) + context.operations.alter_column( + 'reserved_slots', + 'source_type', + server_default=None + ) diff --git a/src/onegov/town6/theme/styles/fullcalendar.scss b/src/onegov/town6/theme/styles/fullcalendar.scss index ff7c75489c..a0b6d19d47 100644 --- a/src/onegov/town6/theme/styles/fullcalendar.scss +++ b/src/onegov/town6/theme/styles/fullcalendar.scss @@ -7,20 +7,55 @@ } .occupancy-calendar { - .fc-view:not(.fc-list), .fc-view:not(.fc-list) + .fc-popover { - .event-accepted, - .event-accepted .fc-bg, - .event-accepted .fc-content { + .fc-view:not(.fc-list) .fc-event, + .fc-view:not(.fc-list) + .fc-popover .fc-event, + .fc-view-harness:has(.fc-view:not(.fc-list)) ~ .fc-event-dragging { + &.event-accepted, + &.event-accepted .fc-bg, + &.event-accepted .fc-content { border-width: 0; background-color: $blue-dark; } - .event-pending, - .event-pending .fc-bg, - .event-pending .fc-content { + &.event-pending, + &.event-pending .fc-bg, + &.event-pending .fc-content { border-width: 0; background-color: lighten(desaturate($blue-dark, 50%), 10%); } + + &.event-blocker, &.fc-event-mirror:not(.event-accepted,.event-pending) { + border-width: 0; + background-color: $red; + background-image: repeating-linear-gradient( + 45deg, + transparent, + transparent 10px, + rgba(0, 0, 0, 0.1) 10px, + rgba(0, 0, 0, 0.1) 20px + ); + + & .fc-event-main { + position: relative; + padding: 2px 4px !important; + padding-right: 20px !important; + font-weight: bold; + font-size: .875rem; + } + + & .delete-blocker { + display: inline-block; + position: absolute; + padding: 2px 6px; + top: 0; + right: 0; + font-size: .75rem; + + &:hover { + opacity: .75; + } + } + } } .fc-event, .fc-event-main { From 4332483907ada63e8930b8356552ea692d606907 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 15 Jan 2026 17:24:35 +0100 Subject: [PATCH 2/4] Adds translations --- .../org/locale/de_CH/LC_MESSAGES/onegov.org.po | 14 +++++++++++++- .../org/locale/fr_CH/LC_MESSAGES/onegov.org.po | 14 +++++++++++++- .../org/locale/it_CH/LC_MESSAGES/onegov.org.po | 14 +++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po index 4c3b3cc2ef..459895350a 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-01-14 08:42+0100\n" +"POT-Creation-Date: 2026-01-15 17:20+0100\n" "PO-Revision-Date: 2022-03-15 10:21+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: German\n" @@ -6093,6 +6093,9 @@ msgstr "" "Um die Verfügbarkeit zu löschen, müssen zuerst alle bestehenden " "Reservationen abgesagt werden." +msgid "Blocker" +msgstr "Sperrzeit" + msgid "A conflicting allocation exists for the requested time period." msgstr "Es besteht bereits eine Verfügbarkeit im gewünschten Zeitraum." @@ -7314,6 +7317,15 @@ msgstr "Die Reservation ist unverändert geblieben" msgid "There are no future reservations" msgstr "Es gibt keine zukünftige Reservationen" +msgid "Blocker not adjustable" +msgstr "Dieser Sperrzeit kann nicht mehr angepasst werden" + +msgid "Cannot move blocker into the past" +msgstr "Eine Sperrzeit kann nicht in die Vergangenheit verschoben werden" + +msgid "Blocker no longer exists" +msgstr "Diese Sperrzeit existiert nicht mehr" + msgid "Added a new daypass" msgstr "Eine neue Tageskarte wurde hinzugefügt" 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 bd9e173981..9f13b0ba0c 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-01-14 08:42+0100\n" +"POT-Creation-Date: 2026-01-15 17:20+0100\n" "PO-Revision-Date: 2022-03-15 10:50+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: French\n" @@ -6107,6 +6107,9 @@ msgstr "" "Pour supprimer cette allocation, toutes les réservations existantes doivent " "être annulées en premier." +msgid "Blocker" +msgstr "Bloqueur" + msgid "A conflicting allocation exists for the requested time period." msgstr "Une allocation contradictoire existe pour la période demandée." @@ -7325,6 +7328,15 @@ msgstr "La réservation est restée inchangée" msgid "There are no future reservations" msgstr "Il n'y a pas de réservations futures" +msgid "Blocker not adjustable" +msgstr "Le bloqueur n'est pas réglable" + +msgid "Cannot move blocker into the past" +msgstr "Impossible de déplacer le bloqueur dans le passé" + +msgid "Blocker no longer exists" +msgstr "Le bloqueur n'existe plus" + msgid "Added a new daypass" msgstr "Nouvel abonnement à la journée ajouté" 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 585683ad70..00e0de020a 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-01-14 08:42+0100\n" +"POT-Creation-Date: 2026-01-15 17:20+0100\n" "PO-Revision-Date: 2022-03-15 10:52+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -6087,6 +6087,9 @@ msgstr "" "Per eliminare questa allocazione, è necessario prima annullare tutte le " "prenotazioni esistenti." +msgid "Blocker" +msgstr "Bloccante" + msgid "A conflicting allocation exists for the requested time period." msgstr "Esiste un'allocazione in conflitto per il periodo di tempo richiesto." @@ -7301,6 +7304,15 @@ msgstr "La prenotazione è rimasta invariata" msgid "There are no future reservations" msgstr "Non ci sono prenotazioni future" +msgid "Blocker not adjustable" +msgstr "Bloccante non regolabile" + +msgid "Cannot move blocker into the past" +msgstr "Impossibile spostare il bloccante nel passato" + +msgid "Blocker no longer exists" +msgstr "Il bloccante non esiste più" + msgid "Added a new daypass" msgstr "Aggiunto un nuovo biglietto giornaliero" From edf934c8313113d803a80eac12a11fb9a40e3639 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 15 Jan 2026 17:37:10 +0100 Subject: [PATCH 3/4] Fixes a couple of minor issues in JS --- src/onegov/org/assets/js/occupancycalendar.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/onegov/org/assets/js/occupancycalendar.jsx b/src/onegov/org/assets/js/occupancycalendar.jsx index 80f59c0741..fd97780c9c 100644 --- a/src/onegov/org/assets/js/occupancycalendar.jsx +++ b/src/onegov/org/assets/js/occupancycalendar.jsx @@ -79,7 +79,7 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { snapDuration: '00:15', editable: ocOptions.editable, eventResizableFromStart: ocOptions.editable, - selectable: ocOptions.add_blocker_url !== null && ocOptions.editable, + selectable: ocOptions.editable, initialView: ocOptions.view, locale: window.locale.language, multiMonthMaxColumns: 1 @@ -245,7 +245,7 @@ oc.getFullcalendarOptions = function(ocExtendOptions) { if (event.display === 'background') { return null; } - if (event.extendedProps.kind == 'blocker') { + if (event.extendedProps.kind === 'blocker') { return h('div', {title: event.title}, [ event.title, h('div', {class: 'delete-blocker', title: locale('Delete')}, [ From e2b178a71d04c4108d3fc63c07bdc873aa828165 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 15 Jan 2026 18:42:36 +0100 Subject: [PATCH 4/4] Adds basic test for new views. --- tests/onegov/org/conftest.py | 52 ++++++++++++++++++- tests/onegov/org/test_views_resources.py | 63 +++++++++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/tests/onegov/org/conftest.py b/tests/onegov/org/conftest.py index d31e5167b5..4e67a0fb7d 100644 --- a/tests/onegov/org/conftest.py +++ b/tests/onegov/org/conftest.py @@ -11,6 +11,7 @@ from onegov.core.elements import Element from onegov.core.orm.observer import ScopedPropertyObserver from onegov.core.utils import Bunch, module_path +from onegov.core.security import Public from onegov.form import ( Form, FormDefinitionCollection, @@ -20,7 +21,7 @@ from onegov.org import OrgApp from onegov.org.initial_content import create_new_organisation from onegov.org.layout import DefaultLayout -from onegov.org.models import TicketMessage +from onegov.org.models import Organisation, TicketMessage from onegov.org.views.ticket import delete_ticket from onegov.ticket import TicketCollection, Handler from onegov.user import User @@ -67,6 +68,15 @@ def __call__( whole_day: bool = ..., ) -> ExtendedResponse: ... + class _AddBlockerFunc(Protocol): + def __call__( + self, + start: str = ..., + end: str = ..., + whole_day: bool = ..., + reason: str | None = ..., + ) -> ExtendedResponse: ... + class _RenderFunc(Protocol): def __call__(self, element: Element) -> ExtendedResponse: ... else: @@ -77,6 +87,19 @@ class TestOrgApp(OrgApp): __test__ = False maildir: str +class CSRF: + pass + +@TestOrgApp.path(model=CSRF, path='csrf-token') +def get_csrf_dummy(app: TestOrgApp) -> CSRF: + return CSRF() + + +# NOTE: We add a testing view to retrieve a csrf token for the current session +@TestOrgApp.view(CSRF, permission=Public) +def get_csrf_token(self: Organisation, request: OrgRequest) -> str: + return request.csrf_token + @pytest.fixture(scope='function') def cfg_path( @@ -116,6 +139,10 @@ class Client(BaseClient[_OrgAppT]): skip_n_forms = 1 use_intercooler = True + @property + def csrf_token(self) -> str: + return self.get('/csrf-token').text + def bound_reserve(self, allocation: Allocation) -> _ReserveFunc: default_start = f'{allocation.start:%H:%M}' @@ -138,6 +165,29 @@ def reserve( return reserve + def bound_add_blocker(self, allocation: Allocation) -> _AddBlockerFunc: + + default_start = f'{allocation.start:%H:%M}' + default_end = f'{allocation.end:%H:%M}' + default_whole_day = allocation.whole_day + resource = allocation.resource + allocation_id = allocation.id + + def add_blocker( + start: str = default_start, + end: str = default_end, + whole_day: bool = default_whole_day, + reason: str | None = None, + ) -> ExtendedResponse: + return self.post( + f'/allocation/{resource}/{allocation_id}/add-blocker' + f'?start={start}&end={end}&reason={reason or ""}' + f'&whole_day={whole_day and "1" or "0"}' + f'&csrf-token={self.csrf_token}' + ) + + return add_blocker + @pytest.fixture(scope='function') def handlers() -> Iterator[HandlerRegistry]: diff --git a/tests/onegov/org/test_views_resources.py b/tests/onegov/org/test_views_resources.py index afe80056ef..4fa9b5b43f 100644 --- a/tests/onegov/org/test_views_resources.py +++ b/tests/onegov/org/test_views_resources.py @@ -13,11 +13,12 @@ import warnings from base64 import b64decode -from datetime import datetime, date +from datetime import datetime, date, timedelta from decimal import Decimal from freezegun import freeze_time from io import BytesIO from libres.db.models import Reservation +from urllib.parse import quote from onegov.core.utils import module_path, normalize_for_url from onegov.file import FileCollection @@ -1089,6 +1090,66 @@ def test_auto_accept_reservations(client: Client) -> None: assert 'You can pick it up at the counter' in page +@freeze_time("2015-08-28", tick=True) +def test_blocker_views(client: Client) -> None: + # prepare the required data + resources = ResourceCollection(client.app.libres_context) + resource = resources.by_name('tageskarte') + assert resource is not None + resource.definition = 'Note = ___' + resource.pick_up = 'You can pick it up at the counter' + scheduler = resource.get_scheduler(client.app.libres_context) + + allocations = scheduler.allocate( + dates=(datetime(2015, 8, 29), datetime(2015, 8, 29)), + whole_day=True, + partly_available=True + ) + + add_blocker = client.bound_add_blocker(allocations[0]) + transaction.commit() + + client.login_admin() + + # create a blocker + result = add_blocker(whole_day=True, reason='Cleaning') + assert result.json == {'success': True} + + blocker = scheduler.managed_blockers().one() + assert blocker.reason == 'Cleaning' + + # change the reason + client.post( + f'/reservation-blocker/{blocker.resource}/{blocker.id}/set-reason' + f'?blocker-id={blocker.id}&reason=No+reason' + f'&csrf-token={client.csrf_token}' + ) + + blocker = scheduler.managed_blockers().one() + assert blocker.reason == 'No reason' + + # adjust the time + new_start = blocker.display_start() + timedelta(hours=1) + new_end = blocker.display_end() - timedelta(hours=1) + result = client.post( + f'/reservation-blocker/{blocker.resource}/{blocker.id}/adjust' + f'?blocker-id={blocker.id}&start={quote(new_start.isoformat())}' + f'&end={quote(new_end.isoformat())}&csrf-token={client.csrf_token}' + ) + assert result.json == {'success': True} + + blocker = scheduler.managed_blockers().one() + assert blocker.display_start() == new_start + assert blocker.display_end() == new_end + + # delete the blocker + client.delete( + f'/reservation-blocker/{blocker.resource}/{blocker.id}' + f'?csrf-token={client.csrf_token}' + ) + assert scheduler.managed_blockers().one_or_none() is None + + @pytest.mark.parametrize( 'reject_type', ['reject-all', 'reject-all-with-message'] )