[CalendarServer-changes] [13218] CalendarServer/trunk

source_changes at macosforge.org source_changes at macosforge.org
Tue Apr 8 22:18:45 PDT 2014


Revision: 13218
          http://trac.calendarserver.org//changeset/13218
Author:   gaya at apple.com
Date:     2014-04-08 22:18:45 -0700 (Tue, 08 Apr 2014)
Log Message:
-----------
group attendees

Modified Paths:
--------------
    CalendarServer/trunk/.project
    CalendarServer/trunk/twistedcaldav/directorybackedaddressbook.py
    CalendarServer/trunk/twistedcaldav/ical.py
    CalendarServer/trunk/twistedcaldav/test/test_icalendar.py
    CalendarServer/trunk/twistedcaldav/test/test_upgrade.py
    CalendarServer/trunk/txdav/caldav/datastore/sql.py
    CalendarServer/trunk/txdav/caldav/datastore/test/util.py
    CalendarServer/trunk/txdav/caldav/datastore/util.py
    CalendarServer/trunk/txdav/who/directory.py
    CalendarServer/trunk/txdav/who/groups.py
    CalendarServer/trunk/txdav/who/test/test_groups.py

Added Paths:
-----------
    CalendarServer/trunk/txdav/who/test/accounts/groupAttendeeAccounts.xml
    CalendarServer/trunk/txdav/who/test/test_group_attendees.py

Modified: CalendarServer/trunk/.project
===================================================================
--- CalendarServer/trunk/.project	2014-04-09 05:17:43 UTC (rev 13217)
+++ CalendarServer/trunk/.project	2014-04-09 05:18:45 UTC (rev 13218)
@@ -3,6 +3,10 @@
 	<name>trunk</name>
 	<comment></comment>
 	<projects>
+		<project>caldavclientlibrary</project>
+		<project>kerberos</project>
+		<project>pycalendar</project>
+		<project>twextpy</project>
 	</projects>
 	<buildSpec>
 		<buildCommand>
@@ -16,7 +20,7 @@
 	</natures>
 	<filteredResources>
 		<filter>
-			<id>1396665409305</id>
+			<id>1396668930421</id>
 			<name></name>
 			<type>10</type>
 			<matcher>
@@ -25,7 +29,7 @@
 			</matcher>
 		</filter>
 		<filter>
-			<id>1396665409305</id>
+			<id>1396668930422</id>
 			<name></name>
 			<type>10</type>
 			<matcher>

Modified: CalendarServer/trunk/twistedcaldav/directorybackedaddressbook.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/directorybackedaddressbook.py	2014-04-09 05:17:43 UTC (rev 13217)
+++ CalendarServer/trunk/twistedcaldav/directorybackedaddressbook.py	2014-04-09 05:18:45 UTC (rev 13218)
@@ -221,10 +221,10 @@
 
                 vCardsResults = [(yield ABDirectoryQueryResult(self).generate(record)) for record in records]
 
-                filteredResults = []
+                filteredResults = set()
                 for vCardResult in vCardsResults:
                     if addressBookFilter.match(vCardResult.vCard()):
-                        filteredResults.append(vCardResult)
+                        filteredResults.add(vCardResult)
                     else:
                         log.debug("doAddressBookDirectoryQuery: vCard did not match filter:\n{vcard}", vcard=vCardResult.vCard())
 
@@ -249,7 +249,7 @@
                 if maxQueryRecords and maxRecords > maxQueryRecords:
                     maxRecords = maxQueryRecords
 
-            results = sorted(list(filteredResults), key=lambda result: result.vCard().propertyValue("UID"))
+            results = sorted(filteredResults, key=lambda result: result.vCard().propertyValue("UID"))
             limited = maxResults and len(results) >= maxResults
 
         log.info("limited={l} #results={n}", l=limited, n=len(results))

Modified: CalendarServer/trunk/twistedcaldav/ical.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/ical.py	2014-04-09 05:17:43 UTC (rev 13217)
+++ CalendarServer/trunk/twistedcaldav/ical.py	2014-04-09 05:18:45 UTC (rev 13218)
@@ -183,7 +183,7 @@
             pyobj = kwargs["pycalendar"]
 
             if not isinstance(pyobj, PyProperty):
-                raise TypeError("Not a Property: %r" % (property,))
+                raise TypeError("Not a Property: {0!r}".format(property,))
 
             self._pycalendar = pyobj
         else:
@@ -201,7 +201,10 @@
 
 
     def __repr__(self):
-        return "<%s: %r: %r>" % (self.__class__.__name__, self.name(), self.value())
+        return (
+            "<{self.__class__.__name__}: {name!r}: {value!r}>"
+            .format(self=self, name=self.name(), value=self.value())
+        )
 
 
     def __hash__(self):
@@ -358,7 +361,7 @@
 
         # get date/date-time value
         dt = self._pycalendar.getValue().getValue()
-        assert isinstance(dt, DateTime), "Not a date/date-time value: %r" % (self,)
+        assert isinstance(dt, DateTime), "Not a date/date-time value: {0!r}".format(self,)
 
         return timeRangesOverlap(dt, None, start, end, defaulttz)
 
@@ -473,13 +476,13 @@
         try:
             result = Calendar.parseData(data, format)
         except ErrorBase, e:
-            errmsg = "%s: %s" % (e.mReason, e.mData,)
+            errmsg = "{0}: {1}".format(e.mReason, e.mData,)
             result = None
         if not result:
             if isstream:
                 data.seek(0)
                 data = data.read()
-            raise InvalidICalendarDataError("%s\n%s" % (errmsg, data,))
+            raise InvalidICalendarDataError("{0}\n{1}".format(errmsg, data,))
         return clazz(None, pycalendar=result)
 
 
@@ -531,7 +534,7 @@
 
                 if pyobj is not None:
                     if not isinstance(pyobj, ComponentBase):
-                        raise TypeError("Not a ComponentBase: %r" % (pyobj,))
+                        raise TypeError("Not a ComponentBase: {0!r}".format(pyobj,))
 
                 self._pycalendar = pyobj
             else:
@@ -542,7 +545,7 @@
 
                 if parent is not None:
                     if not isinstance(parent, Component):
-                        raise TypeError("Not a Component: %r" % (parent,))
+                        raise TypeError("Not a Component: {0!r}".format(parent,))
 
                 self._parent = parent
             else:
@@ -575,7 +578,10 @@
 
 
     def __repr__(self):
-        return "<%s: %r>" % (self.__class__.__name__, str(self._pycalendar))
+        return (
+            "<{self.__class__.__name__}: {pycal!r}>"
+            .format(self=self, pycal=str(self._pycalendar))
+        )
 
 
     def __hash__(self):
@@ -600,7 +606,7 @@
         """
         Return text representation and include timezones if the option is on.
         """
-        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: {0!r}".format(self,)
 
         result = self._pycalendar.getText(includeTimezones=includeTimezones, format=format)
         if result is None:
@@ -626,14 +632,14 @@
         @return: the name of the primary type.
         @raise: L{InvalidICalendarDataError} if there is more than one primary type.
         """
-        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: {0!r}".format(self,)
 
         mtype = None
         for component in self.subcomponents():
             if component.name() in ignoredComponents:
                 continue
             elif mtype and (mtype != component.name()):
-                raise InvalidICalendarDataError("Component contains more than one type of primary type: %r" % (self,))
+                raise InvalidICalendarDataError("Component contains more than one type of primary type: {0!r}".format(self,))
             else:
                 mtype = component.name()
 
@@ -647,7 +653,7 @@
 
         @return: the L{Component} of the primary type.
         """
-        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: {0!r}".format(self,)
 
         result = None
         for component in self.subcomponents():
@@ -667,7 +673,7 @@
         @return: the L{Component} for the master component,
             or C{None} if there isn't one.
         """
-        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: {0!r}".format(self,)
 
         for component in self.subcomponents():
             if component.name() in ignoredComponents:
@@ -688,7 +694,7 @@
         @return: the L{Component} for the overridden component,
             or C{None} if there isn't one.
         """
-        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: {0!r}".format(self,)
 
         if isinstance(recurrence_id, str):
             recurrence_id = DateTime.parseText(recurrence_id) if recurrence_id else None
@@ -710,7 +716,7 @@
         Return the access level for this component.
         @return: the access level for the calendar data.
         """
-        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Must be a VCALENDAR: {0!r}".format(self,)
 
         access = self.propertyValue(Component.ACCESS_PROPERTY)
         if access:
@@ -780,7 +786,7 @@
         if len(properties) == 1:
             return properties[0]
         if len(properties) > 1:
-            raise InvalidICalendarDataError("More than one %s property in component %r" % (name, self))
+            raise InvalidICalendarDataError("More than one {0} property in component {1!r}".format(name, self))
         return None
 
 
@@ -808,7 +814,7 @@
         if len(properties) == 1:
             return properties[0].value()
         if len(properties) > 1:
-            raise InvalidICalendarDataError("More than one %s property in component %r" % (name, self))
+            raise InvalidICalendarDataError("More than one {0} property in component {1!r}".format(name, self))
         return None
 
 
@@ -926,12 +932,12 @@
             repeat : an integer for the REPEAT count
             duration: the repeat duration if present, otherwise None
         """
-        assert self.name() == "VALARM", "Component is not a VAlARM: %r" % (self,)
+        assert self.name() == "VALARM", "Component is not a VAlARM: {0!r}".format(self,)
 
         # The trigger value
         trigger = self.propertyValue("TRIGGER")
         if trigger is None:
-            raise InvalidICalendarDataError("VALARM has no TRIGGER property: %r" % (self,))
+            raise InvalidICalendarDataError("VALARM has no TRIGGER property: {0!r}".format(self,))
 
         # The related parameter
         related = self.getProperty("TRIGGER").parameterValue("RELATED")
@@ -951,7 +957,7 @@
         duration = self.propertyValue("DURATION")
 
         if repeat > 0 and duration is None:
-            raise InvalidICalendarDataError("VALARM has invalid REPEAT/DURATIOn properties: %r" % (self,))
+            raise InvalidICalendarDataError("VALARM has invalid REPEAT/DURATIOn properties: {0!r}".format(self,))
 
         return (trigger, related, repeat, duration)
 
