[CalendarServer-changes] [10323] CalendarServer/trunk/twistedcaldav/scheduling/processing.py
source_changes at macosforge.org
source_changes at macosforge.org
Thu Jan 24 18:51:41 PST 2013
Revision: 10323
http://trac.calendarserver.org//changeset/10323
Author: cdaboo at apple.com
Date: 2013-01-24 18:51:41 -0800 (Thu, 24 Jan 2013)
Log Message:
-----------
Change auto-accept processing so that partstat changes are only applied when the in-coming iTIP partstat is NEEDS-ACTION.
That preserves any manual partstat changes (e.g., proxy declines something that was auto-accepted) until the organizer
explicitly requests re-processing of partstat.
Modified Paths:
--------------
CalendarServer/trunk/twistedcaldav/scheduling/processing.py
Modified: CalendarServer/trunk/twistedcaldav/scheduling/processing.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/scheduling/processing.py 2013-01-25 02:47:19 UTC (rev 10322)
+++ CalendarServer/trunk/twistedcaldav/scheduling/processing.py 2013-01-25 02:51:41 UTC (rev 10323)
@@ -14,30 +14,34 @@
# limitations under the License.
##
-from twisted.python.log import err as log_traceback
-from twext.python.log import Logger
+from pycalendar.datetime import PyCalendarDateTime
+from pycalendar.duration import PyCalendarDuration
+from pycalendar.timezone import PyCalendarTimezone
-from twisted.internet import reactor
-from twisted.internet.defer import inlineCallbacks, returnValue
+from twext.python.log import Logger
from twext.web2.dav.method.report import NumberOfMatchesWithinLimits
from twext.web2.dav.util import joinURL
from twext.web2.http import HTTPError
+
+from twisted.internet import reactor
+from twisted.internet.defer import inlineCallbacks, returnValue
+from twisted.python.log import err as log_traceback
+
from twistedcaldav import customxml, caldavxml
from twistedcaldav.caldavxml import caldav_namespace
from twistedcaldav.config import config
from twistedcaldav.ical import Property
from twistedcaldav.instance import InvalidOverriddenInstanceError
+from twistedcaldav.memcachelock import MemcacheLock, MemcacheLockTimeoutError
+from twistedcaldav.memcacher import Memcacher
from twistedcaldav.method import report_common
from twistedcaldav.scheduling.cuaddress import normalizeCUAddr
from twistedcaldav.scheduling.itip import iTipProcessing, iTIPRequestStatus
from twistedcaldav.scheduling.utils import getCalendarObjectForPrincipals
-from twistedcaldav.memcachelock import MemcacheLock, MemcacheLockTimeoutError
-from twistedcaldav.memcacher import Memcacher
-from pycalendar.duration import PyCalendarDuration
-from pycalendar.datetime import PyCalendarDateTime
-from pycalendar.timezone import PyCalendarTimezone
-import uuid
+
+import collections
import hashlib
+import uuid
"""
CalDAV implicit processing.
@@ -504,8 +508,7 @@
if self.recipient.principal.canAutoSchedule(organizer=organizer):
# auto schedule mode can depend on who the organizer is
mode = self.recipient.principal.getAutoScheduleMode(organizer=organizer)
- send_reply, store_inbox, partstat = (yield self.checkAttendeeAutoReply(new_calendar,
- mode))
+ send_reply, store_inbox, partstat = (yield self.checkAttendeeAutoReply(new_calendar, mode))
# Only store inbox item when reply is not sent or always for users
store_inbox = store_inbox or self.recipient.principal.getCUType() == "INDIVIDUAL"
@@ -541,8 +544,7 @@
if self.recipient.principal.canAutoSchedule(organizer=organizer):
# auto schedule mode can depend on who the organizer is
mode = self.recipient.principal.getAutoScheduleMode(organizer=organizer)
- send_reply, store_inbox, partstat = (yield self.checkAttendeeAutoReply(new_calendar,
- mode))
+ send_reply, store_inbox, partstat = (yield self.checkAttendeeAutoReply(new_calendar, mode))
# Only store inbox item when reply is not sent or always for users
store_inbox = store_inbox or self.recipient.principal.getCUType() == "INDIVIDUAL"
@@ -724,14 +726,13 @@
@inlineCallbacks
def checkAttendeeAutoReply(self, calendar, automode):
"""
- Check whether a reply to the given iTIP message is needed. We will not process a reply
- A reply will either be positive (accepted invitation) or negative (denied invitation).
- In addition we will modify calendar to reflect
- any new state (e.g. set PARTSTAT to ACCEPTED or DECLINED).
+ Check whether a reply to the given iTIP message is needed and if so make the
+ appropriate changes to the calendar data. Changes are only made for the case
+ where the PARTSTAT of the attendee is NEEDS-ACTION - i.e., any existing state
+ is left unchanged. This allows, e.g., proxies to decline events that would
+ otherwise have been auto-accepted and those stay declined as non-schedule-change
+ updates are received.
- BTW The incoming iTIP message may contain multiple components so we need to iterate over all those.
- At the moment we will treat a failure on one instance as a DECLINE of the entire set.
-
@param calendar: the iTIP message to process
@type calendar: L{Component}
@param automode: the auto-schedule mode for the recipient
@@ -749,102 +750,106 @@
log.debug("ImplicitProcessing - recipient '%s' processing UID: '%s' - checking for auto-reply with mode: %s" % (self.recipient.cuaddr, self.uid, automode,))
- # The accept-always and decline-always modes do not need any freebusy checks
- if automode in ("accept-always", "decline-always",):
- all_accepted = automode == "accept-always"
- all_declined = automode == "decline-always"
+ cuas = self.recipient.principal.calendarUserAddresses()
- # Other modes need freebusy check
- else:
- # First expand current one to get instances (only go 1 year into the future)
- default_future_expansion_duration = PyCalendarDuration(days=356 * 1)
- expand_max = PyCalendarDateTime.getToday() + default_future_expansion_duration
- instances = calendar.expandTimeRanges(expand_max, ignoreInvalidInstances=True)
- instance_states = dict([(instance, True) for instance in instances.instances.itervalues()])
+ # First expand current one to get instances (only go 1 year into the future)
+ default_future_expansion_duration = PyCalendarDuration(days=365)
+ expand_max = PyCalendarDateTime.getToday() + default_future_expansion_duration
+ instances = calendar.expandTimeRanges(expand_max, ignoreInvalidInstances=True)
- # Extract UID from primary component as we want to ignore this one if we match it
- # in any calendars.
- comp = calendar.mainComponent(allow_multiple=True)
- uid = comp.propertyValue("UID")
+ # Cache the current attendee partstat on the instance object for later use, and
+ # also mark whether the instance time slot would be free
+ for instance in instances.instances.itervalues():
+ attendee = instance.component.getAttendeeProperty(cuas)
+ instance.partstat = attendee.parameterValue("PARTSTAT", "NEEDS-ACTION") if attendee else None
+ instance.free = True
- # Now compare each instance time-range with the index and see if there is an overlap
- calendars = (yield self._getCalendarsToMatch())
+ # Extract UID from primary component as we want to ignore this one if we match it
+ # in any calendars.
+ comp = calendar.mainComponent(allow_multiple=True)
+ uid = comp.propertyValue("UID")
- for calURL in calendars:
- testcal = (yield self.request.locateResource(calURL))
+ # Now compare each instance time-range with the index and see if there is an overlap
+ calendars = (yield self._getCalendarsToMatch())
- # Get the timezone property from the collection, and store in the query filter
- # for use during the query itself.
- has_prop = (yield testcal.hasProperty((caldav_namespace, "calendar-timezone"), self.request))
- if has_prop:
- tz = (yield testcal.readProperty((caldav_namespace, "calendar-timezone"), self.request))
- tzinfo = tz.calendar().gettimezone()
- else:
- tzinfo = PyCalendarTimezone(utc=True)
+ for calURL in calendars:
+ testcal = (yield self.request.locateResource(calURL))
- # Now do search for overlapping time-range
- for instance in instances.instances.itervalues():
- if instance_states[instance]:
- try:
- # First list is BUSY, second BUSY-TENTATIVE, third BUSY-UNAVAILABLE
- fbinfo = ([], [], [])
+ # Get the timezone property from the collection, and store in the query filter
+ # for use during the query itself.
+ has_prop = (yield testcal.hasProperty((caldav_namespace, "calendar-timezone"), self.request))
+ if has_prop:
+ tz = (yield testcal.readProperty((caldav_namespace, "calendar-timezone"), self.request))
+ tzinfo = tz.calendar().gettimezone()
+ else:
+ tzinfo = PyCalendarTimezone(utc=True)
- def makeTimedUTC(dt):
- dt = dt.duplicate()
- if dt.isDateOnly():
- dt.setDateOnly(False)
- dt.setHHMMSS(0, 0, 0)
- if dt.floating():
- dt.setTimezone(tzinfo)
- dt.adjustToUTC()
- return dt
+ # Now do search for overlapping time-range and set instance.free based
+ # on whether there is an overlap or not
+ for instance in instances.instances.itervalues():
+ if instance.partstat == "NEEDS-ACTION" and instance.free:
+ try:
+ # First list is BUSY, second BUSY-TENTATIVE, third BUSY-UNAVAILABLE
+ fbinfo = ([], [], [])
- tr = caldavxml.TimeRange(
- start=str(makeTimedUTC(instance.start)),
- end=str(makeTimedUTC(instance.end)),
- )
+ def makeTimedUTC(dt):
+ dt = dt.duplicate()
+ if dt.isDateOnly():
+ dt.setDateOnly(False)
+ dt.setHHMMSS(0, 0, 0)
+ if dt.floating():
+ dt.setTimezone(tzinfo)
+ dt.adjustToUTC()
+ return dt
- yield report_common.generateFreeBusyInfo(self.request, testcal, fbinfo, tr, 0, uid, servertoserver=True)
+ tr = caldavxml.TimeRange(
+ start=str(makeTimedUTC(instance.start)),
+ end=str(makeTimedUTC(instance.end)),
+ )
- # If any fbinfo entries exist we have an overlap
- if len(fbinfo[0]) or len(fbinfo[1]) or len(fbinfo[2]):
- instance_states[instance] = False
- except NumberOfMatchesWithinLimits:
- instance_states[instance] = False
- log.info("Exceeded number of matches whilst trying to find free-time.")
+ yield report_common.generateFreeBusyInfo(self.request, testcal, fbinfo, tr, 0, uid, servertoserver=True)
- # If everything is declined we can exit now
- if not any(instance_states.itervalues()):
- break
+ # If any fbinfo entries exist we have an overlap
+ if len(fbinfo[0]) or len(fbinfo[1]) or len(fbinfo[2]):
+ instance.free = False
+ except NumberOfMatchesWithinLimits:
+ instance.free[instance] = False
+ log.info("Exceeded number of matches whilst trying to find free-time.")
- # TODO: here we should do per-instance ACCEPT/DECLINE behavior
- # For now we will assume overall ACCEPT/DECLINE
+ # If everything is declined we can exit now
+ if not any([instance.free for instance in instances.instances.itervalues()]):
+ break
- # Collect all the accepted and declined states
- all_accepted = all(instance_states.itervalues())
- all_declined = not any(instance_states.itervalues())
+ # Now adjust the instance.partstat currently set to "NEEDS-ACTION" to the
+ # value determined by auto-accept logic based on instance.free state
+ partstat_counts = collections.defaultdict(int)
+ for instance in instances.instances.itervalues():
+ if instance.partstat == "NEEDS-ACTION":
+ if automode == "accept-always":
+ freePartstat = busyPartstat = "ACCEPTED"
+ elif automode == "decline-always":
+ freePartstat = busyPartstat = "DECLINED"
+ else:
+ freePartstat = "ACCEPTED" if automode in ("accept-if-free", "automatic",) else "NEEDS-ACTION"
+ busyPartstat = "DECLINED" if automode in ("decline-if-busy", "automatic",) else "NEEDS-ACTION"
+ instance.partstat = freePartstat if instance.free else busyPartstat
+ partstat_counts[instance.partstat] += 1
- # Do the simple case of all accepted or decline separately
- cuas = self.recipient.principal.calendarUserAddresses()
- if all_accepted or all_declined:
+ if len(partstat_counts) == 0:
+ # Nothing to do
+ returnValue((False, True, "",))
+
+ elif len(partstat_counts) == 1:
+ # Do the simple case of all PARTSTATs the same separately
# Extract the ATTENDEE property matching current recipient from the calendar data
attendeeProps = calendar.getAttendeeProperties(cuas)
if not attendeeProps:
returnValue((False, True, "",))
- if automode == "accept-always":
- freePartstat = busyPartstat = "ACCEPTED"
- elif automode == "decline-always":
- freePartstat = busyPartstat = "DECLINED"
- else:
- freePartstat = "ACCEPTED" if automode in ("accept-if-free", "automatic",) else "NEEDS-ACTION"
- busyPartstat = "DECLINED" if automode in ("decline-if-busy", "automatic",) else "NEEDS-ACTION"
- freeStateOpaque = freePartstat == "ACCEPTED"
-
- partstat = freePartstat if all_accepted else busyPartstat
- calendar.replacePropertyInAllComponents(Property("TRANSP", "OPAQUE" if all_accepted and freeStateOpaque else "TRANSPARENT"))
-
- made_changes = self.changeAttendeePartstat(attendeeProps, partstat)
+ made_changes = False
+ partstat = partstat_counts.keys()[0]
+ for component in calendar.subcomponents():
+ made_changes |= self.resetAttendeePartstat(component, cuas, partstat)
store_inbox = partstat == "NEEDS-ACTION"
else:
@@ -857,12 +862,8 @@
store_inbox = False
partstat = "MIXED RESPONSE"
- freePartstat = "ACCEPTED" if automode in ("accept-if-free", "automatic",) else "NEEDS-ACTION"
- busyPartstat = "DECLINED" if automode in ("decline-if-busy", "automatic",) else "NEEDS-ACTION"
- freeStateOpaque = freePartstat == "ACCEPTED"
-
# Default state is whichever of free or busy has most instances
- defaultStateFree = len(filter(lambda x: x, instance_states.values())) >= len(instance_states.keys()) / 2
+ defaultPartStat = max(partstat_counts.items(), key=lambda x: x[1])[0]
# See if there is a master component first
hadMasterRsvp = False
@@ -871,29 +872,23 @@
attendee = master.getAttendeeProperty(cuas)
if attendee:
hadMasterRsvp = attendee.parameterValue("RSVP", "FALSE") == "TRUE"
- new_partstat = freePartstat if defaultStateFree else busyPartstat
- if new_partstat == "NEEDS-ACTION":
+ if defaultPartStat == "NEEDS-ACTION":
store_inbox = True
- made_changes |= self.changeAttendeePartstat(attendee, new_partstat)
- master.replaceProperty(Property("TRANSP", "OPAQUE" if defaultStateFree and freeStateOpaque else "TRANSPARENT"))
+ made_changes |= self.resetAttendeePartstat(master, cuas, defaultPartStat)
# Look at expanded instances and change partstat accordingly
- for instance, free in sorted(instance_states.iteritems(), key=lambda x: x[0].rid):
+ for instance in sorted(instances.instances.values(), key=lambda x: x.rid):
overridden = calendar.overriddenComponent(instance.rid)
- if not overridden and free == defaultStateFree:
+ if not overridden and instance.partstat == defaultPartStat:
# Nothing to do as state matches the master
continue
if overridden:
# Change ATTENDEE property to match new state
- attendee = overridden.getAttendeeProperty(cuas)
- if attendee:
- new_partstat = freePartstat if free else busyPartstat
- if new_partstat == "NEEDS-ACTION":
- store_inbox = True
- made_changes |= self.changeAttendeePartstat(attendee, new_partstat)
- overridden.replaceProperty(Property("TRANSP", "OPAQUE" if free and freeStateOpaque else "TRANSPARENT"))
+ if instance.partstat == "NEEDS-ACTION":
+ store_inbox = True
+ made_changes |= self.resetAttendeePartstat(overridden, cuas, instance.partstat)
else:
# Derive a new overridden component and change partstat. We also need to make sure we restore any RSVP
# value that may have been overwritten by any change to the master itself.
@@ -901,13 +896,11 @@
if derived:
attendee = derived.getAttendeeProperty(cuas)
if attendee:
- new_partstat = freePartstat if free else busyPartstat
- if new_partstat == "NEEDS-ACTION":
+ if instance.partstat == "NEEDS-ACTION":
store_inbox = True
- self.changeAttendeePartstat(attendee, new_partstat, hadMasterRsvp)
- derived.replaceProperty(Property("TRANSP", "OPAQUE" if free and freeStateOpaque else "TRANSPARENT"))
- calendar.addComponent(derived)
+ self.resetAttendeePartstat(derived, cuas, instance.partstat, hadMasterRsvp)
made_changes = True
+ calendar.addComponent(derived)
# Fake a SCHEDULE-STATUS on the ORGANIZER property
if made_changes:
@@ -981,12 +974,16 @@
yield delchild.storeRemove(self.request, False, childURL)
- def changeAttendeePartstat(self, attendees, partstat, hadRSVP=False):
+ def resetAttendeePartstat(self, component, cuas, partstat, hadRSVP=False):
"""
- Change the PARTSTAT on any ATTENDEE properties passed in.
+ Change the PARTSTAT on any ATTENDEE properties that match the list of calendar user
+ addresses on the component passed in. Also adjust the TRANSP property to match the
+ new PARTSTAT value.
- @param attendees: a single ATTENDEE property or a list of them
- @type attendees: L{Property}, C{list} or C{tuple}
+ @param component: an iCalendar component to modify
+ @type attendees: L{Component}
+ @param cuas: a list of calendar user addresses to match
+ @type attendees: C{list} or C{tuple}
@param partstat: new PARTSTAT to set
@type partstat: C{str}
@param hadRSVP: indicates whether RSVP should be added when changing to NEEDS-ACTION
@@ -995,11 +992,9 @@
@return: C{True} if any change was made, C{False} otherwise
"""
- if isinstance(attendees, Property):
- attendees = (attendees,)
-
madeChanges = False
- for attendee in attendees:
+ attendee = component.getAttendeeProperty(cuas)
+ if attendee:
if attendee.parameterValue("PARTSTAT", "NEEDS-ACTION") != partstat:
attendee.setParameter("PARTSTAT", partstat)
madeChanges = True
@@ -1014,6 +1009,9 @@
except KeyError:
pass
+ # Adjust TRANSP to OPAQUE if PARTSTAT is ACCEPTED, otherwise TRANSPARENT
+ component.replaceProperty(Property("TRANSP", "OPAQUE" if partstat == "ACCEPTED" else "TRANSPARENT"))
+
return madeChanges
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20130124/65e65417/attachment-0001.html>
More information about the calendarserver-changes
mailing list