[CalendarServer-changes] [13947] CalendarServer/trunk

source_changes at macosforge.org source_changes at macosforge.org
Wed Sep 10 07:52:49 PDT 2014


Revision: 13947
          http://trac.calendarserver.org//changeset/13947
Author:   cdaboo at apple.com
Date:     2014-09-10 07:52:49 -0700 (Wed, 10 Sep 2014)
Log Message:
-----------
Revamp group attendee expansion to properly handle removal of the group attendee/calendar object resource link when instance expansion is not done.
Also tweak the split timing parameter to apply the split (by default) one hour from now.

Modified Paths:
--------------
    CalendarServer/trunk/twistedcaldav/stdconfig.py
    CalendarServer/trunk/txdav/caldav/datastore/sql.py
    CalendarServer/trunk/txdav/caldav/datastore/test/test_sql.py
    CalendarServer/trunk/txdav/who/groups.py
    CalendarServer/trunk/txdav/who/test/test_group_attendees.py

Added Paths:
-----------
    CalendarServer/trunk/txdav/caldav/datastore/test/accounts/groupAttendeeAccounts.xml

Modified: CalendarServer/trunk/twistedcaldav/stdconfig.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/stdconfig.py	2014-09-08 17:45:12 UTC (rev 13946)
+++ CalendarServer/trunk/twistedcaldav/stdconfig.py	2014-09-10 14:52:49 UTC (rev 13947)
@@ -974,7 +974,7 @@
     "GroupAttendees" : {
         "Enabled": True,
         "ReconciliationDelaySeconds" : 5,
-        "UpdateOldEventLimitSeconds" : 1 * 24 * 60 * 60,   # 1 day
+        "AutoUpdateSecondsFromNow" : 60 * 60,   # 1 hour
     },
 
     "AutomaticPurging": {

Modified: CalendarServer/trunk/txdav/caldav/datastore/sql.py
===================================================================
--- CalendarServer/trunk/txdav/caldav/datastore/sql.py	2014-09-08 17:45:12 UTC (rev 13946)
+++ CalendarServer/trunk/txdav/caldav/datastore/sql.py	2014-09-10 14:52:49 UTC (rev 13947)
@@ -2349,6 +2349,23 @@
 
 
     @inlineCallbacks
+    def groupEventLinks(self):
+        """
+        Return the current group event links for this resource.
+
+        @return: a L{dict} with group ids as the key and membership hash as the value
+        @rtype: L{dict}
+        """
+        ga = schema.GROUP_ATTENDEE
+        rows = yield Select(
+            [ga.GROUP_ID, ga.MEMBERSHIP_HASH],
+            From=ga,
+            Where=ga.RESOURCE_ID == self._resourceID,
+        ).on(self._txn)
+        returnValue(dict(rows))
+
+
+    @inlineCallbacks
     def updateEventGroupLink(self, groupCUAToAttendeeMemberPropMap=None):
         """
         update schema.GROUP_ATTENDEE
@@ -2360,13 +2377,7 @@
                 returnValue(False)
 
         changed = False
-        ga = schema.GROUP_ATTENDEE
-        rows = yield Select(
-            [ga.GROUP_ID, ga.MEMBERSHIP_HASH],
-            From=ga,
-            Where=ga.RESOURCE_ID == self._resourceID,
-        ).on(self._txn)
-        groupIDToMembershipHashMap = dict(rows)
+        groupIDToMembershipHashMap = (yield self.groupEventLinks())
 
         for groupCUA in groupCUAToAttendeeMemberPropMap:
             groupRecord = yield self.directoryService().recordWithCalendarUserAddress(groupCUA)
@@ -2379,6 +2390,7 @@
                 _ignore_extant
             ) = yield self._txn.groupByUID(groupUID)
 
+            ga = schema.GROUP_ATTENDEE
             if groupID in groupIDToMembershipHashMap:
                 if groupIDToMembershipHashMap[groupID] != membershipHash:
                     yield Update(
@@ -2416,38 +2428,52 @@
 
     @inlineCallbacks
     def removeOldEventGroupLink(self, component, instances, inserting, txn):
+        """
+        Check to see if the calendar event has any instances ongoing into the future (past
+        a cut-off value in the future - default 1 hour). If there are no ongoing instances,
+        then "decouple" this resource from the automatic group reconciliation process as
+        past events should only show the group membership as it existed at the time.
 
+        @param component: the iCalendar data to process
+        @type component: L{Component}
+        @param instances: list of instances
+        @type instances: L{InstanceList} or L{None}
+        @param inserting: whether or not the resource is being created
+        @type inserting: L{bool}
+        @param txn: transaction to use
+        @type txn: L{Transaction}
+        """
+
         isOldEventWithGroupAttendees = False
 
         # If this event is old, break possible tie to group update
         if hasattr(self, "_groupCUAToAttendeeMemberPropMap"):
 
-            if (component.masterComponent() is None or not component.isRecurring()):
-                cutoffDate_datatime = (
-                    datetime.datetime.utcnow() +
-                    datetime.timedelta(seconds=config.GroupAttendees.UpdateOldEventLimitSeconds)
+            # If we were not provided with any instances, then expand the component to check
+            if instances is None:
+                # Do an expansion out to the cut-off and then check to see if there
+                # are still instances beyond that
+                expand = (DateTime.getNowUTC() +
+                          Duration(seconds=config.GroupAttendees.AutoUpdateSecondsFromNow))
+
+                instances = component.expandTimeRanges(
+                    expand,
+                    lowerLimit=None,
+                    ignoreInvalidInstances=True
                 )
-                tr = schema.TIME_RANGE
-                rows = yield Select(
-                    [Count(tr.CALENDAR_OBJECT_RESOURCE_ID)],
-                    From=tr,
-                    Where=(
-                        tr.CALENDAR_OBJECT_RESOURCE_ID == self._resourceID).And(
-                        tr.END_DATE > cutoffDate_datatime
-                    ),
-                ).on(txn)
-                isOldEventWithGroupAttendees = rows[0][0] == 0
 
+                isOldEventWithGroupAttendees = instances.limit is None
+
+            elif len(instances.instances):
+                cutoffDate_DateTime = (
+                    DateTime.getNowUTC() +
+                    Duration(seconds=config.GroupAttendees.AutoUpdateSecondsFromNow)
+                )
+                maxInstanceKey = sorted(instance for instance in instances)[-1]
+                isOldEventWithGroupAttendees = instances[maxInstanceKey].start < cutoffDate_DateTime and instances.limit is None
+
             else:
-                if instances and len(instances.instances):
-                    cutoffDate_DateTime = (
-                        DateTime.getNowUTC() +
-                        Duration(seconds=config.GroupAttendees.UpdateOldEventLimitSeconds)
-                    )
-                    maxInstanceKey = sorted(instance for instance in instances)[-1]
-                    isOldEventWithGroupAttendees = cutoffDate_DateTime > instances[maxInstanceKey].end
-                else:
-                    isOldEventWithGroupAttendees = True
+                isOldEventWithGroupAttendees = instances.limit is None
 
             if isOldEventWithGroupAttendees:
                 if inserting:
@@ -2464,6 +2490,65 @@
         returnValue(isOldEventWithGroupAttendees)
 
 
+    @inlineCallbacks
+    def groupAttendeeChanged(self, groupID):
+        """
+        One or more group attendee membership lists have changed, so sync up with those
+        changes. This might involve splitting the event if there are past instances of
+        a recurring event, since we don't want the attendee list in the past instances
+        to change.
+        """
+        component = yield self.componentForUser()
+
+        # Change a copy of the original, as we need the original cached on the resource
+        # so we can do a diff to test implicit scheduling changes
+        component = component.duplicate()
+
+        # sync group attendees
+        if (yield self.reconcileGroupAttendees(component)):
+
+            # Group attendees in the event have changed
+
+            # Do not reconcile events entirely in the past
+            if (
+                yield self.removeOldEventGroupLink(
+                    component,
+                    instances=None,
+                    inserting=False,
+                    txn=self._txn
+                )
+            ):
+                returnValue(None)
+
+            # For recurring events we split the event so past instances are not reconciled
+            if component.masterComponent() is not None and component.isRecurring():
+                splitter = iCalSplitter(0, 0)
+                break_point = DateTime.getToday() + Duration(seconds=config.GroupAttendees.AutoUpdateSecondsFromNow)
+                rid = splitter.whereSplit(component, break_point=break_point)
+                if rid is not None:
+                    yield self.split(onlyThis=True, rid=rid)
+
+                    # remove group link to ensure update (update to unknown hash would work too)
+                    # FIXME: its possible that more than one group id gets updated during this single work item, so we
+                    # need to make sure that ALL the group_id's are removed by this query.
+                    ga = schema.GROUP_ATTENDEE
+                    yield Delete(
+                        From=ga,
+                        Where=(ga.RESOURCE_ID == self._resourceID).And(
+                            ga.GROUP_ID == groupID
+                        )
+                    ).on(self._txn)
+
+                    # update group attendee in remaining component
+                    component = yield self.componentForUser()
+                    component = component.duplicate()
+                    yield self.reconcileGroupAttendees(component)
+                    yield self._setComponentInternal(component, inserting=False, internal_state=ComponentUpdateState.SPLIT_OWNER)
+                    returnValue(None)
+
+            yield self.setComponent(component)
+
+
     def validCalendarDataCheck(self, component, inserting):
         """
         Check that the calendar data is valid iCalendar.

Added: CalendarServer/trunk/txdav/caldav/datastore/test/accounts/groupAttendeeAccounts.xml
===================================================================
--- CalendarServer/trunk/txdav/caldav/datastore/test/accounts/groupAttendeeAccounts.xml	                        (rev 0)
+++ CalendarServer/trunk/txdav/caldav/datastore/test/accounts/groupAttendeeAccounts.xml	2014-09-10 14:52:49 UTC (rev 13947)
@@ -0,0 +1,139 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+Copyright (c) 2006-2014 Apple Inc. All rights reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+ -->
+
+<!DOCTYPE accounts SYSTEM "accounts.dtd">
+
+<directory realm="Test Realm">
+	<record type="user">
+	    <short-name>user01</short-name>
+	    <uid>user01</uid>
+	    <guid>10000000-0000-0000-0000-000000000001</guid>
+	    <password>user01</password>
+	    <full-name>User 01</full-name>
+	    <email>user01 at example.com</email>
+	</record>
+	<record type="user">
+	    <short-name>user02</short-name>
+	    <uid>user02</uid>
+	    <guid>10000000-0000-0000-0000-000000000002</guid>
+	    <password>user02</password>
+	    <full-name>User 02</full-name>
+	    <email>user02 at example.com</email>
+	</record>
+	<record type="user">
+	    <short-name>user03</short-name>
+	    <uid>user03</uid>
+	    <guid>10000000-0000-0000-0000-000000000003</guid>
+	    <password>user03</password>
+	    <full-name>User 03</full-name>
+	    <email>user03 at example.com</email>
+	</record>
+	<record type="user">
+	    <short-name>user04</short-name>
+	    <uid>user04</uid>
+	    <guid>10000000-0000-0000-0000-000000000004</guid>
+	    <password>user04</password>
+	    <full-name>User 04</full-name>
+	    <email>user04 at example.com</email>
+	</record>
+	<record type="user">
+	    <short-name>user05</short-name>
+	    <uid>user05</uid>
+	    <guid>10000000-0000-0000-0000-000000000005</guid>
+	    <password>user05</password>
+	    <full-name>User 05</full-name>
+	    <email>user05 at example.com</email>
+	</record>
+	<record type="user">
+	    <short-name>user06</short-name>
+	    <uid>user06</uid>
+	    <guid>10000000-0000-0000-0000-000000000006</guid>
+	    <password>user06</password>
+	    <full-name>User 06</full-name>
+	    <email>user06 at example.com</email>
+	</record>
+	<record type="user">
+	    <short-name>user07</short-name>
+	    <uid>user07</uid>
+	    <guid>10000000-0000-0000-0000-000000000007</guid>
+	    <password>user07</password>
+	    <full-name>User 07</full-name>
+	    <email>user07 at example.com</email>
+	</record>
+	<record type="user">
+	    <short-name>user08</short-name>
+	    <uid>user08</uid>
+	    <guid>10000000-0000-0000-0000-000000000008</guid>
+	    <password>user08</password>
+	    <full-name>User 08</full-name>
+	    <email>user08 at example.com</email>
+	</record>
+	<record type="user">
+	    <short-name>user09</short-name>
+	    <uid>user09</uid>
+	    <guid>10000000-0000-0000-0000-000000000009</guid>
+	    <password>user09</password>
+	    <full-name>User 09</full-name>
+	    <email>user09 at example.com</email>
+	</record>
+	<record type="user">
+	    <short-name>user10</short-name>
+	    <uid>user10</uid>
+	    <guid>10000000-0000-0000-0000-000000000010</guid>
+	    <password>user10</password>
+	    <full-name>User 10</full-name>
+	    <email>user10 at example.com</email>
+	</record>
+	<record type="group">
+	    <short-name>group01</short-name>
+	    <uid>group01</uid>
+	    <guid>20000000-0000-0000-0000-000000000001</guid>
+	    <full-name>Group 01</full-name>
+	    <email>group01 at example.com</email>
+	    <member-uid>user02</member-uid>
+	</record>
+	<record type="group">
+	    <short-name>group02</short-name>
+	    <uid>group02</uid>
+	    <guid>20000000-0000-0000-0000-000000000002</guid>
+	    <full-name>Group 02</full-name>
+	    <email>group02 at example.com</email>
+	    <member-uid>user06</member-uid>
+	    <member-uid>user07</member-uid>
+	    <member-uid>user08</member-uid>
+	</record>
+	<record type="group">
+	    <short-name>group03</short-name>
+	    <uid>group03</uid>
+	    <guid>20000000-0000-0000-0000-000000000003</guid>
+	    <full-name>Group 03</full-name>
+	    <email>group03 at example.com</email>
+	    <member-uid>user07</member-uid>
+	    <member-uid>user08</member-uid>
+	    <member-uid>user09</member-uid>
+	</record>
+	<record type="group">
+	    <short-name>group04</short-name>
+	    <uid>group04</uid>
+	    <guid>20000000-0000-0000-0000-000000000004</guid>
+	    <full-name>Group 04</full-name>
+	    <member-uid>group02</member-uid>
+	    <member-uid>group03</member-uid>
+	    <member-uid>user10</member-uid>
+	</record>
+</directory>

Modified: CalendarServer/trunk/txdav/caldav/datastore/test/test_sql.py
===================================================================
--- CalendarServer/trunk/txdav/caldav/datastore/test/test_sql.py	2014-09-08 17:45:12 UTC (rev 13946)
+++ CalendarServer/trunk/txdav/caldav/datastore/test/test_sql.py	2014-09-10 14:52:49 UTC (rev 13947)
@@ -27,6 +27,7 @@
 from txweb2.http_headers import MimeType
 from txweb2.stream import MemoryStream
 
+from twisted.python.filepath import FilePath
 from twisted.internet import reactor
 from twisted.internet.defer import inlineCallbacks, returnValue, DeferredList, \
     succeed
@@ -74,6 +75,7 @@
 from twext.enterprise.jobqueue import JobItem
 
 import datetime
+import os
 
 
 class CalendarSQLStorageTests(CalendarCommonTests, unittest.TestCase):
@@ -7756,3 +7758,932 @@
         yield JobItem.waitEmpty(self._sqlCalendarStore.newTransaction, reactor, 60)
 
         self.assertEqual(self.trcount, 6)
+
+
+
+class GroupExpand(CommonCommonTests, unittest.TestCase):
+    """
+    CalendarObject group attendee expansion.
+    """
+
+    @inlineCallbacks
+    def setUp(self):
+        yield super(GroupExpand, self).setUp()
+
+        accountsFilePath = FilePath(
+            os.path.join(os.path.dirname(__file__), "accounts")
+        )
+        yield self.buildStoreAndDirectory(
+            accounts=accountsFilePath.child("groupAttendeeAccounts.xml"),
+        )
+
+        yield self.populate()
+
+        now = DateTime.getNowUTC()
+        now.setDateOnly(True)
+        past1 = now.duplicate()
+        past1.offsetDay(-1)
+        past2 = now.duplicate()
+        past2.offsetDay(-2)
+        past400 = now.duplicate()
+        past400.offsetDay(-400)
+        now1 = now.duplicate()
+        now1.offsetDay(1)
+        now2 = now.duplicate()
+        now2.offsetDay(2)
+        self.subs = {
+            "now": now,
+            "past1": past1,
+            "past2": past2,
+            "past400": past400,
+            "now1": now1,
+            "now2": now2,
+        }
+
+
+    @inlineCallbacks
+    def populate(self):
+        yield populateCalendarsFrom(self.requirements, self.storeUnderTest())
+        self.notifierFactory.reset()
+
+
+    @property
+    def requirements(self):
+        return {
+            "user01": {
+                "calendar": {},
+                "inbox": {},
+            },
+            "user02": {
+                "calendar": {},
+                "inbox": {},
+            },
+            "user03": {
+                "calendar": {},
+                "inbox": {},
+            },
+        }
+
+
+    @inlineCallbacks
+    def test_expand_insert(self):
+        """
+        Test that creating an event with a group attendee triggers expansion.
+        """
+
+        event = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+ATTENDEE:urn:x-uid:group01
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        result = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+SUMMARY:New Event
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        # PUT causes expansion
+        cal = yield self.calendarUnderTest(home="user01", name="calendar")
+        yield cal.createObjectResourceWithName("1.ics", Component.fromString(event))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        comp = yield calobj.componentForUser()
+        self.assertEqual(normalize_iCalStr(comp), normalize_iCalStr(result), msg=diff_iCalStrs(comp, result))
+
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)
+
+
+    @inlineCallbacks
+    def test_expand_update_new(self):
+        """
+        Test that updating an event with a new group attendee triggers expansion, both with
+        and without a time range change.
+        """
+        event1 = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        event2 = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+ATTENDEE:urn:x-uid:group01
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        result = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+SEQUENCE:1
+SUMMARY:New Event
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        # PUT does not cause expansion
+        cal = yield self.calendarUnderTest(home="user01", name="calendar")
+        yield cal.createObjectResourceWithName("1.ics", Component.fromString(event1))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 0)
+        yield self.commit()
+
+        # PUT causes expansion
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        yield calobj.setComponent(Component.fromString(event2))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        comp = yield calobj.componentForUser()
+        self.assertEqual(normalize_iCalStr(comp), normalize_iCalStr(result), msg=diff_iCalStrs(comp, result))
+
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)
+
+
+    @inlineCallbacks
+    def test_expand_update_existing(self):
+        """
+        Test that updating an event with an existing group attendee leaves expansion in place,
+        both with and without a time range change.
+        """
+        event1 = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+ATTENDEE:urn:x-uid:group01
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        event2 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+SEQUENCE:1
+SUMMARY:New Event #2
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        event3 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{now1}T130000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+SEQUENCE:1
+SUMMARY:New Event #2
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        result = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{now1}T130000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+SEQUENCE:2
+SUMMARY:New Event #2
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        # PUT causes expansion
+        cal = yield self.calendarUnderTest(home="user01", name="calendar")
+        yield cal.createObjectResourceWithName("1.ics", Component.fromString(event1))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)
+        yield self.commit()
+
+        # PUT expansion done - no time-range change
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        yield calobj.setComponent(Component.fromString(event2))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)
+
+        # PUT expansion done - time-range change
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        yield calobj.setComponent(Component.fromString(event3))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        comp = yield calobj.componentForUser()
+        self.assertEqual(normalize_iCalStr(comp), normalize_iCalStr(result), msg=diff_iCalStrs(comp, result))
+
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)
+
+
+    @inlineCallbacks
+    def test_expand_insert_recurring(self):
+        """
+        Test that creating an event with a group attendee triggers expansion.
+        """
+
+        event = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+ATTENDEE:urn:x-uid:group01
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        result = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+RRULE:FREQ=DAILY
+SUMMARY:New Event
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        # PUT causes expansion
+        cal = yield self.calendarUnderTest(home="user01", name="calendar")
+        yield cal.createObjectResourceWithName("1.ics", Component.fromString(event))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        comp = yield calobj.componentForUser()
+        self.assertEqual(normalize_iCalStr(comp), normalize_iCalStr(result), msg=diff_iCalStrs(comp, result))
+
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)
+
+
+    @inlineCallbacks
+    def test_expand_update_new_recurring(self):
+        """
+        Test that updating an event with a new group attendee triggers expansion, both with
+        and without a time range change.
+        """
+        event1 = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        event2 = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+ATTENDEE:urn:x-uid:group01
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        result = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+RRULE:FREQ=DAILY
+SEQUENCE:1
+SUMMARY:New Event
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        # PUT does not cause expansion
+        cal = yield self.calendarUnderTest(home="user01", name="calendar")
+        yield cal.createObjectResourceWithName("1.ics", Component.fromString(event1))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 0)
+        yield self.commit()
+
+        # PUT causes expansion
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        yield calobj.setComponent(Component.fromString(event2))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        comp = yield calobj.componentForUser()
+        self.assertEqual(normalize_iCalStr(comp), normalize_iCalStr(result), msg=diff_iCalStrs(comp, result))
+
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)
+
+
+    @inlineCallbacks
+    def test_expand_update_existing_recurring(self):
+        """
+        Test that updating an event with an existing group attendee leaves expansion in place,
+        both with and without a time range change.
+        """
+        event1 = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+ATTENDEE:urn:x-uid:group01
+RRULE:FREQ=DAILY
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        event2 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{now1}T120000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+RRULE:FREQ=DAILY
+SEQUENCE:1
+SUMMARY:New Event #2
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        event3 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{now1}T130000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+RRULE:FREQ=DAILY
+SEQUENCE:1
+SUMMARY:New Event #2
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        result = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{now1}T130000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+RRULE:FREQ=DAILY
+SEQUENCE:2
+SUMMARY:New Event #2
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        # PUT causes expansion
+        cal = yield self.calendarUnderTest(home="user01", name="calendar")
+        yield cal.createObjectResourceWithName("1.ics", Component.fromString(event1))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)
+        yield self.commit()
+
+        # PUT expansion done - no time-range change
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        yield calobj.setComponent(Component.fromString(event2))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)
+
+        # PUT expansion done - time-range change
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        yield calobj.setComponent(Component.fromString(event3))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        comp = yield calobj.componentForUser()
+        self.assertEqual(normalize_iCalStr(comp), normalize_iCalStr(result), msg=diff_iCalStrs(comp, result))
+
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)
+
+
+    @inlineCallbacks
+    def test_expand_insert_past(self):
+        """
+        Test that creating an event with a group attendee triggers expansion.
+        """
+
+        event = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{past1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+ATTENDEE:urn:x-uid:group01
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        result = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{past1}T120000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+SUMMARY:New Event
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        # PUT causes expansion
+        cal = yield self.calendarUnderTest(home="user01", name="calendar")
+        yield cal.createObjectResourceWithName("1.ics", Component.fromString(event))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        comp = yield calobj.componentForUser()
+        self.assertEqual(normalize_iCalStr(comp), normalize_iCalStr(result), msg=diff_iCalStrs(comp, result))
+
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 0)
+
+
+    @inlineCallbacks
+    def test_expand_update_new_past(self):
+        """
+        Test that updating an event with a new group attendee triggers expansion, both with
+        and without a time range change.
+        """
+        event1 = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{past1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        event2 = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{past1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+ATTENDEE:urn:x-uid:group01
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        result = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{past1}T120000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+SEQUENCE:1
+SUMMARY:New Event
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        # PUT does not cause expansion
+        cal = yield self.calendarUnderTest(home="user01", name="calendar")
+        yield cal.createObjectResourceWithName("1.ics", Component.fromString(event1))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 0)
+        yield self.commit()
+
+        # PUT causes expansion
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        yield calobj.setComponent(Component.fromString(event2))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        comp = yield calobj.componentForUser()
+        self.assertEqual(normalize_iCalStr(comp), normalize_iCalStr(result), msg=diff_iCalStrs(comp, result))
+
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 0)
+
+
+    @inlineCallbacks
+    def test_expand_update_existing_past(self):
+        """
+        Test that updating an event with an existing group attendee leaves expansion in place,
+        both with and without a time range change.
+        """
+        event1 = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{past1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+ATTENDEE:urn:x-uid:group01
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        event2 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{past1}T120000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+SEQUENCE:1
+SUMMARY:New Event #2
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        event3 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{past1}T130000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+SEQUENCE:1
+SUMMARY:New Event #2
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        result = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{past1}T130000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+SEQUENCE:2
+SUMMARY:New Event #2
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        # PUT causes expansion
+        cal = yield self.calendarUnderTest(home="user01", name="calendar")
+        yield cal.createObjectResourceWithName("1.ics", Component.fromString(event1))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 0)
+        yield self.commit()
+
+        # PUT expansion done - no time-range change
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        yield calobj.setComponent(Component.fromString(event2))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 0)
+
+        # PUT expansion done - time-range change
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        yield calobj.setComponent(Component.fromString(event3))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        comp = yield calobj.componentForUser()
+        self.assertEqual(normalize_iCalStr(comp), normalize_iCalStr(result), msg=diff_iCalStrs(comp, result))
+
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 0)
+
+
+    @inlineCallbacks
+    def test_expand_insert_recurrence_big_future_step(self):
+        """
+        Test that creating an event with a recurrence with one instance in the past and one in the distant
+        future is properly linked.
+        """
+
+        event = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{past1}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+ATTENDEE:urn:x-uid:group01
+RRULE:FREQ=YEARLY;INTERVAL=2
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        result = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{past1}T120000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+RRULE:FREQ=YEARLY;INTERVAL=2
+SUMMARY:New Event
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        # PUT causes expansion
+        cal = yield self.calendarUnderTest(home="user01", name="calendar")
+        yield cal.createObjectResourceWithName("1.ics", Component.fromString(event))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        comp = yield calobj.componentForUser()
+        self.assertEqual(normalize_iCalStr(comp), normalize_iCalStr(result), msg=diff_iCalStrs(comp, result))
+
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)
+
+
+    @inlineCallbacks
+    def test_expand_insert_recurrence_big_past_step(self):
+        """
+        Test that creating an event with a recurrence with one instance in the distant past and one in the distant
+        future is properly linked.
+        """
+
+        event = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:uid1
+DTSTART:{past400}T120000Z
+DURATION:PT1H
+SUMMARY:New Event
+DTSTAMP:20100203T013909Z
+ORGANIZER:mailto:user01 at example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:user01 at example.com
+ATTENDEE:urn:x-uid:group01
+RRULE:FREQ=YEARLY;INTERVAL=4
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        result = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN
+BEGIN:VEVENT
+UID:uid1
+DTSTAMP:20100203T013909Z
+DTSTART:{past400}T120000Z
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;PARTSTAT=ACCEPTED:urn:x-uid:user01
+ATTENDEE;CN=Group 01;CUTYPE=X-SERVER-GROUP;EMAIL=group01 at example.com;SCHEDULE-STATUS=2.7:urn:x-uid:group01
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;MEMBER="urn:x-uid:group01";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:x-uid:user02
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:x-uid:user01
+RRULE:FREQ=YEARLY;INTERVAL=4
+SUMMARY:New Event
+END:VEVENT
+END:VCALENDAR
+""".format(**self.subs)
+
+        # PUT causes expansion
+        cal = yield self.calendarUnderTest(home="user01", name="calendar")
+        yield cal.createObjectResourceWithName("1.ics", Component.fromString(event))
+        yield self.commit()
+
+        calobj = yield self.calendarObjectUnderTest(home="user01", calendar_name="calendar", name="1.ics")
+        comp = yield calobj.componentForUser()
+        self.assertEqual(normalize_iCalStr(comp), normalize_iCalStr(result), msg=diff_iCalStrs(comp, result))
+
+        links = yield calobj.groupEventLinks()
+        self.assertEqual(len(links), 1)

Modified: CalendarServer/trunk/txdav/who/groups.py
===================================================================
--- CalendarServer/trunk/txdav/who/groups.py	2014-09-08 17:45:12 UTC (rev 13946)
+++ CalendarServer/trunk/txdav/who/groups.py	2014-09-10 14:52:49 UTC (rev 13947)
@@ -19,16 +19,13 @@
 Group membership caching
 """
 