@@ -1070,7 +1076,7 @@
         @return: a set of strings, one for each unique TZID value.
         """
 
-        assert self.name() == "VCALENDAR", "Not a calendar: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Not a calendar: {0!r}".format(self,)
 
         results = set()
         for component in self.subcomponents():
@@ -1689,7 +1695,7 @@
         """
         @return: the UID of the subcomponents in this component.
         """
-        assert self.name() == "VCALENDAR", "Not a calendar: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Not a calendar: {0!r}".format(self,)
 
         if not hasattr(self, "_resource_uid"):
             for subcomponent in self.subcomponents():
@@ -1706,7 +1712,7 @@
         """
         Generate a new UID for all components in this VCALENDAR
         """
-        assert self.name() == "VCALENDAR", "Not a calendar: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Not a calendar: {0!r}".format(self,)
 
         newUID = str(uuid.uuid4()) if newUID is None else newUID
         self._pycalendar.changeUID(self.resourceUID(), newUID)
@@ -1720,7 +1726,7 @@
         @return: the name of the iCalendar type of the subcomponents in this
             component.
         """
-        assert self.name() == "VCALENDAR", "Not a calendar: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Not a calendar: {0!r}".format(self,)
 
         if not hasattr(self, "_resource_type"):
             has_timezone = False
@@ -1738,7 +1744,7 @@
                 if has_timezone:
                     self._resource_type = "VTIMEZONE"
                 else:
-                    raise InvalidICalendarDataError("No component type found for calendar component: %r" % (self,))
+                    raise InvalidICalendarDataError("No component type found for calendar component: {0!r}".format(self,))
 
         return self._resource_type
 
@@ -1772,10 +1778,10 @@
             cannot be fixed.
         """
         if self.name() != "VCALENDAR":
-            log.debug("Not a calendar: %s" % (self,))
+            log.debug("Not a calendar: {0}".format(self,))
             raise InvalidICalendarDataError("Not a calendar")
         if not self.resourceType():
-            log.debug("Unknown resource type: %s" % (self,))
+            log.debug("Unknown resource type: {0}".format(self,))
             raise InvalidICalendarDataError("Unknown resource type")
 
         # Do underlying iCalendar library validation with data fix
@@ -1788,11 +1794,11 @@
             unfixed.extend(runfixed)
 
         if unfixed:
-            log.debug("Calendar data had unfixable problems:\n  %s" % ("\n  ".join(unfixed),))
+            log.debug("Calendar data had unfixable problems:\n  {0}".format("\n  ".join(unfixed),))
             if doRaise:
-                raise InvalidICalendarDataError("Calendar data had unfixable problems:\n  %s" % ("\n  ".join(unfixed),))
+                raise InvalidICalendarDataError("Calendar data had unfixable problems:\n  {0}".format("\n  ".join(unfixed),))
         if fixed:
-            log.debug("Calendar data had fixable problems:\n  %s" % ("\n  ".join(fixed),))
+            log.debug("Calendar data had fixable problems:\n  {0}".format("\n  ".join(fixed),))
 
         return fixed, unfixed
 
@@ -1832,9 +1838,9 @@
                             if len(property.value()) > 0:
                                 master.addProperty(property)
                             del exdates[rid]
-                            fixed.append("Removed EXDATE for valid override: %s" % (rid,))
+                            fixed.append("Removed EXDATE for valid override: {0}".format(rid,))
                         else:
-                            unfixed.append("EXDATE for valid override: %s" % (rid,))
+                            unfixed.append("EXDATE for valid override: {0}".format(rid,))
 
                 # Get the set of all valid recurrence IDs
                 valid_rids = self.validInstances(all_rids, ignoreInvalidInstances=True)
@@ -1855,9 +1861,9 @@
                             exdateValue = exdate.getValue()
                             if exdateValue < dtstart:
                                 if doFix:
-                                    fixed.append("Removed earlier EXDATE: %s" % (exdateValue,))
+                                    fixed.append("Removed earlier EXDATE: {0}".format(exdateValue,))
                                 else:
-                                    unfixed.append("EXDATE earlier than master: %s" % (exdateValue,))
+                                    unfixed.append("EXDATE earlier than master: {0}".format(exdateValue,))
                                 changed = True
                             else:
                                 newValues.append(exdateValue)
@@ -1882,10 +1888,13 @@
                 brokenRID = brokenComponent.propertyValue("RECURRENCE-ID")
                 if doFix:
                     master.addProperty(Property("RDATE", [brokenRID, ]))
-                    fixed.append("Added RDATE for invalid occurrence: %s" %
-                        (brokenRID,))
+                    fixed.append(
+                        "Added RDATE for invalid occurrence: {0}".format(
+                            brokenRID,
+                        )
+                    )
                 else:
-                    unfixed.append("Invalid occurrence: %s" % (brokenRID,))
+                    unfixed.append("Invalid occurrence: {0}".format(brokenRID,))
 
         return fixed, unfixed
 
@@ -1926,12 +1935,14 @@
                     ctype = subcomponent.name()
                 else:
                     if ctype != subcomponent.name():
-                        msg = "Calendar resources may not contain more than one type of calendar component (%s and %s found)" % (ctype, subcomponent.name())
+                        msg = "Calendar resources may not contain more than one type of calendar component ({0} and {1} found)".format(
+                            ctype, subcomponent.name()
+                        )
                         log.debug(msg)
                         raise InvalidICalendarDataError(msg)
 
                 if ctype not in allowedComponents:
-                    msg = "Component type: %s not allowed" % (ctype,)
+                    msg = "Component type: {0} not allowed".format(ctype,)
                     log.debug(msg)
                     raise InvalidICalendarDataError(msg)
 
@@ -1946,14 +1957,18 @@
                 if component_id is None:
                     component_id = uid
                 elif component_id != uid:
-                    msg = "Calendar resources may not contain components with different UIDs (%s and %s found)" % (component_id, subcomponent.propertyValue("UID"))
+                    msg = "Calendar resources may not contain components with different UIDs ({0} and {1} found)".format(
+                        component_id, subcomponent.propertyValue("UID")
+                    )
                     log.debug(msg)
                     raise InvalidICalendarDataError(msg)
 
                 # Verify that there is only one master component
                 if rid is None:
                     if got_master:
-                        msg = "Calendar resources may not contain components with the same UIDs and no Recurrence-IDs (%s and %s found)" % (component_id, subcomponent.propertyValue("UID"))
+                        msg = "Calendar resources may not contain components with the same UIDs and no Recurrence-IDs ({0} and {1} found)".format(
+                            component_id, subcomponent.propertyValue("UID")
+                        )
                         log.debug(msg)
                         raise InvalidICalendarDataError(msg)
                     else:
@@ -1976,7 +1991,7 @@
 
                 # Check for duplicate RECURRENCE-IDs
                 if rid in component_rids:
-                    msg = "Calendar resources may not contain components with the same Recurrence-IDs (%s)" % (rid,)
+                    msg = "Calendar resources may not contain components with the same Recurrence-IDs ({0})".format(rid,)
                     log.debug(msg)
                     raise InvalidICalendarDataError(msg)
                 else:
@@ -1990,7 +2005,7 @@
         if not config.EnableTimezonesByReference:
             for timezone_ref in timezone_refs:
                 if timezone_ref not in timezones:
-                    msg = "Timezone ID %s is referenced but not defined: %s" % (timezone_ref, self,)
+                    msg = "Timezone ID {0} is referenced but not defined: {1}".format(timezone_ref, self,)
                     log.debug(msg)
                     raise InvalidICalendarDataError(msg)
 
@@ -2002,7 +2017,7 @@
         for timezone in timezones:
             if timezone not in timezone_refs:
                 log.debug(
-                    "Timezone %s is not referenced by any non-timezone component" % (timezone,)
+                    "Timezone {0} is not referenced by any non-timezone component".format(timezone,)
                 )
 
         # Control character check - only HTAB, CR, LF allowed for characters in the range 0x00-0x1F
@@ -2025,7 +2040,7 @@
                 if foundOrganizer:
                     if organizer != foundOrganizer:
                         # We have different ORGANIZERs in the same iCalendar object - this is an error
-                        msg = "Only one ORGANIZER is allowed in an iCalendar object:\n%s" % (self,)
+                        msg = "Only one ORGANIZER is allowed in an iCalendar object:\n{0}".format(self,)
                         log.debug(msg)
                         raise InvalidICalendarDataError(msg)
                 else:
@@ -2322,7 +2337,7 @@
         @return: the string value of the Organizer property, or None
         """
 
-        assert self.name() == "VCALENDAR", "Not a calendar: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Not a calendar: {0!r}".format(self,)
 
         # Extract appropriate sub-component if this is a VCALENDAR
         results = []
@@ -2630,7 +2645,7 @@
         @type properties: C{tuple} or C{list}
         """
 
-        assert from_calendar.name() == "VCALENDAR", "Not a calendar: %r" % (self,)
+        assert from_calendar.name() == "VCALENDAR", "Not a calendar: {0!r}".format(self,)
 
         if self.name() == "VCALENDAR":
             for component in self.subcomponents():
@@ -2658,7 +2673,7 @@
         on the master to account for changes.
         """
 
-        assert self.name() == "VCALENDAR", "Not a calendar: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Not a calendar: {0!r}".format(self,)
 
         # Modify any components that reference the attendee, make note of the ones that don't
         remove_components = []
@@ -2704,7 +2719,7 @@
         if not rids or None in rids:
             return True
 
-        assert self.name() == "VCALENDAR", "Not a calendar: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Not a calendar: {0!r}".format(self,)
 
         # Remove components not in the list
         components = tuple(self.subcomponents())
@@ -2726,7 +2741,7 @@
         Remove all ATTENDEE properties except for the one specified.
         """
 
-        assert self.name() == "VCALENDAR", "Not a calendar: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Not a calendar: {0!r}".format(self,)
 
         for component in self.subcomponents():
             if component.name() in ignoredComponents:
@@ -2739,7 +2754,7 @@
         Remove all ATTENDEE properties except for the ones specified.
         """
 
-        assert self.name() == "VCALENDAR", "Not a calendar: %r" % (self,)
+        assert self.name() == "VCALENDAR", "Not a calendar: {0!r}".format(self,)
 
         attendees = set([attendee.lower() for attendee in attendees])
 
@@ -2753,7 +2768,7 @@
         """
         Test whether the component has a VALARM as an immediate sub-component.
         """
-        assert self.name().upper() in ("VEVENT", "VTODO",), "Not a VEVENT or VTODO: %r" % (self,)
+        assert self.name().upper() in ("VEVENT", "VTODO",), "Not a VEVENT or VTODO: {0!r}".format(self,)
 
         for component in self.subcomponents():
             if component.name().upper() == "VALARM":
