[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