-from pycalendar.datetime import DateTime
-from pycalendar.duration import Duration
 from twext.enterprise.dal.record import fromTable
 from twext.enterprise.dal.syntax import Delete, Select, Parameter
 from twext.enterprise.jobqueue import WorkItem, RegeneratingWorkItem
 from twext.python.log import Logger
 from twisted.internet.defer import inlineCallbacks, returnValue, succeed
 from twistedcaldav.config import config
-from txdav.caldav.datastore.scheduling.icalsplitter import iCalSplitter
-from txdav.caldav.datastore.sql import CalendarStoreFeatures, ComponentUpdateState
+from txdav.caldav.datastore.sql import CalendarStoreFeatures
 from txdav.common.datastore.sql_tables import schema, _BIND_MODE_OWN
 import datetime
 import time
@@ -173,84 +170,10 @@
         ).calendarObjectWithID(
             self.transaction, self.resourceID
         )
-        component = yield calendarObject.componentForUser()
+        yield calendarObject.groupAttendeeChanged(self.groupID)
 
-        # Change a copy of the original, as we need the original cached on the resource
-        # so we can do a diff to test implicit scheduling changes
-        component = component.duplicate()
 
-        # sync group attendees
-        if (yield calendarObject.reconcileGroupAttendees(component)):
 