@@ -2785,9 +2800,9 @@
 DURATION:PT1H
 DTSTAMP:20110427T000000Z
 SUMMARY:bogus
-%sEND:VEVENT
+{0}END:VEVENT
 END:VCALENDAR
-""".replace("\n", "\r\n") % (alarm,)
+""".replace("\n", "\r\n").format(alarm,)
 
         try:
             calendar = Component.fromString(caldata)
@@ -3262,20 +3277,18 @@
                 # Check that we can lookup this calendar user address - if not
                 # we cannot do anything with it
                 cuaddr = normalizeCUAddr(prop.value())
-                name, guid, cuaddrs = yield lookupFunction(cuaddr, recordFunction, config)
+                name, guid, cutype, cuaddrs = yield lookupFunction(cuaddr, recordFunction, config)
                 if guid is None:
                     continue
 
                 # Get any EMAIL parameter
                 oldemail = prop.parameterValue("EMAIL")
                 if oldemail:
-                    oldemail = "mailto:%s" % (oldemail,)
+                    oldemail = "mailto:{0}".format(oldemail,)
 
                 # Get any CN parameter
                 oldCN = prop.parameterValue("CN")
 
-                cutype = prop.parameterValue("CUTYPE")
-
                 if toUUID:
                     # Always re-write value to urn:uuid
                     if isinstance(guid, uuid.UUID):
@@ -3356,11 +3369,58 @@
                     else:
                         prop.removeParameter("EMAIL")
 
+                if cutype == "INDIVIDUAL":
+                    cutype = None
+
+                if cutype != prop.parameterValue("CUTYPE"):
+                    if cutype:
+                        prop.setParameter("CUTYPE", cutype)
+                    else:
+                        prop.removeParameter("CUTYPE")
+
             # For VPOLL also do immediate children
             if component.name() == "VPOLL":
                 yield component.normalizeCalendarUserAddresses(lookupFunction, recordFunction, toUUID)
 
 
+    @inlineCallbacks
+    def expandGroupAttendee(self, groupGUID, memberGUIDs, recordFunction):
+
+        memberUUIDs = set(["urn:uuid:" + str(memberGUID) for memberGUID in memberGUIDs])
+        groupUUID = "urn:uuid:" + str(groupGUID)
+        changed = False
+        for component in self.subcomponents():
+            if component.name() in ignoredComponents:
+                continue
+
+            oldAttendeeProps = tuple(component.properties("ATTENDEE"))
+            oldAttendeeUUIDs = set([attendeeProp.value() for attendeeProp in oldAttendeeProps])
+
+            # add new member attendees
+            for memberUUID in sorted(memberUUIDs - oldAttendeeUUIDs):
+                directoryRecord = yield recordFunction(memberUUID)
+                newAttendeeProp = directoryRecord.attendee(params={"MEMBER": groupUUID})
+                component.addProperty(newAttendeeProp)
+                changed = True
+
+            # remove attendee or update MEMBER attribute for non-primary attendees in this group,
+            for attendeeProp in oldAttendeeProps:
+                if attendeeProp.hasParameter("MEMBER"):
+                    parameterValues = tuple(attendeeProp.parameterValues("MEMBER"))
+                    if groupUUID in parameterValues:
+                        if attendeeProp.value() not in memberUUIDs:
+                            attendeeProp.removeParameterValue("MEMBER", groupUUID)
+                            if not attendeeProp.parameterValues("MEMBER"):
+                                component.removeProperty(attendeeProp)
+                            changed = True
+                    else:
+                        if attendeeProp.value() in memberUUIDs:
+                            attendeeProp.setParameter("MEMBER", parameterValues + (groupUUID,))
+                            changed = True
+
+        returnValue(changed)
+
+
     def allPerUserUIDs(self):
 
         results = set()
@@ -3484,7 +3544,7 @@
             tzcomp = comp
             break
     else:
-        raise InvalidICalendarDataError("No VTIMEZONE component in %s" % (tzdata,))
+        raise InvalidICalendarDataError("No VTIMEZONE component in {0}".format(tzdata,))
 
     tzexpanded = tzcomp._pycalendar.expandAll(start, end)
 
@@ -3529,7 +3589,7 @@
             tzcomp = comp
             break
     else:
-        raise InvalidICalendarDataError("No VTIMEZONE component in %s" % (tzdata,))
+        raise InvalidICalendarDataError("No VTIMEZONE component in {0}".format(tzdata,))
 
     tzexpanded = tzcomp._pycalendar.expandAll(start, end, with_name=True)
 
@@ -3566,18 +3626,18 @@
 
 # #
 # Utilities
-# #
+# #p
 
 @inlineCallbacks
 def normalizeCUAddress(cuaddr, lookupFunction, recordFunction, toUUID=True):
     # Check that we can lookup this calendar user address - if not
     # we cannot do anything with it
-    _ignore_name, guid, cuaddrs = (yield lookupFunction(normalizeCUAddr(cuaddr), recordFunction, config))
+    _ignore_name, guid, _ignore_cuType, cuaddrs = (yield lookupFunction(normalizeCUAddr(cuaddr), recordFunction, config))
 
     if toUUID:
         # Always re-write value to urn:uuid
         if guid:
-            returnValue("urn:uuid:%s" % (guid,))
+            returnValue("urn:uuid:{0}".format(guid,))
 
     # If it is already a non-UUID address leave it be
     elif cuaddr.startswith("urn:uuid:"):

Modified: CalendarServer/trunk/twistedcaldav/test/test_icalendar.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/test/test_icalendar.py	2014-04-09 05:17:43 UTC (rev 13217)
+++ CalendarServer/trunk/twistedcaldav/test/test_icalendar.py	2014-04-09 05:18:45 UTC (rev 13218)
@@ -7529,17 +7529,20 @@
                     "urn:uuid:foo" : (
                         "Foo",
                         "foo",
+                        "INDIVIDUAL",
                         ("urn:uuid:foo", "http://example.com/foo", "/foo")
                     ),
                     "urn:uuid:bar" : (
                         "Bar",
                         "bar",
+                        "INDIVIDUAL",
                         ("urn:uuid:bar", "mailto:bar at example.com", "http://example.com/bar", "/bar")
                     ),
                     "urn:uuid:baz" : (
                         "Baz",
                         "baz",
-                        ("urn:uuid:baz", "http://example.com/baz")
+                        "INDIVIDUAL",
+                       ("urn:uuid:baz", "http://example.com/baz")
                     ),
                 }[cuaddr]
             )
@@ -7585,16 +7588,19 @@
                     "/principals/users/foo" : (
                         "Foo",
                         "foo",
+                        "INDIVIDUAL",
                         ("urn:uuid:foo",)
                     ),
                     "http://example.com/principals/users/bar" : (
                         "Bar",
                         "bar",
+                        "INDIVIDUAL",
                         ("urn:uuid:bar",)
                     ),
                     "http://example.com/principals/locations/buzz" : (
                         "{Restricted} Buzz",
                         "buzz",
+                        "INDIVIDUAL",
                         ("urn:uuid:buzz",)
                     ),
                 }[cuaddr]
@@ -7641,16 +7647,19 @@
                     "/principals/users/foo" : (
                         "Foo",
                         "foo",
+                        "INDIVIDUAL",
                         ("urn:uuid:foo",)
                     ),
                     "http://example.com/principals/users/bar" : (
                         "Bar",
                         "bar",
+                        "INDIVIDUAL",
                         ("urn:uuid:bar",)
                     ),
                     "http://example.com/principals/locations/buzz" : (
                         "{Restricted} Buzz",
                         "buzz",
+                        "INDIVIDUAL",
                         ("urn:uuid:buzz",)
                     ),
                 }[cuaddr]
@@ -7697,16 +7706,19 @@
                     "/principals/users/foo" : (
                         "Foo",
                         "foo",
+                        "INDIVIDUAL",
                         ("urn:uuid:foo",)
                     ),
                     "http://example.com/principals/users/bar" : (
                         "Bar",
                         "bar",
+                        "INDIVIDUAL",
                         ("urn:uuid:bar",)
                     ),
                     "http://example.com/principals/locations/buzz" : (
                         "{Restricted} Buzz",
                         "buzz",
+                        "INDIVIDUAL",
                         ("urn:uuid:buzz",)
                     ),
                 }[cuaddr]
@@ -8439,21 +8451,25 @@
                     "urn:uuid:foo" : (
                         "Foo",
                         "foo",
+                        "INDIVIDUAL",
                         ("urn:uuid:foo", "http://example.com/foo", "/foo")
                     ),
                     "urn:uuid:bar" : (
                         "Bar",
                         "bar",
+                        "INDIVIDUAL",
                         ("urn:uuid:bar", "mailto:bar at example.com", "http://example.com/bar", "/bar")
                     ),
                     "urn:uuid:baz" : (
                         "Baz",
                         "baz",
+                        "INDIVIDUAL",
                         ("urn:uuid:baz", "http://example.com/baz")
                     ),
                     "urn:uuid:buz" : (
                         "Buz",
                         "buz",
+                        "INDIVIDUAL",
                         ("urn:uuid:buz",)
                     ),
                 }[cuaddr]
@@ -8483,11 +8499,13 @@
                     "/principals/users/foo" : (
                         "Foo",
                         "foo",
+                        "INDIVIDUAL",
                         ("urn:uuid:foo",)
                     ),
                     "http://example.com/principals/users/buz" : (
                         "Buz",
                         "buz",
+                        "INDIVIDUAL",
                         ("urn:uuid:buz",)
                     ),
                 }[cuaddr]

Modified: CalendarServer/trunk/twistedcaldav/test/test_upgrade.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/test/test_upgrade.py	2014-04-09 05:17:43 UTC (rev 13217)
+++ CalendarServer/trunk/twistedcaldav/test/test_upgrade.py	2014-04-09 05:18:45 UTC (rev 13218)
@@ -1516,6 +1516,9 @@
                 self.uid = uid
                 self.calendarUserAddresses = cuas
 
+            def getCUType(self):
+                return "INDIVIDUAL"
+
             @property
             def displayName(self):
                 return self.fullNames[0]
@@ -1592,7 +1595,6 @@
 
 
 
