# HG changeset patch # User Paul Boddie # Date 1454102702 -3600 # Node ID 7f4bd7d4236a3b6378345d53b1751fd7addbb837 # Parent fbf21b1a8c0224bed7e57b5ca4dc77b91faf8102 Added support for additional scheduling modules, moving the existing functionality into the freebusy module, providing initial functionality for an access-based module, and introducing a mechanism to update the registry of modules explicitly. Added a test of combining scheduling functions. diff -r fbf21b1a8c02 -r 7f4bd7d4236a docs/wiki/Administration --- a/docs/wiki/Administration Fri Jan 29 22:17:47 2016 +0100 +++ b/docs/wiki/Administration Fri Jan 29 22:25:02 2016 +0100 @@ -16,6 +16,8 @@ In the background, imip-agent uses and updates information as described in the [[../FilesystemUsage|filesystem usage guide]]. +<> + == Initialising the System == As described in the [[../GettingStarted|getting started guide]], the system is @@ -61,7 +63,7 @@ be "handed over" at regular intervals. The `freebusy_offers` setting, together with the `scheduling_function` setting, -would allow different kinds of resources to "keep open" tentatively-suggested +allows different kinds of resources to "keep open" tentatively-suggested periods for different lengths of time, allowing frequently-requested resources to respond to scheduling requests in a timely fashion, whilst also allowing other resources to give more time to event organisers to respond to their @@ -108,3 +110,20 @@ from being routed via imip-agent. This is as simple as either not listing the identity in [[../MailIntegration/Simple|lists of addresses]] or by adjusting [[../MailIntegration/LDAP|queries yielding calendar users]]. + +== Adding Scheduling Functions == + +The `scheduling_function` setting employs functions that reside within modules in +the `imiptools.handlers.scheduling` package. Extra modules can be installed in +this package by adding files to the `scheduling` directory within the software +installation. + +After adding modules, a tool must be run to register the new modules: + +{{{ +tools/update_scheduling_modules.py +}}} + +It is envisaged that the installation of additional scheduling modules and the +use of this tool will be performed by the packaging system provided by an +operating system distribution. diff -r fbf21b1a8c02 -r 7f4bd7d4236a imiptools/handlers/scheduling/__init__.py --- a/imiptools/handlers/scheduling/__init__.py Fri Jan 29 22:17:47 2016 +0100 +++ b/imiptools/handlers/scheduling/__init__.py Fri Jan 29 22:25:02 2016 +0100 @@ -19,11 +19,7 @@ this program. If not, see . """ -from imiptools.data import uri_values -from imiptools.dates import ValidityError, to_timezone -from imiptools.period import coalesce_freebusy, invert_freebusy, \ - periods_from, remove_event_periods, \ - remove_periods +from imiptools.handlers.scheduling.manifest import scheduling_functions def apply_scheduling_functions(functions, handler): @@ -60,196 +56,4 @@ return response -def schedule_in_freebusy(handler, freebusy=None): - - """ - Attempt to schedule the current object of the given 'handler' in the - free/busy schedule of a resource, returning an indication of the kind of - response to be returned. - - If 'freebusy' is specified, the given collection of busy periods will be - used to determine whether any conflicts occur. Otherwise, the current user's - free/busy records will be used. - """ - - # If newer than any old version, discard old details from the - # free/busy record and check for suitability. - - periods = handler.get_periods(handler.obj) - - freebusy = freebusy or handler.store.get_freebusy(handler.user) - offers = handler.store.get_freebusy_offers(handler.user) - - # Check the periods against any scheduled events and against - # any outstanding offers. - - scheduled = handler.can_schedule(freebusy, periods) - scheduled = scheduled and handler.can_schedule(offers, periods) - - return scheduled and "ACCEPTED" or "DECLINED" - -def schedule_corrected_in_freebusy(handler): - - """ - Attempt to schedule the current object of the given 'handler', correcting - specified datetimes according to the configuration of a resource, - returning an indication of the kind of response to be returned. - """ - - obj = handler.obj.copy() - - # Check any constraints on the request. - - try: - corrected = handler.correct_object() - - # Refuse to schedule obviously invalid requests. - - except ValidityError: - return None - - # With a valid request, determine whether the event can be scheduled. - - scheduled = schedule_in_freebusy(handler) - - # Restore the original object if it was corrected but could not be - # scheduled. - - if scheduled == "DECLINED" and corrected: - handler.set_object(obj) - - # Where the corrected object can be scheduled, issue a counter - # request. - - return scheduled == "ACCEPTED" and (corrected and "COUNTER" or "ACCEPTED") or "DECLINED" - -def schedule_next_available_in_freebusy(handler): - - """ - Attempt to schedule the current object of the given 'handler', correcting - specified datetimes according to the configuration of a resource, then - suggesting the next available period in the free/busy records if scheduling - cannot occur for the requested period, returning an indication of the kind - of response to be returned. - """ - - scheduled = schedule_corrected_in_freebusy(handler) - - if scheduled in ("ACCEPTED", "COUNTER"): - return scheduled - - # There should already be free/busy information for the user. - - user_freebusy = handler.store.get_freebusy(handler.user) - busy = user_freebusy - - # Subtract any periods from this event from the free/busy collections. - - event_periods = remove_event_periods(user_freebusy, handler.uid, handler.recurrenceid) - - # Find busy periods for the other attendees. - - for attendee in uri_values(handler.obj.get_values("ATTENDEE")): - if attendee != handler.user: - freebusy = handler.store.get_freebusy_for_other(handler.user, attendee) - if freebusy: - remove_periods(freebusy, event_periods) - busy += freebusy - - # Obtain the combined busy periods. - - busy.sort() - busy = coalesce_freebusy(busy) - - # Obtain free periods. - - free = invert_freebusy(busy) - permitted_values = handler.get_permitted_values() - periods = [] - - # Do not attempt to redefine rule-based periods. - - last = None - - for period in handler.get_periods(handler.obj, explicit_only=True): - duration = period.get_duration() - - # Try and schedule periods normally since some of them may be - # compatible with the schedule. - - if permitted_values: - period = period.get_corrected(permitted_values) - - scheduled = handler.can_schedule(freebusy, [period]) - - if scheduled == "ACCEPTED": - periods.append(period) - last = period.get_end() - continue - - # Get free periods from the time of each period. - - for found in periods_from(free, period): - - # Skip any periods before the last period. - - if last: - if last > found.get_end(): - continue - - # Adjust the start of the free period to exclude the last period. - - found = found.make_corrected(max(found.get_start(), last), found.get_end()) - - # Only test free periods long enough to hold the requested period. - - if found.get_duration() >= duration: - - # Obtain a possible period, starting at the found point and - # with the requested duration. Then, correct the period if - # necessary. - - start = to_timezone(found.get_start(), period.get_tzid()) - possible = period.make_corrected(start, start + period.get_duration()) - if permitted_values: - possible = possible.get_corrected(permitted_values) - - # Only if the possible period is still within the free period - # can it be used. - - if possible.within(found): - periods.append(possible) - break - - # Where no period can be found, decline the invitation. - - else: - return "DECLINED" - - # Use the found period to set the start of the next window to search. - - last = periods[-1].get_end() - - # Replace the periods in the object. - - obj = handler.obj.copy() - changed = handler.obj.set_periods(periods) - - # Check one last time, reverting the change if not scheduled. - - scheduled = schedule_in_freebusy(handler, busy) - - if scheduled == "DECLINED": - handler.set_object(obj) - - return scheduled == "ACCEPTED" and (changed and "COUNTER" or "ACCEPTED") or "DECLINED" - -# Registry of scheduling functions. - -scheduling_functions = { - "schedule_in_freebusy" : schedule_in_freebusy, - "schedule_corrected_in_freebusy" : schedule_corrected_in_freebusy, - "schedule_next_available_in_freebusy" : schedule_next_available_in_freebusy, - } - # vim: tabstop=4 expandtab shiftwidth=4 diff -r fbf21b1a8c02 -r 7f4bd7d4236a imiptools/handlers/scheduling/access.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imiptools/handlers/scheduling/access.py Fri Jan 29 22:25:02 2016 +0100 @@ -0,0 +1,45 @@ +#!/usr/bin/env python + +""" +Access-control-related scheduling functionality. + +Copyright (C) 2016 Paul Boddie + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +from imiptools.data import get_address + +def same_domain_only(handler): + + """ + Attempt to schedule the current object of the given 'handler' if the + organiser employs an address in the same domain as the resource. + """ + + organiser = get_address(handler.obj.get_value("ORGANIZER")) + user = get_address(handler.user) + + organiser_domain = organiser.rsplit("@", 1)[-1] + user_domain = user.rsplit("@", 1)[-1] + + return organiser_domain == user_domain and "ACCEPTED" or "DECLINED" + +# Registry of scheduling functions. + +scheduling_functions = { + "same_domain_only" : same_domain_only, + } + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r fbf21b1a8c02 -r 7f4bd7d4236a imiptools/handlers/scheduling/freebusy.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imiptools/handlers/scheduling/freebusy.py Fri Jan 29 22:25:02 2016 +0100 @@ -0,0 +1,220 @@ +#!/usr/bin/env python + +""" +Free/busy-related scheduling functionality. + +Copyright (C) 2015, 2016 Paul Boddie + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +from imiptools.data import uri_values +from imiptools.dates import ValidityError, to_timezone +from imiptools.period import coalesce_freebusy, invert_freebusy, \ + periods_from, remove_event_periods, \ + remove_periods + +def schedule_in_freebusy(handler, freebusy=None): + + """ + Attempt to schedule the current object of the given 'handler' in the + free/busy schedule of a resource, returning an indication of the kind of + response to be returned. + + If 'freebusy' is specified, the given collection of busy periods will be + used to determine whether any conflicts occur. Otherwise, the current user's + free/busy records will be used. + """ + + # If newer than any old version, discard old details from the + # free/busy record and check for suitability. + + periods = handler.get_periods(handler.obj) + + freebusy = freebusy or handler.store.get_freebusy(handler.user) + offers = handler.store.get_freebusy_offers(handler.user) + + # Check the periods against any scheduled events and against + # any outstanding offers. + + scheduled = handler.can_schedule(freebusy, periods) + scheduled = scheduled and handler.can_schedule(offers, periods) + + return scheduled and "ACCEPTED" or "DECLINED" + +def schedule_corrected_in_freebusy(handler): + + """ + Attempt to schedule the current object of the given 'handler', correcting + specified datetimes according to the configuration of a resource, + returning an indication of the kind of response to be returned. + """ + + obj = handler.obj.copy() + + # Check any constraints on the request. + + try: + corrected = handler.correct_object() + + # Refuse to schedule obviously invalid requests. + + except ValidityError: + return None + + # With a valid request, determine whether the event can be scheduled. + + scheduled = schedule_in_freebusy(handler) + + # Restore the original object if it was corrected but could not be + # scheduled. + + if scheduled == "DECLINED" and corrected: + handler.set_object(obj) + + # Where the corrected object can be scheduled, issue a counter + # request. + + return scheduled == "ACCEPTED" and (corrected and "COUNTER" or "ACCEPTED") or "DECLINED" + +def schedule_next_available_in_freebusy(handler): + + """ + Attempt to schedule the current object of the given 'handler', correcting + specified datetimes according to the configuration of a resource, then + suggesting the next available period in the free/busy records if scheduling + cannot occur for the requested period, returning an indication of the kind + of response to be returned. + """ + + scheduled = schedule_corrected_in_freebusy(handler) + + if scheduled in ("ACCEPTED", "COUNTER"): + return scheduled + + # There should already be free/busy information for the user. + + user_freebusy = handler.store.get_freebusy(handler.user) + busy = user_freebusy + + # Subtract any periods from this event from the free/busy collections. + + event_periods = remove_event_periods(user_freebusy, handler.uid, handler.recurrenceid) + + # Find busy periods for the other attendees. + + for attendee in uri_values(handler.obj.get_values("ATTENDEE")): + if attendee != handler.user: + freebusy = handler.store.get_freebusy_for_other(handler.user, attendee) + if freebusy: + remove_periods(freebusy, event_periods) + busy += freebusy + + # Obtain the combined busy periods. + + busy.sort() + busy = coalesce_freebusy(busy) + + # Obtain free periods. + + free = invert_freebusy(busy) + permitted_values = handler.get_permitted_values() + periods = [] + + # Do not attempt to redefine rule-based periods. + + last = None + + for period in handler.get_periods(handler.obj, explicit_only=True): + duration = period.get_duration() + + # Try and schedule periods normally since some of them may be + # compatible with the schedule. + + if permitted_values: + period = period.get_corrected(permitted_values) + + scheduled = handler.can_schedule(freebusy, [period]) + + if scheduled == "ACCEPTED": + periods.append(period) + last = period.get_end() + continue + + # Get free periods from the time of each period. + + for found in periods_from(free, period): + + # Skip any periods before the last period. + + if last: + if last > found.get_end(): + continue + + # Adjust the start of the free period to exclude the last period. + + found = found.make_corrected(max(found.get_start(), last), found.get_end()) + + # Only test free periods long enough to hold the requested period. + + if found.get_duration() >= duration: + + # Obtain a possible period, starting at the found point and + # with the requested duration. Then, correct the period if + # necessary. + + start = to_timezone(found.get_start(), period.get_tzid()) + possible = period.make_corrected(start, start + period.get_duration()) + if permitted_values: + possible = possible.get_corrected(permitted_values) + + # Only if the possible period is still within the free period + # can it be used. + + if possible.within(found): + periods.append(possible) + break + + # Where no period can be found, decline the invitation. + + else: + return "DECLINED" + + # Use the found period to set the start of the next window to search. + + last = periods[-1].get_end() + + # Replace the periods in the object. + + obj = handler.obj.copy() + changed = handler.obj.set_periods(periods) + + # Check one last time, reverting the change if not scheduled. + + scheduled = schedule_in_freebusy(handler, busy) + + if scheduled == "DECLINED": + handler.set_object(obj) + + return scheduled == "ACCEPTED" and (changed and "COUNTER" or "ACCEPTED") or "DECLINED" + +# Registry of scheduling functions. + +scheduling_functions = { + "schedule_in_freebusy" : schedule_in_freebusy, + "schedule_corrected_in_freebusy" : schedule_corrected_in_freebusy, + "schedule_next_available_in_freebusy" : schedule_next_available_in_freebusy, + } + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r fbf21b1a8c02 -r 7f4bd7d4236a imiptools/handlers/scheduling/manifest.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imiptools/handlers/scheduling/manifest.py Fri Jan 29 22:25:02 2016 +0100 @@ -0,0 +1,5 @@ +scheduling_functions = {} +from imiptools.handlers.scheduling.freebusy import scheduling_functions as l +scheduling_functions.update(l) +from imiptools.handlers.scheduling.access import scheduling_functions as l +scheduling_functions.update(l) diff -r fbf21b1a8c02 -r 7f4bd7d4236a tests/templates/event-request-sauna-outsider.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/templates/event-request-sauna-outsider.txt Fri Jan 29 22:25:02 2016 +0100 @@ -0,0 +1,34 @@ +Content-Type: multipart/alternative; boundary="===============0047278175==" +MIME-Version: 1.0 +From: paul.boddie@example.net +To: resource-room-sauna@example.com +Subject: Wanting to book the sauna! + +--===============0047278175== +Content-Type: text/plain; charset="us-ascii" +MIME-Version: 1.0 +Content-Transfer-Encoding: 7bit + +This message contains an event. +--===============0047278175== +MIME-Version: 1.0 +Content-Transfer-Encoding: 7bit +Content-Type: text/calendar; charset="us-ascii"; method="REQUEST" + +BEGIN:VCALENDAR +PRODID:-//imip-agent/test//EN +METHOD:REQUEST +VERSION:2.0 +BEGIN:VEVENT +ORGANIZER:mailto:paul.boddie@example.net +ATTENDEE;ROLE=CHAIR:mailto:paul.boddie@example.net +ATTENDEE;RSVP=TRUE:mailto:resource-room-sauna@example.com +DTSTAMP:20141125T004600Z +DTSTART;TZID=Europe/Oslo:20141126T160000 +DTEND;TZID=Europe/Oslo:20141126T164500 +SUMMARY:Meeting at 4pm +UID:event1@example.net +END:VEVENT +END:VCALENDAR + +--===============0047278175==-- diff -r fbf21b1a8c02 -r 7f4bd7d4236a tests/test_resource_invitation_constraints_multiple.sh --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/test_resource_invitation_constraints_multiple.sh Fri Jan 29 22:25:02 2016 +0100 @@ -0,0 +1,89 @@ +#!/bin/sh + +. "`dirname \"$0\"`/common.sh" + +USER="mailto:resource-room-sauna@example.com" +SENDER="mailto:paul.boddie@example.net" +FBFILE="$STORE/$USER/freebusy" +FBOFFERFILE="$STORE/$USER/freebusy-offers" +FBSENDERFILE="$STORE/$SENDER/freebusy" +FBSENDEROTHERFILE="$STORE/$SENDER/freebusy-other/$USER" +FBSENDERREQUESTS="$STORE/$SENDER/requests" + +mkdir -p "$PREFS/$USER" +echo 'Europe/Oslo' > "$PREFS/$USER/TZID" +echo 'share' > "$PREFS/$USER/freebusy_sharing" +cat > "$PREFS/$USER/scheduling_function" < "$PREFS/$USER/permitted_times" +echo 'PT60S' > "$PREFS/$USER/freebusy_offers" + + "$RESOURCE_SCRIPT" $ARGS < "$TEMPLATES/fb-request-sauna-all.txt" 2>> $ERROR \ +| "$SHOWMAIL" \ +> out0.tmp + + grep -q 'METHOD:REPLY' out0.tmp \ +&& ! grep -q '^FREEBUSY' out0.tmp \ +&& echo "Success" \ +|| echo "Failed" + +# Attempt to schedule an event. + +"$OUTGOING_SCRIPT" $ARGS < "$TEMPLATES/event-request-sauna-outsider.txt" 2>> $ERROR + + grep -q "^20141126T150000Z${TAB}20141126T154500Z" "$FBSENDERFILE" \ +&& echo "Success" \ +|| echo "Failed" + +# Present the request to the resource. + + "$RESOURCE_SCRIPT" $ARGS < "$TEMPLATES/event-request-sauna-outsider.txt" 2>> $ERROR \ +| tee out1r.tmp \ +| "$SHOWMAIL" \ +> out1.tmp + + grep -q 'METHOD:REPLY' out1.tmp \ +&& grep -q 'ATTENDEE.*;PARTSTAT=DECLINED' out1.tmp \ +&& echo "Success" \ +|| echo "Failed" + + ! [ -e "$FBFILE" ] \ +|| ! grep -q "^20141126T150000Z${TAB}20141126T154500Z" "$FBFILE" \ +&& echo "Success" \ +|| echo "Failed" + +# Relax the scheduling function. + +cat > "$PREFS/$USER/scheduling_function" <> $ERROR \ +| tee out2r.tmp \ +| "$SHOWMAIL" \ +> out2.tmp + + grep -q 'METHOD:REPLY' out2.tmp \ +&& grep -q 'ATTENDEE.*;PARTSTAT=ACCEPTED' out2.tmp \ +&& echo "Success" \ +|| echo "Failed" + + [ -e "$FBFILE" ] \ +&& grep -q "^20141126T150000Z${TAB}20141126T154500Z" "$FBFILE" \ +&& echo "Success" \ +|| echo "Failed" + +# Check the free/busy state of the resource again. + + "$RESOURCE_SCRIPT" $ARGS < "$TEMPLATES/fb-request-sauna-all.txt" 2>> $ERROR \ +| "$SHOWMAIL" \ +> out3.tmp + + grep -q 'METHOD:REPLY' out3.tmp \ +&& grep -q 'FREEBUSY;FBTYPE=BUSY:20141126T150000Z/20141126T154500Z' out3.tmp \ +&& echo "Success" \ +|| echo "Failed" diff -r fbf21b1a8c02 -r 7f4bd7d4236a tools/install.sh --- a/tools/install.sh Fri Jan 29 22:17:47 2016 +0100 +++ b/tools/install.sh Fri Jan 29 22:25:02 2016 +0100 @@ -26,12 +26,13 @@ cp $AGENTS "$INSTALL_DIR" cp $MODULES "$INSTALL_DIR" -if [ ! -e "$INSTALL_DIR/imiptools" ]; then - mkdir "$INSTALL_DIR/imiptools" -fi -if [ ! -e "$INSTALL_DIR/imiptools/handlers" ]; then - mkdir "$INSTALL_DIR/imiptools/handlers" -fi +for DIR in "$INSTALL_DIR/imiptools" \ + "$INSTALL_DIR/imiptools/handlers" \ + "$INSTALL_DIR/imiptools/handlers/scheduling" ; do + if [ ! -e "$DIR" ]; then + mkdir "$DIR" + fi +done # Remove any symbolic link to the config module. @@ -43,6 +44,13 @@ cp imiptools/*.py "$INSTALL_DIR/imiptools/" cp imiptools/handlers/*.py "$INSTALL_DIR/imiptools/handlers/" +cp imiptools/handlers/scheduling/*.py "$INSTALL_DIR/imiptools/handlers/scheduling/" + +# Remove migrated modules. + +if [ -e "$INSTALL_DIR/imiptools/handlers/scheduling.py" ]; then + rm "$INSTALL_DIR/imiptools/handlers/scheduling.py"* +fi # Install the config module in a more appropriate location. diff -r fbf21b1a8c02 -r 7f4bd7d4236a tools/update_scheduling_modules.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tools/update_scheduling_modules.py Fri Jan 29 22:25:02 2016 +0100 @@ -0,0 +1,55 @@ +#!/usr/bin/env python + +""" +Update the scheduling modules import manifest. + +Copyright (C) 2016 Paul Boddie + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +from glob import glob +from os.path import join, split, splitext +import imiptools.handlers + +reserved = ["__init__.py", "manifest.py"] + +# The main program generating a new version of the manifest module. + +if __name__ == "__main__": + dirname = join(split(imiptools.handlers.__file__)[0], "scheduling") + + # Get all Python files in the scheduling directory, filtering out the + # reserved files that do not provide scheduling functions. + + filenames = [] + for filename in glob(join(dirname, "*.py")): + filename = split(filename)[-1] + if filename not in reserved: + filenames.append(filename) + + # Open the manifest module and write code to import and combine the + # functions from each module. + + f = open(join(dirname, "manifest.py"), "w") + try: + print >>f, "scheduling_functions = {}" + for filename in filenames: + module = splitext(filename)[0] + print >>f, "from imiptools.handlers.scheduling.%s import scheduling_functions as l" % module + print >>f, "scheduling_functions.update(l)" + finally: + f.close() + +# vim: tabstop=4 expandtab shiftwidth=4