-            # group attendees in event have changed
-            if (component.masterComponent() is None or not component.isRecurring()):
-
-                # skip non-recurring old events, no instances
-                if (
-                    yield calendarObject.removeOldEventGroupLink(
-                        component,
-                        instances=None,
-                        inserting=False,
-                        txn=self.transaction
-                    )
-                ):
-                    returnValue(None)
-            else:
-                # skip recurring old events
-                expand = (DateTime.getToday() +
-                          Duration(days=config.FreeBusyIndexExpandAheadDays))
-
-                if config.FreeBusyIndexLowerLimitDays:
-                    truncateLowerLimit = DateTime.getToday()
-                    truncateLowerLimit.offsetDay(-config.FreeBusyIndexLowerLimitDays)
-                else:
-                    truncateLowerLimit = None
-
-                instances = component.expandTimeRanges(
-                    expand,
-                    lowerLimit=truncateLowerLimit,
-                    ignoreInvalidInstances=True
-                )
-                if (
-                    yield calendarObject.removeOldEventGroupLink(
-                        component,
-                        instances=instances,
-                        inserting=False,
-                        txn=self.transaction
-                    )
-                ):
-                    returnValue(None)
-
-                # split spanning events and only update present-future split result
-                splitter = iCalSplitter(0, 1)
-                break_point = DateTime.getToday() - Duration(seconds=config.GroupAttendees.UpdateOldEventLimitSeconds)
-                rid = splitter.whereSplit(component, break_point=break_point)
-                if rid is not None:
-                    yield calendarObject.split(onlyThis=True, rid=rid)
-
-                    # remove group link to ensure update (update to unknown hash would work too)
-                    # FIXME: its possible that more than one group id gets updated during this single work item, so we
-                    # need to make sure that ALL the group_id's are removed by this query.
-                    ga = schema.GROUP_ATTENDEE
-                    yield Delete(
-                        From=ga,
-                        Where=(ga.RESOURCE_ID == self.resourceID).And(
-                            ga.GROUP_ID == self.groupID
-                        )
-                    ).on(self.transaction)
-
-                    # update group attendee in remaining component
-                    component = yield calendarObject.componentForUser()
-                    component = component.duplicate()
-                    change = yield calendarObject.reconcileGroupAttendees(component)
-                    assert change
-                    yield calendarObject._setComponentInternal(component, False, ComponentUpdateState.SPLIT_OWNER)
-                    returnValue(None)
-
-            yield calendarObject.setComponent(component)
-
-
-
 class GroupShareeReconciliationWork(
     WorkItem, fromTable(schema.GROUP_SHAREE_RECONCILE_WORK)
 ):

Modified: CalendarServer/trunk/txdav/who/test/test_group_attendees.py
===================================================================
--- CalendarServer/trunk/txdav/who/test/test_group_attendees.py	2014-09-08 17:45:12 UTC (rev 13946)
+++ CalendarServer/trunk/txdav/who/test/test_group_attendees.py	2014-09-10 14:52:49 UTC (rev 13947)
@@ -58,7 +58,7 @@
         super(GroupAttendeeTestBase, self).configure()
         config.GroupAttendees.Enabled = True
         config.GroupAttendees.ReconciliationDelaySeconds = 0
-        config.GroupAttendees.UpdateOldEventLimitSeconds = 0
+        config.GroupAttendees.AutoUpdateSecondsFromNow = 0
 
 
     @inlineCallbacks
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20140910/7c57334f/attachment-0001.html>


More information about the calendarserver-changes mailing list