-
 normalizeEvent = """BEGIN:VCALENDAR
 VERSION:2.0
 BEGIN:VEVENT

Modified: CalendarServer/trunk/txdav/caldav/datastore/sql.py
===================================================================
--- CalendarServer/trunk/txdav/caldav/datastore/sql.py	2014-04-09 05:17:43 UTC (rev 13217)
+++ CalendarServer/trunk/txdav/caldav/datastore/sql.py	2014-04-09 05:18:45 UTC (rev 13218)
@@ -91,7 +91,7 @@
     ObjectResourceNameNotAllowedError, TooManyObjectResourcesError, \
     InvalidUIDError, UIDExistsError, UIDExistsElsewhereError, \
     InvalidResourceMove, InvalidComponentForStoreError, \
-    NoSuchObjectResourceError
+    NoSuchObjectResourceError, AllRetriesFailed
 from txdav.xml import element
 
 from txdav.idav import ChangeCategory
@@ -189,9 +189,9 @@
             ).on(txn))
             total = len(rows)
             count = 0
-            log.warn("%d dropbox ids to migrate" % (total,))
+            log.warn("{0} dropbox ids to migrate".format(total,))
         except RuntimeError, e:
-            log.error("Dropbox migration failed when cleaning out dropbox ids: %s" % (e,))
+            log.error("Dropbox migration failed when cleaning out dropbox ids: {0}".format(e,))
             yield txn.abort()
             raise
         else:
@@ -200,7 +200,7 @@
         # For each remaining attachment
         rows = -1
         while rows:
-            txn = self._store.newTransaction("CalendarStoreFeatures.upgradeToManagedAttachments - attachment loop count: %d" % (count,))
+            txn = self._store.newTransaction("CalendarStoreFeatures.upgradeToManagedAttachments - attachment loop count: {0}".format(count,))
             try:
                 dropbox_id = "Batched select"
                 rows = (yield Select(
@@ -214,9 +214,9 @@
                     for dropbox_id in rows:
                         (yield self._upgradeDropbox(txn, dropbox_id))
                     count += len(rows)
-                    log.warn("%d of %d dropbox ids migrated" % (count, total,))
+                    log.warn("{0} of {1} dropbox ids migrated".format(count, total,))
             except RuntimeError, e:
-                log.error("Dropbox migration failed for '%s': %s" % (dropbox_id, e,))
+                log.error("Dropbox migration failed for '{0}': {1}".format(dropbox_id, e,))
                 yield txn.abort()
                 raise
             else:
@@ -236,11 +236,11 @@
         @type dropbox_id: C{str}
         """
 
-        log.debug("Processing dropbox id: %s" % (dropbox_id,))
+        log.debug("Processing dropbox id: {0}".format(dropbox_id,))
 
         # Get all affected calendar objects
         cobjs = (yield self._loadCalendarObjectsForDropboxID(txn, dropbox_id))
-        log.debug("  %d affected calendar objects" % (len(cobjs),))
+        log.debug("  {0} affected calendar objects".format(len(cobjs),))
 
         # Get names of each matching attachment
         at = schema.ATTACHMENT
@@ -249,18 +249,18 @@
             From=at,
             Where=at.DROPBOX_ID == dropbox_id,
         ).on(txn))
-        log.debug("  %d associated attachment objects" % (len(names),))
+        log.debug("  {0} associated attachment objects".format(len(names),))
 
         # For each attachment, update each calendar object
         for name in names:
             name = name[0]
-            log.debug("  processing attachment object: %s" % (name,))
+            log.debug("  processing attachment object: {0}".format(name,))
             attachment = (yield DropBoxAttachment.load(txn, dropbox_id, name))
 
             # Check for orphans
             if len(cobjs) == 0:
                 # Just remove the attachment
-                log.warn("Orphaned dropbox id removed: %s" % (attachment._path,))
+                log.warn("Orphaned dropbox id removed: {0}".format(attachment._path,))
                 yield attachment.remove()
                 continue
 
@@ -271,35 +271,35 @@
                 if cobj._parentCollection.ownerHome()._resourceID == attachment._ownerHomeID:
                     owners.append(cobj)
                 cobj_by_UID[cobj.uid()].append(cobj)
-            log.debug("    %d owner calendar objects" % (len(owners),))
-            log.debug("    %d UIDs" % (len(cobj_by_UID),))
-            log.debug("    %d total calendar objects" % (sum([len(items) for items in cobj_by_UID.values()]),))
+            log.debug("    {0} owner calendar objects".format(len(owners),))
+            log.debug("    {0} UIDs".format(len(cobj_by_UID),))
+            log.debug("    {0} total calendar objects".format(sum([len(items) for items in cobj_by_UID.values()]),))
 
             if owners:
                 # Create the managed attachment without references to calendar objects.
                 managed = (yield attachment.convertToManaged())
-                log.debug("    converted attachment: %r" % (attachment,))
+                log.debug("    converted attachment: {0!r}".format(attachment,))
 
                 # Do conversion for each owner object
                 for owner_obj in owners:
 
                     # Add a reference to the managed attachment
                     mattachment = (yield managed.newReference(owner_obj._resourceID))
-                    log.debug("    added reference for: %r" % (owner_obj,))
+                    log.debug("    added reference for: {0!r}".format(owner_obj,))
 
                     # Rewrite calendar data
                     for cobj in cobj_by_UID[owner_obj.uid()]:
                         (yield cobj.convertAttachments(attachment, mattachment))
-                        log.debug("    re-wrote calendar object: %r" % (cobj,))
+                        log.debug("    re-wrote calendar object: {0!r}".format(cobj,))
             else:
                 # TODO: look for cobjs that were not changed and remove their ATTACH properties.
                 # These could happen if the owner object no longer exists.
                 # For now just remove the attachment
-                log.warn("Unowned dropbox id removed: %s" % (attachment._path,))
+                log.warn("Unowned dropbox id removed: {0}".format(attachment._path,))
                 yield attachment.remove()
                 continue
 
-        log.debug("  finished dropbox id: %s" % (dropbox_id,))
+        log.debug("  finished dropbox id: {0}".format(dropbox_id,))
 
 
     @inlineCallbacks
@@ -679,7 +679,7 @@
         """
 
         # Make sure the loop does not operate on any new calendars created during the loop
-        self.log.warn("Splitting calendars for user %s" % (self._ownerUID,))
+        self.log.warn("Splitting calendars for user {0}".format(self._ownerUID,))
         calendars = yield self.calendars()
         for calendar in calendars:
 
@@ -687,7 +687,7 @@
             if calendar.isInbox():
                 continue
             split_count = yield calendar.splitCollectionByComponentTypes()
-            self.log.warn("  Calendar: '%s', split into %d" % (calendar.name(), split_count + 1,))
+            self.log.warn("  Calendar: '{0}', split into {1}".format(calendar.name(), split_count + 1,))
 
         yield self.ensureDefaultCalendarsExist()
 
@@ -1569,7 +1569,7 @@
 
         # Actually expand recurrence max
         for name in names:
-            self.log.info("Search falls outside range of index for %s %s to %s" % (name, minDate, maxDate))
+            self.log.info("Search falls outside range of index for {0} {1} to {2}".format(name, minDate, maxDate))
             yield self.reExpandResource(name, minDate, maxDate)
 
 
@@ -1642,12 +1642,12 @@
 
         # Create the new calendar
         try:
-            newcalendar = yield self._home.createCalendarWithName("%s-%s" % (self._name, component.lower(),))
+            newcalendar = yield self._home.createCalendarWithName("{0}-{1}".format(self._name, component.lower(),))
         except HomeChildNameAlreadyExistsError:
             # If the name we want exists, try repeating with up to ten more
             for ctr in range(10):
                 try:
-                    newcalendar = yield self._home.createCalendarWithName("%s-%s-%d" % (self._name, component.lower(), ctr + 1,))
+                    newcalendar = yield self._home.createCalendarWithName("{0}-{1}-[2}".format(self._name, component.lower(), ctr + 1,))
                 except HomeChildNameAlreadyExistsError:
                     continue
             else:
@@ -1694,7 +1694,7 @@
         for row in rows:
             columnMap = dict(zip(columns, row))
             columnMap[cb.CALENDAR_RESOURCE_ID] = newcalendar._resourceID
-            columnMap[cb.CALENDAR_RESOURCE_NAME] = "%s-%s" % (columnMap[cb.CALENDAR_RESOURCE_NAME], component.lower(),)
+            columnMap[cb.CALENDAR_RESOURCE_NAME] = "{0}-{1}".format(columnMap[cb.CALENDAR_RESOURCE_NAME], component.lower(),)
             yield Insert(columnMap).on(self._txn)
 
 
@@ -1908,27 +1908,73 @@
 
             # Valid calendar component for check
             if not self.calendar().isSupportedComponent(component.mainType()):
-                raise InvalidComponentTypeError("Invalid component type %s for calendar: %s" % (component.mainType(), self.calendar(),))
+                raise InvalidComponentTypeError("Invalid component type {0} for calendar: {1}".format(component.mainType(), self.calendar(),))
 
-            # Valid attendee list size check
-            yield self.validAttendeeListSizeCheck(component, inserting)
-
             # Normalize the calendar user addresses once we know we have valid
             # calendar data
             yield component.normalizeCalendarUserAddresses(normalizationLookup, self.directoryService().recordWithCalendarUserAddress)
 
+            # Expand groups
+            yield self.expandGroupAttendees(component)
+
+            # Valid attendee list size check
+            yield self.validAttendeeListSizeCheck(component, inserting)
+
         # Possible timezone stripping
         if config.EnableTimezonesByReference:
             component.stripKnownTimezones()
 
         # Check location/resource organizer requirement
-        yield self.validLocationResourceOrganizer(component, inserting, internal_state)
+        self.validLocationResourceOrganizer(component, inserting, internal_state)
 
         # Check access
         if config.EnablePrivateEvents:
             self.validAccess(component, inserting, internal_state)
 
 
+    @inlineCallbacks
+    def expandGroupAttendees(self, component):
+        """
+        Expand group attendees
+        """
+
+        if not config.Scheduling.Options.AllowGroupAsAttendee:
+            return
+
+        attendeeProps = component.getAllAttendeeProperties()
+        groupGUIDs = set([
+            uuid.UUID(attendeeProp.value()[len("urn:uuid:"):]) for attendeeProp in attendeeProps
+            if attendeeProp.parameterValue("CUTYPE") == "GROUP"
+        ])
+
+        for groupGUID in groupGUIDs:
+
+            groupRecord = yield self.directoryService().recordWithGUID(groupGUID)
+            if groupRecord:
+                members = yield groupRecord.expandedMembers()
+                memberGUIDs = sorted([member.guid for member in members])
+
+                membershipHashContent = hashlib.md5()
+                for memberGUID in memberGUIDs:
+                    membershipHashContent.update(str(memberGUID))
+                membershipHash = membershipHashContent.hexdigest()
+
+                # associate group ID with self
+                groupID, _ignore_name, membershipHash, _ignore_modDate = yield self._txn.groupByUID(str(groupGUID))
+                try:
+                    groupAttendee = schema.GROUP_ATTENDEE
+                    yield Insert({
+                        groupAttendee.RESOURCE_ID: self._resourceID,
+                        groupAttendee.GROUP_ID: groupID,
+                        groupAttendee.MEMBERSHIP_HASH: membershipHash,
+                    })
+                except AllRetriesFailed:
+                    pass
+
+                # get members
+                yield component.expandGroupAttendee(groupGUID, memberGUIDs, self.directoryService().recordWithCalendarUserAddress)
+
+
     def validCalendarDataCheck(self, component, inserting):
         """
         Check that the calendar data is valid iCalendar.
@@ -1938,7 +1984,7 @@
 
         # Valid calendar data checks
         if not isinstance(component, VComponent):
-            raise InvalidObjectResourceError("Wrong type of object: %s" % (type(component),))
+            raise InvalidObjectResourceError("Wrong type of object: {0}".format(type(component),))
 
         try:
             component.validCalendarData(validateRecurrences=self._txn._migrating)
@@ -1983,7 +2029,11 @@
                     oldAttendeeListLength = 0
 
                 if attendeeListLength > oldAttendeeListLength:
-                    raise TooManyAttendeesError("Attendee list size %d is larger than allowed limit %d" % (attendeeListLength, config.MaxAttendeesPerInstance))
+                    raise TooManyAttendeesError(
+                        "Attendee list size {0} is larger than allowed limit {1}".format(
+                            attendeeListLength, config.MaxAttendeesPerInstance
+                        )
+                )
 
 
     @inlineCallbacks
@@ -2001,7 +2051,7 @@
             if organizer is None and (
                 cutype == "ROOM" and not config.Scheduling.Options.AllowLocationWithoutOrganizer or
                 cutype == "RESOURCE" and not config.Scheduling.Options.AllowResourceWithoutOrganizer):
-                raise ValidOrganizerError("Organizer required in calendar data for a %s" % (cutype.lower(),))
+                raise ValidOrganizerError("Organizer required in calendar data for a {0}".format(cutype.lower(),))
 
             # Check for tracking the modifier
             if organizer is None and (
@@ -2294,11 +2344,11 @@
                                         "X-TITLE": title,
                                     }
                                     structured = Property("X-APPLE-STRUCTURED-LOCATION",
-                                        "geo:%s" % (geo.encode("utf-8"),), params=params,
+                                        "geo:{0}".format(geo.encode("utf-8"),), params=params,
                                         valuetype=Value.VALUETYPE_URI)
                                     sub.replaceProperty(structured)
                                     newLocProperty = Property("LOCATION",
-                                        "%s\n%s" % (title, street.encode("utf-8")))
+                                        "{0}\n{1}".format(title, street.encode("utf-8")))
                                     sub.replaceProperty(newLocProperty)
 
 
@@ -2435,7 +2485,7 @@
 
         new_uid = component.resourceUID()
         if internal_state == ComponentUpdateState.NORMAL:
-            yield NamedLock.acquire(self._txn, "ImplicitUIDLock:%s" % (hashlib.md5(new_uid).hexdigest(),))
+            yield NamedLock.acquire(self._txn, "ImplicitUIDLock:{0}".format(hashlib.md5(new_uid).hexdigest(),))
 
         # UID conflict check - note we do this after reserving the UID to avoid a race condition where two requests
         # try to write the same calendar data to two different resource URIs.
@@ -2452,7 +2502,7 @@
                     if elsewhere.calendar().id() == self.calendar().id():
                         raise UIDExistsError("UID already exists in same calendar.")
                     else:
-                        raise UIDExistsElsewhereError("UID already exists in different calendar: %s." % (elsewhere.calendar().name(),))
+                        raise UIDExistsElsewhereError("UID already exists in different calendar: {0}".format(elsewhere.calendar().name(),))
 
 
     @inlineCallbacks
@@ -2555,10 +2605,10 @@
                         yield self._removeInternal(internal_state=ComponentRemoveState.INTERNAL)
                         raise ResourceDeletedError("Resource modified but immediately deleted by the server.")
                     else:
-                        raise AttendeeAllowedError("Attendee cannot create event for Organizer: %s" % (implicit_result,))
+                        raise AttendeeAllowedError("Attendee cannot create event for Organizer: {0}".format(implicit_result,))
 
                 else:
-                    msg = "Invalid return status code from ImplicitScheduler: %s" % (implicit_result,)
+                    msg = "Invalid return status code from ImplicitScheduler: {0}".format(implicit_result,)
                     log.error(msg)
                     raise InvalidObjectResourceError(msg)
             else:
@@ -2700,8 +2750,9 @@
                 recurrenceLimit = instances.limit
                 recurrenceLowerLimit = instances.lowerLimit
             except InvalidOverriddenInstanceError, e:
-                self.log.error("Invalid instance %s when indexing %s in %s" %
-                               (e.rid, self._name, self._calendar,))
+                self.log.error("Invalid instance {0} when indexing {1} in {2}".format(
+                    e.rid, self._name, self._calendar,)
+                )
 
                 if txn._migrating:
                     # TODO: fix the data here by re-writing component then re-index
@@ -2924,20 +2975,27 @@
             except InvalidICalendarDataError, e:
                 # This is a really bad situation, so do raise
                 raise InternalDataStoreError(
-                    "Data corruption detected (%s) in id: %s"
-                    % (e, self._resourceID)
+                    "Data corruption detected ({0}) in id: {1}".format(
+                        e, self._resourceID
+                    )
                 )
 
             # Fix any bogus data we can
             fixed, unfixed = component.validCalendarData(doFix=True, doRaise=False)
 
             if unfixed:
-                self.log.error("Calendar data id=%s had unfixable problems:\n  %s" %
-                               (self._resourceID, "\n  ".join(unfixed),))
+                self.log.error(
+                    "Calendar data id={0} had unfixable problems:\n  {1}".format(
+                        self._resourceID, "\n  ".join(unfixed),
+                        )
+                    )
 
             if fixed:
-                self.log.error("Calendar data id=%s had fixable problems:\n  %s" %
-                               (self._resourceID, "\n  ".join(fixed),))
+                self.log.error(
+                    "Calendar data id={0} had fixable problems:\n  {1}".format(
+                        self._resourceID, "\n  ".join(fixed),
+                    )
+                )
 
             self._cachedComponent = component
             self._cachedCommponentPerUser = {}
@@ -3028,7 +3086,7 @@
                 internal_request=(internal_state != ComponentUpdateState.NORMAL),
             ))
             if do_implicit_action:
-                yield NamedLock.acquire(self._txn, "ImplicitUIDLock:%s" % (hashlib.md5(calendar.resourceUID()).hexdigest(),))
+                yield NamedLock.acquire(self._txn, "ImplicitUIDLock:{0}".format(hashlib.md5(calendar.resourceUID()).hexdigest(),))
 
         # Need to also remove attachments
         if internal_state != ComponentRemoveState.INTERNAL:
@@ -3386,7 +3444,7 @@
             t = attachment.store(content_type, filename)
             yield readStream(stream, t.write)
         except Exception, e:
-            self.log.error("Unable to store attachment: %s" % (e,))
+            self.log.error("Unable to store attachment: {0}".format(e,))
             raise AttachmentStoreFailed
         yield t.loseConnection()
 
@@ -3444,7 +3502,7 @@
             # Check that this is a proper update
             oldattachment = (yield self.attachmentWithManagedID(managed_id))
             if oldattachment is None:
-                self.log.error("Missing managed attachment even though ATTACHMENT_CALENDAR_OBJECT indicates it is present: %s" % (managed_id,))
+                self.log.error("Missing managed attachment even though ATTACHMENT_CALENDAR_OBJECT indicates it is present: {0}".format(managed_id,))
                 raise AttachmentStoreFailed
 
             # We actually create a brand new attachment object for the update, but with the same managed-id. That way, other resources
@@ -3453,7 +3511,7 @@
             t = attachment.store(content_type, filename)
             yield readStream(stream, t.write)
         except Exception, e:
-            self.log.error("Unable to store attachment: %s" % (e,))
+            self.log.error("Unable to store attachment: {0}".format(e,))
             raise AttachmentStoreFailed
         yield t.loseConnection()
 
@@ -3535,7 +3593,7 @@
             attachments = component.properties("ATTACH")
             removed = False
             for attachment in tuple(attachments):
-                if attachment.value().endswith("/dropbox/%s/%s" % (
+                if attachment.value().endswith("/dropbox/{0}/{1}".format(
                     urllib.quote(oldattachment.dropboxID()),
                     urllib.quote(oldattachment.name()),
                 )):
@@ -3800,7 +3858,7 @@
         """
 
         # First job is to grab a UID lock on this entire series of events
-        yield NamedLock.acquire(self._txn, "ImplicitUIDLock:%s" % (hashlib.md5(self._uid).hexdigest(),))
+        yield NamedLock.acquire(self._txn, "ImplicitUIDLock:{0}".format(hashlib.md5(self._uid).hexdigest(),))
 
         # Find all other calendar objects on this server with the same UID
         if onlyThis:
@@ -3832,7 +3890,12 @@
 
         # Store changed data
         yield self._setComponentInternal(calendar_new, internal_state=ComponentUpdateState.SPLIT_OWNER, split_details=(rid, olderUID, True,))
-        olderObject = yield self.calendar()._createCalendarObjectWithNameInternal("%s.ics" % (olderUID,), calendar_old, ComponentUpdateState.SPLIT_OWNER, split_details=(rid, newerUID, False,))
+        olderObject = yield self.calendar()._createCalendarObjectWithNameInternal(
+            "{0}.ics".format(olderUID,),
+            calendar_old,
+            ComponentUpdateState.SPLIT_OWNER,
+            split_details=(rid, newerUID, False,)
+        )
 
         # Split each one - but not this resource
         for resource in resources:
@@ -3863,12 +3926,12 @@
 
         # Create a new resource and store its data (but not if the parent is "inbox", or if it is empty)
         if not self.calendar().isInbox() and ical_old.mainType() is not None:
-            yield self.calendar()._createCalendarObjectWithNameInternal("%s.ics" % (olderUID,), ical_old, ComponentUpdateState.SPLIT_ATTENDEE)
+            yield self.calendar()._createCalendarObjectWithNameInternal("{0}.ics".format(olderUID,), ical_old, ComponentUpdateState.SPLIT_ATTENDEE)
 
 
     class CalendarObjectSplitterWork(WorkItem, fromTable(schema.CALENDAR_OBJECT_SPLITTER_WORK)):
 
-        group = property(lambda self: "CalendarObjectSplitterWork:%s" % (self.resourceID,))
+        group = property(lambda self: "CalendarObjectSplitterWork:{0}".format(self.resourceID,))
 
         @inlineCallbacks
         def doWork(self):
@@ -4013,7 +4076,10 @@
 
 
     def __repr__(self):
-        return "<%s: %s>" % (self.__class__.__name__, self._attachmentID)
+        return (
+            "<{self.__class__.__name__}: {self._attachmentID}>"
+            .format(self=self)
+        )
 
 
     def _attachmentPathRoot(self):
@@ -4677,7 +4743,7 @@
         splits = name.rsplit(".", 1)
         fname = splits[0]
         suffix = splits[1] if len(splits) == 2 else "unknown"
-        return "%s-%s.%s" % (fname, managed_id[:8], suffix)
+        return "{0}-{1}.{2}".format(fname, managed_id[:8], suffix)
 
 
     @inlineCallbacks
@@ -4770,7 +4836,7 @@
         location = (yield self.location())
 
         attach.setParameter("MANAGED-ID", self.managedID())
-        attach.setParameter("FMTTYPE", "%s/%s" % (self.contentType().mediaType, self.contentType().mediaSubtype))
+        attach.setParameter("FMTTYPE", "{0}/{1}".format(self.contentType().mediaType, self.contentType().mediaSubtype))
         attach.setParameter("FILENAME", self.name())
         attach.setParameter("SIZE", str(self.size()))
         attach.setValue(location)

Modified: CalendarServer/trunk/txdav/caldav/datastore/test/util.py
===================================================================
--- CalendarServer/trunk/txdav/caldav/datastore/test/util.py	2014-04-09 05:17:43 UTC (rev 13217)
+++ CalendarServer/trunk/txdav/caldav/datastore/test/util.py	2014-04-09 05:18:45 UTC (rev 13218)
@@ -78,7 +78,6 @@
         self.geographicLocation = geographicLocation
 
 
-
     def canonicalCalendarUserAddress(self):
         """
             Return a CUA for this record, preferring in this order:
@@ -177,22 +176,26 @@
     # Structured Locations
     directory.addRecord(TestCalendarStoreDirectoryRecord(
         "il1", ("il1",), "1 Infinite Loop", [],
+        cutype="ROOM",
         geographicLocation="37.331741,-122.030333",
         streetAddress="1 Infinite Loop, Cupertino, CA 95014"
     ))
     directory.addRecord(TestCalendarStoreDirectoryRecord(
         "il2", ("il2",), "2 Infinite Loop", [],
+        cutype="ROOM",
         geographicLocation="37.332633,-122.030502",
         streetAddress="2 Infinite Loop, Cupertino, CA 95014"
     ))
     directory.addRecord(TestCalendarStoreDirectoryRecord(
         "room1", ("room1",), "Conference Room One",
         frozenset(("urn:uuid:room1",)),
+        cutype="ROOM",
         associatedAddress="il1",
     ))
     directory.addRecord(TestCalendarStoreDirectoryRecord(
         "room2", ("room2",), "Conference Room Two",
         frozenset(("urn:uuid:room2",)),
+        cutype="ROOM",
         associatedAddress="il2",
     ))
 
@@ -206,8 +209,8 @@
         (uid,),
         uid.capitalize(),
         frozenset((
-            "urn:uuid:%s" % (uid,),
-            "mailto:%s at example.com" % (uid,),
+            "urn:uuid:{0}".format(uid,),
+            "mailto:{0}@example.com".format(uid,),
         )),
     )
 

Modified: CalendarServer/trunk/txdav/caldav/datastore/util.py
===================================================================
--- CalendarServer/trunk/txdav/caldav/datastore/util.py	2014-04-09 05:17:43 UTC (rev 13217)
+++ CalendarServer/trunk/txdav/caldav/datastore/util.py	2014-04-09 05:18:45 UTC (rev 13218)
@@ -104,9 +104,9 @@
 def normalizationLookup(cuaddr, recordFunction, config):
     """
     Lookup function to be passed to ical.normalizeCalendarUserAddresses.
-    Returns a tuple of (Full name C{str}, guid C{UUID}, and calendar user address list C{str})
-    for the given cuaddr.  The recordFunction is called to retrieve the
-    record for the cuaddr.
+    Returns a tuple of (Full name C{str}, guid C{UUID}, cudtype C{str}, and
+    calendar user address list C{str}) for the given cuaddr.
+    recordFunction is called to retrieve the record for the cuaddr.
     """
     try:
         record = yield recordFunction(cuaddr)
@@ -115,7 +115,7 @@
         record = None
 
     if record is None:
-        returnValue((None, None, None))
+        returnValue((None, None, None, None))
     else:
 
         # RFC5545 syntax does not allow backslash escaping in
@@ -132,7 +132,7 @@
         except AttributeError:
             guid = None
 
-        returnValue((fullName, guid, cuas))
+        returnValue((fullName, guid, record.getCUType(), cuas))
 
 
 

Modified: CalendarServer/trunk/txdav/who/directory.py
===================================================================
--- CalendarServer/trunk/txdav/who/directory.py	2014-04-09 05:17:43 UTC (rev 13217)
+++ CalendarServer/trunk/txdav/who/directory.py	2014-04-09 05:18:45 UTC (rev 13218)
@@ -27,6 +27,8 @@
 from twext.who.idirectory import RecordType as BaseRecordType
 from twisted.cred.credentials import UsernamePassword
 from twisted.internet.defer import inlineCallbacks, returnValue
+from twistedcaldav.config import config
+from twistedcaldav.ical import Property
 from txdav.caldav.datastore.scheduling.ischedule.localservers import Servers
 from txdav.who.delegates import RecordType as DelegateRecordType
 from txdav.who.idirectory import (
@@ -75,10 +77,7 @@
             record = yield self.recordWithGUID(guid)
         elif address.startswith("mailto:"):
             records = yield self.recordsWithEmailAddress(address[7:])
-            if records:
-                record = records[0]
-            else:
-                returnValue(None)
+            record = records[0] if records else None
         elif address.startswith("/principals/"):
             parts = address.split("/")
             if len(parts) == 4:
@@ -90,9 +89,16 @@
                     recordType = self.oldNameToRecordType(parts[2])
                     record = yield self.recordWithShortName(recordType, parts[3])
 
-        returnValue(record if record and record.hasCalendars else None)
+        if record:
+            if record.hasCalendars or (
+                config.Scheduling.Options.AllowGroupAsAttendee and
+                record.recordType == BaseRecordType.group
+            ):
+                returnValue(record)
 
+        returnValue(None)
 
+
     def recordsMatchingTokens(self, tokens, context=None, limitResults=50,
                               timeoutSeconds=10):
         fields = [
@@ -178,7 +184,6 @@
             )
         return self.recordsFromExpression(expression)
 
-
     _oldRecordTypeNames = {
         "address": "addresses",
         "group": "groups",
@@ -199,6 +204,7 @@
     def recordTypeToOldName(self, recordType):
         return self._oldRecordTypeNames[recordType.name]
 
+
     def oldNameToRecordType(self, oldName):
         for name, value in self._oldRecordTypeNames.iteritems():
             if oldName == value:
@@ -213,7 +219,6 @@
     class
     """
 
-
     @inlineCallbacks
     def verifyCredentials(self, credentials):
 
@@ -244,14 +249,19 @@
     @property
     def calendarUserAddresses(self):
         try:
-            if not self.hasCalendars:
+            if not (
+                self.hasCalendars or (
+                    config.Scheduling.Options.AllowGroupAsAttendee and
+                    self.recordType == BaseRecordType.group
+                )
+            ):
                 return frozenset()
         except AttributeError:
             pass
 
         try:
             cuas = set(
-                ["mailto:%s" % (emailAddress,)
+                ["mailto:{0}".format(emailAddress,)
                  for emailAddress in self.emailAddresses]
             )
         except AttributeError:
@@ -275,7 +285,6 @@
             )
         return frozenset(cuas)
 
-
     # Mapping from directory record.recordType to RFC2445 CUTYPE values
     _cuTypes = {
         BaseRecordType.user: 'INDIVIDUAL',
@@ -314,7 +323,6 @@
         #                        % (username,))
         #         self.enabledForAddressBooks = False
 
-
     @property
     def displayName(self):
         return self.fullNames[0]
@@ -417,7 +425,6 @@
         return config.EnableCalDAV and self.hasCalendars
 
 
-
     @inlineCallbacks
     def canAutoSchedule(self, organizer=None):
         # FIXME:
@@ -496,3 +503,31 @@
             if delegatorGroup:
                 if other in (yield delegatorGroup.members()):
                     returnValue(True)
+
+
+    def attendee(self, params=None):
+        """
+        Returns a pycalendar ATTENDEE property for this record.
+
+        @param groupUIDs: group uids for the MEMBER parameter of returned property
+        @type organizer: C{List}
+
+        @return: the attendee property
+        @rtype: C{Property}
+        """
+        params = {} if params is None else params.copy()
+
+        if "PARTSTAT" not in params:
+            params["PARTSTAT"] = "NEEDS-ACTION"
+        if "CN"not in params:
+            if self.fullNames:
+                params["CN"] = list(self.fullNames)[0]
+        if "EMAIL" not in params:
+            if self.emailAddresses:
+                params["EMAIL"] = list(self.emailAddresses)[0]
+        if "CUTYPE" not in params:
+            cuType = self.getCUType()
+            if cuType is not "INDIVIDUAL":
+                params["CUTYPE"] = cuType
+
+        return Property("ATTENDEE", "urn:uuid:" + self.uid.encode("utf-8"), params=params)

Modified: CalendarServer/trunk/txdav/who/groups.py
===================================================================
--- CalendarServer/trunk/txdav/who/groups.py	2014-04-09 05:17:43 UTC (rev 13217)
+++ CalendarServer/trunk/txdav/who/groups.py	2014-04-09 05:18:45 UTC (rev 13218)
@@ -169,11 +169,39 @@
             )
         ).on(self.transaction)
 
-    # MOVE2WHO
-    # TODO: Pull this over from groupcacher branch
+        # get calendar Object
+        calObject = schema.CALENDAR_OBJECT
+        rows = yield Select(
+                [calObject.CALENDAR_RESOURCE_ID, ],
+                From=calObject,
+                Where=calObject.RESOURCE_ID == self.eventID,
+        ).on(self.transaction)
 
+        calendarID = rows[0][0]
+        calendarHome = (yield self.Calendar._ownerHomeWithResourceID.on(
+            self.transaction, resourceID=calendarID)
+        )[0][0]
 
+        calendar = yield calendarHome.childWithID(calendarID)
+        calendarObject = yield calendar.objectResourceWithID(self.eventID)
 
+        # get group individual UIDs
+        groupMemember = schema.GROUP_MEMBERSHIP
+        rows = yield Select(
+                [groupMemember.MEMBER_GUID, ],
+                From=groupMemember,
+                Where=groupMemember.GROUP_ID == self.groupID,
+        ).on(self.transaction)
+        memberGUIDs = [row[0] for row in rows]
+
+        component = yield calendarObject.component()
+        changed = component.expandGroupAttendee(self.groupGUID, memberGUIDs, self.directoryService().recordWithCalendarUserAddress)
+
+        if changed:
+            yield calendarObject.setComponent(component)
+
+
+
 def diffAssignments(old, new):
     """
     Compare two proxy assignment lists and return their differences in the form

Added: CalendarServer/trunk/txdav/who/test/accounts/groupAttendeeAccounts.xml
===================================================================
--- CalendarServer/trunk/txdav/who/test/accounts/groupAttendeeAccounts.xml	                        (rev 0)
+++ CalendarServer/trunk/txdav/who/test/accounts/groupAttendeeAccounts.xml	2014-04-09 05:18:45 UTC (rev 13218)
@@ -0,0 +1,137 @@
+<?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>10000000-0000-0000-0000-000000000001</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>10000000-0000-0000-0000-000000000002</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>10000000-0000-0000-0000-000000000003</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>10000000-0000-0000-0000-000000000004</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>10000000-0000-0000-0000-000000000005</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>10000000-0000-0000-0000-000000000006</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>10000000-0000-0000-0000-000000000007</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>10000000-0000-0000-0000-000000000008</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>10000000-0000-0000-0000-000000000009</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>10000000-0000-0000-0000-000000000010</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>20000000-0000-0000-0000-000000000001</uid>
+	    <guid>20000000-0000-0000-0000-000000000001</guid>
+	    <full-name>Group 01</full-name>
+	    <email>group01 at example.com</email>
+	    <member-uid>10000000-0000-0000-0000-000000000001</member-uid>
+	</record>
+	<record type="group">
+	    <short-name>group02</short-name>
+	    <uid>20000000-0000-0000-0000-000000000002</uid>
+	    <guid>20000000-0000-0000-0000-000000000002</guid>
+	    <full-name>Group 02</full-name>
+	    <email>group02 at example.com</email>
+	    <member-uid>10000000-0000-0000-0000-000000000006</member-uid>
+	    <member-uid>10000000-0000-0000-0000-000000000007</member-uid>
+	</record>
+	<record type="group">
+	    <short-name>group03</short-name>
+	    <uid>20000000-0000-0000-0000-000000000003</uid>
+	    <guid>20000000-0000-0000-0000-000000000003</guid>
+	    <full-name>Group 03</full-name>
+	    <email>group03 at example.com</email>
+	    <member-uid>10000000-0000-0000-0000-000000000008</member-uid>
+	    <member-uid>10000000-0000-0000-0000-000000000009</member-uid>
+	</record>
+	<record type="group">
+	    <short-name>group04</short-name>
+	    <uid>20000000-0000-0000-0000-000000000004</uid>
+	    <guid>20000000-0000-0000-0000-000000000004</guid>
+	    <full-name>Group 04</full-name>
+	    <member-uid>20000000-0000-0000-0000-000000000002</member-uid>
+	    <member-uid>20000000-0000-0000-0000-000000000003</member-uid>
+	    <member-uid>10000000-0000-0000-0000-000000000010</member-uid>
+	</record>
+</directory>

Added: CalendarServer/trunk/txdav/who/test/test_group_attendees.py
===================================================================
--- CalendarServer/trunk/txdav/who/test/test_group_attendees.py	                        (rev 0)
+++ CalendarServer/trunk/txdav/who/test/test_group_attendees.py	2014-04-09 05:18:45 UTC (rev 13218)
@@ -0,0 +1,338 @@
+##
+# Copyright (c) 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.
+##
+
+"""
+    group attendee tests
+"""
+
+from twext.who.test.test_xml import xmlService
+from twisted.internet.defer import inlineCallbacks
+from twisted.trial import unittest
+from twistedcaldav.config import config
+from twistedcaldav.ical import Component, normalize_iCalStr
+from txdav.caldav.datastore.test.util import buildCalendarStore, populateCalendarsFrom, CommonCommonTests
+from txdav.who.util import directoryFromConfig
+import os
+
+class GroupAttendeeReconciliation(CommonCommonTests, unittest.TestCase):
+    """
+    GroupAttendeeReconciliation tests
+    """
+
+    @inlineCallbacks
+    def setUp(self):
+        self.patch(config.Scheduling.Options, "AllowGroupAsAttendee", "True")
+
+        yield super(GroupAttendeeReconciliation, self).setUp()
+        self.xmlService = xmlService(self.mktemp(), xmlData=None)
+
+        self.patch(config.DirectoryService.params, "xmlFile",
+            os.path.join(
+                os.path.dirname(__file__), "accounts", "groupAttendeeAccounts.xml"
+            )
+        )
+        self.patch(config.ResourceService.params, "xmlFile",
+            os.path.join(
+                os.path.dirname(__file__), "accounts", "resources.xml"
+            )
+        )
+        self._sqlCalendarStore = yield buildCalendarStore(self, self.notifierFactory, directoryFromConfig(config))
+        yield self.populate()
+
+        self.paths = {}
+
+
+    def storeUnderTest(self):
+        """
+        Create and return a L{CalendarStore} for testing.
+        """
+        return self._sqlCalendarStore
+
+
+    @inlineCallbacks
+    def populate(self):
+        yield populateCalendarsFrom(self.requirements, self.storeUnderTest())
+        self.notifierFactory.reset()
+
+    requirements = {
+        "10000000-0000-0000-0000-000000000001" : {
+            "calendar" : {}
+        },
+    }
+
+    @inlineCallbacks
+    def test_simplePUT(self):
+        """
+        Test that group attendee is expanded on PUT
+        """
+        calendar = yield self.calendarUnderTest(name="calendar", home="10000000-0000-0000-0000-000000000001")
+
+        data_put_1 = """BEGIN:VCALENDAR
+CALSCALE:GREGORIAN
+PRODID:-//Example Inc.//Example Calendar//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:20051222T205953Z
+CREATED:20060101T150000Z
+DTSTART;TZID=US/Eastern:20140101T100000
+DURATION:PT1H
+SUMMARY:event 1
+UID:event1 at ninevah.local
+ORGANIZER:MAILTO:user01 at example.com
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE:MAILTO:group02 at example.com
+END:VEVENT
+END:VCALENDAR"""
+
+        data_get_1 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//Example Inc.//Example Calendar//EN
+BEGIN:VEVENT
+UID:event1 at ninevah.local
+DTSTART;TZID=US/Eastern:20140101T100000
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;RSVP=TRUE:urn:uuid:10000000-0000-0000-0000-000000000001
+ATTENDEE;CN=Group 02;CUTYPE=GROUP;EMAIL=group02 at example.com;RSVP=TRUE;SCHEDULE-STATUS=3.7:urn:uuid:20000000-0000-0000-0000-000000000002
+ATTENDEE;CN=User 06;EMAIL=user06 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000002";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000006
+ATTENDEE;CN=User 07;EMAIL=user07 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000002";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000007
+CREATED:20060101T150000Z
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:uuid:10000000-0000-0000-0000-000000000001
+SUMMARY:event 1
+END:VEVENT
+END:VCALENDAR
+"""
+
+        vcalendar1 = Component.fromString(data_put_1)
+        yield calendar.createCalendarObjectWithName("data1.ics", vcalendar1)
+        yield self.commit()
+
+        cobj1 = yield self.calendarObjectUnderTest(name="data1.ics", calendar_name="calendar", home="10000000-0000-0000-0000-000000000001")
+        vcalendar2 = yield cobj1.component()
+        self.assertEqual(normalize_iCalStr(vcalendar2), normalize_iCalStr(data_get_1))
+
+
+    @inlineCallbacks
+    def test_unknownPUT(self):
+        """
+        Test unknown group with CUTYPE=GROUP handled
+        """
+        calendar = yield self.calendarUnderTest(name="calendar", home="10000000-0000-0000-0000-000000000001")
+
+        data_put_1 = """BEGIN:VCALENDAR
+CALSCALE:GREGORIAN
+PRODID:-//Example Inc.//Example Calendar//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:20051222T205953Z
+CREATED:20060101T150000Z
+DTSTART;TZID=US/Eastern:20140101T100000
+DURATION:PT1H
+SUMMARY:event 1
+UID:event1 at ninevah.local
+ORGANIZER:MAILTO:user01 at example.com
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE;CUTYPE=GROUP:urn:uuid:FFFFFFFF-EEEE-DDDD-CCCC-BBBBBBBBBBBB
+END:VEVENT
+END:VCALENDAR"""
+
+        data_get_1 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//Example Inc.//Example Calendar//EN
+BEGIN:VEVENT
+UID:event1 at ninevah.local
+DTSTART;TZID=US/Eastern:20140101T100000
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;RSVP=TRUE:urn:uuid:10000000-0000-0000-0000-000000000001
+ATTENDEE;CUTYPE=GROUP;RSVP=TRUE;SCHEDULE-STATUS=3.7:urn:uuid:FFFFFFFF-EEEE-DDDD-CCCC-BBBBBBBBBBBB
+CREATED:20060101T150000Z
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:uuid:10000000-0000-0000-0000-000000000001
+SUMMARY:event 1
+END:VEVENT
+END:VCALENDAR
+"""
+
+        vcalendar1 = Component.fromString(data_put_1)
+        yield calendar.createCalendarObjectWithName("data1.ics", vcalendar1)
+        yield self.commit()
+
+        cobj1 = yield self.calendarObjectUnderTest(name="data1.ics", calendar_name="calendar", home="10000000-0000-0000-0000-000000000001")
+        vcalendar2 = yield cobj1.component()
+        self.assertEqual(normalize_iCalStr(vcalendar2), normalize_iCalStr(data_get_1))
+
+
+    @inlineCallbacks
+    def test_primaryAttendeeInGroupPUT(self):
+        """
+        Test that primary attendee also in group remains primary
+        """
+        calendar = yield self.calendarUnderTest(name="calendar", home="10000000-0000-0000-0000-000000000001")
+
+        data_put_1 = """BEGIN:VCALENDAR
+CALSCALE:GREGORIAN
+PRODID:-//Example Inc.//Example Calendar//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:20051222T205953Z
+CREATED:20060101T150000Z
+DTSTART;TZID=US/Eastern:20140101T100000
+DURATION:PT1H
+SUMMARY:event 1
+UID:event1 at ninevah.local
+ORGANIZER:MAILTO:user01 at example.com
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE:mailto:user02 at example.com
+ATTENDEE:MAILTO:group01 at example.com
+END:VEVENT
+END:VCALENDAR"""
+
+        data_get_1 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//Example Inc.//Example Calendar//EN
+BEGIN:VEVENT
+UID:event1 at ninevah.local
+DTSTART;TZID=US/Eastern:20140101T100000
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;RSVP=TRUE:urn:uuid:10000000-0000-0000-0000-000000000001
+ATTENDEE;CN=User 02;EMAIL=user02 at example.com;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000002
+ATTENDEE;CN=Group 01;CUTYPE=GROUP;EMAIL=group01 at example.com;RSVP=TRUE;SCHEDULE-STATUS=3.7:urn:uuid:20000000-0000-0000-0000-000000000001
+CREATED:20060101T150000Z
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:uuid:10000000-0000-0000-0000-000000000001
+SUMMARY:event 1
+END:VEVENT
+END:VCALENDAR
+"""
+        vcalendar1 = Component.fromString(data_put_1)
+        yield calendar.createCalendarObjectWithName("data1.ics", vcalendar1)
+        yield self.commit()
+
+        cobj1 = yield self.calendarObjectUnderTest(name="data1.ics", calendar_name="calendar", home="10000000-0000-0000-0000-000000000001")
+        vcalendar1 = yield cobj1.component()
+        self.assertEqual(normalize_iCalStr(vcalendar1), normalize_iCalStr(data_get_1))
+
+
+    @inlineCallbacks
+    def test_nestedPUT(self):
+        """
+        Test that nested groups are expanded expanded on PUT
+        """
+        calendar = yield self.calendarUnderTest(name="calendar", home="10000000-0000-0000-0000-000000000001")
+
+        data_put_1 = """BEGIN:VCALENDAR
+CALSCALE:GREGORIAN
+PRODID:-//Example Inc.//Example Calendar//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:20051222T205953Z
+CREATED:20060101T150000Z
+DTSTART;TZID=US/Eastern:20140101T100000
+DURATION:PT1H
+SUMMARY:event 1
+UID:event1 at ninevah.local
+ORGANIZER:MAILTO:user01 at example.com
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE:urn:uuid:20000000-0000-0000-0000-000000000004
+END:VEVENT
+END:VCALENDAR"""
+
+        data_get_1 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//Example Inc.//Example Calendar//EN
+BEGIN:VEVENT
+UID:event1 at ninevah.local
+DTSTART;TZID=US/Eastern:20140101T100000
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;RSVP=TRUE:urn:uuid:10000000-0000-0000-0000-000000000001
+ATTENDEE;CN=Group 04;CUTYPE=GROUP;RSVP=TRUE;SCHEDULE-STATUS=3.7:urn:uuid:20000000-0000-0000-0000-000000000004
+ATTENDEE;CN=User 06;EMAIL=user06 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000004";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000006
+ATTENDEE;CN=User 07;EMAIL=user07 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000004";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000007
+ATTENDEE;CN=User 08;EMAIL=user08 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000004";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000008
+ATTENDEE;CN=User 09;EMAIL=user09 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000004";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000009
+ATTENDEE;CN=User 10;EMAIL=user10 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000004";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000010
+CREATED:20060101T150000Z
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:uuid:10000000-0000-0000-0000-000000000001
+SUMMARY:event 1
+END:VEVENT
+END:VCALENDAR
+"""
+
+        vcalendar1 = Component.fromString(data_put_1)
+        yield calendar.createCalendarObjectWithName("data1.ics", vcalendar1)
+        yield self.commit()
+
+        cobj1 = yield self.calendarObjectUnderTest(name="data1.ics", calendar_name="calendar", home="10000000-0000-0000-0000-000000000001")
+        vcalendar2 = yield cobj1.component()
+        self.assertEqual(normalize_iCalStr(vcalendar2), normalize_iCalStr(data_get_1))
+
+
+    @inlineCallbacks
+    def test_twoGroupPUT(self):
+        """
+        Test that expanded users in two primary groups have groups in MEMBERS param
+        """
+        calendar = yield self.calendarUnderTest(name="calendar", home="10000000-0000-0000-0000-000000000001")
+
+        data_put_1 = """BEGIN:VCALENDAR
+CALSCALE:GREGORIAN
+PRODID:-//Example Inc.//Example Calendar//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:20051222T205953Z
+CREATED:20060101T150000Z
+DTSTART;TZID=US/Eastern:20140101T100000
+DURATION:PT1H
+SUMMARY:event 1
+UID:event1 at ninevah.local
+ORGANIZER:MAILTO:user01 at example.com
+ATTENDEE:mailto:user01 at example.com
+ATTENDEE:MAILTO:group02 at example.com
+ATTENDEE:urn:uuid:20000000-0000-0000-0000-000000000004
+END:VEVENT
+END:VCALENDAR"""
+
+        data_get_1 = """BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+PRODID:-//Example Inc.//Example Calendar//EN
+BEGIN:VEVENT
+UID:event1 at ninevah.local
+DTSTART;TZID=US/Eastern:20140101T100000
+DURATION:PT1H
+ATTENDEE;CN=User 01;EMAIL=user01 at example.com;RSVP=TRUE:urn:uuid:10000000-0000-0000-0000-000000000001
+ATTENDEE;CN=Group 02;CUTYPE=GROUP;EMAIL=group02 at example.com;RSVP=TRUE;SCHEDULE-STATUS=3.7:urn:uuid:20000000-0000-0000-0000-000000000002
+ATTENDEE;CN=Group 04;CUTYPE=GROUP;RSVP=TRUE;SCHEDULE-STATUS=3.7:urn:uuid:20000000-0000-0000-0000-000000000004
+ATTENDEE;CN=User 06;EMAIL=user06 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000002","urn:uuid:20000000-0000-0000-0000-000000000004";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000006
+ATTENDEE;CN=User 07;EMAIL=user07 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000002","urn:uuid:20000000-0000-0000-0000-000000000004";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000007
+ATTENDEE;CN=User 08;EMAIL=user08 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000004";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000008
+ATTENDEE;CN=User 09;EMAIL=user09 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000004";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000009
+ATTENDEE;CN=User 10;EMAIL=user10 at example.com;MEMBER="urn:uuid:20000000-0000-0000-0000-000000000004";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.2:urn:uuid:10000000-0000-0000-0000-000000000010
+CREATED:20060101T150000Z
+ORGANIZER;CN=User 01;EMAIL=user01 at example.com:urn:uuid:10000000-0000-0000-0000-000000000001
+SUMMARY:event 1
+END:VEVENT
+END:VCALENDAR
+"""
+
+        vcalendar1 = Component.fromString(data_put_1)
+        yield calendar.createCalendarObjectWithName("data1.ics", vcalendar1)
+        yield self.commit()
+
+        cobj1 = yield self.calendarObjectUnderTest(name="data1.ics", calendar_name="calendar", home="10000000-0000-0000-0000-000000000001")
+        vcalendar2 = yield cobj1.component()
+        self.assertEqual(normalize_iCalStr(vcalendar2), normalize_iCalStr(data_get_1))

Modified: CalendarServer/trunk/txdav/who/test/test_groups.py
===================================================================
--- CalendarServer/trunk/txdav/who/test/test_groups.py	2014-04-09 05:17:43 UTC (rev 13217)
+++ CalendarServer/trunk/txdav/who/test/test_groups.py	2014-04-09 05:18:45 UTC (rev 13218)
@@ -25,6 +25,7 @@
 from txdav.common.icommondatastore import NotFoundError
 
 
+
 class GroupCacherTest(StoreTestCase):
 
     @inlineCallbacks
@@ -46,7 +47,7 @@
         record = yield self.directory.recordWithUID(u"__top_group_1__")
         yield self.groupCacher.refreshGroup(txn, record.uid)
 
-        groupID, name, membershipHash, modified = (yield txn.groupByUID(record.uid))
+        groupID, _ignore_name, membershipHash, _ignore_modified = (yield txn.groupByUID(record.uid))
 
         self.assertEquals(membershipHash, "553eb54e3bbb26582198ee04541dbee4")
 
@@ -88,7 +89,7 @@
         # Refresh the group so it's assigned a group_id
         uid = u"__top_group_1__"
         yield self.groupCacher.refreshGroup(txn, uid)
-        groupID, name, membershipHash, modified = (yield txn.groupByUID(uid))
+        groupID, name, _ignore_membershipHash, _ignore_modified = (yield txn.groupByUID(uid))
 
         # Remove two members, and add one member
         newSet = set()
@@ -135,7 +136,7 @@
         uid = u"__top_group_1__"
         hash = "553eb54e3bbb26582198ee04541dbee4"
         yield self.groupCacher.refreshGroup(txn, uid)
-        groupID, name, membershipHash, modified = yield txn.groupByUID(uid)
+        groupID, _ignore_name, _ignore_membershipHash, _ignore_modified = yield txn.groupByUID(uid)
         results = (yield txn.groupByID(groupID))
         self.assertEquals((uid, u"Top Group 1", hash), results)
 
@@ -242,7 +243,6 @@
             )
         )
 
-
         #
         # Now, remove some external assignments
         #
@@ -310,6 +310,7 @@
             )
         )
 
+
     def test_diffAssignments(self):
         """
         Ensure external proxy assignment diffing works
@@ -389,3 +390,4 @@
                 {"D": ("7", "8"), "C": ("4", "5"), "A": ("1", "2")},
             )
         )
+
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20140408/41376470/attachment-0001.html>


More information about the calendarserver-changes mailing list