<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[13988] CalendarServer/trunk</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta">
<dt>Revision</dt> <dd><a href="http://trac.calendarserver.org//changeset/13988">13988</a></dd>
<dt>Author</dt> <dd>cdaboo@apple.com</dd>
<dt>Date</dt> <dd>2014-09-22 10:00:48 -0700 (Mon, 22 Sep 2014)</dd>
</dl>

<h3>Log Message</h3>
<pre>Enable addMemberts/removeMembers functionality in the directory and DPS. Support those methods with new commands in calendarserver_manage_principals. Add a new control API resource to also expose those operations via HTTP for CDT testing.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#CalendarServertrunkcalendarservertaputilpy">CalendarServer/trunk/calendarserver/tap/util.py</a></li>
<li><a href="#CalendarServertrunkcalendarservertoolsprincipalspy">CalendarServer/trunk/calendarserver/tools/principals.py</a></li>
<li><a href="#CalendarServertrunkcalendarservertoolstesttest_principalspy">CalendarServer/trunk/calendarserver/tools/test/test_principals.py</a></li>
<li><a href="#CalendarServertrunkconfcaldavdtestplist">CalendarServer/trunk/conf/caldavd-test.plist</a></li>
<li><a href="#CalendarServertrunktwistedcaldavdirectoryaugmentpy">CalendarServer/trunk/twistedcaldav/directory/augment.py</a></li>
<li><a href="#CalendarServertrunktwistedcaldavstdconfigpy">CalendarServer/trunk/twistedcaldav/stdconfig.py</a></li>
<li><a href="#CalendarServertrunktxdavdpsclientpy">CalendarServer/trunk/txdav/dps/client.py</a></li>
<li><a href="#CalendarServertrunktxdavdpscommandspy">CalendarServer/trunk/txdav/dps/commands.py</a></li>
<li><a href="#CalendarServertrunktxdavdpsserverpy">CalendarServer/trunk/txdav/dps/server.py</a></li>
<li><a href="#CalendarServertrunktxdavdpstesttest_clientpy">CalendarServer/trunk/txdav/dps/test/test_client.py</a></li>
<li><a href="#CalendarServertrunktxdavwhoaugmentpy">CalendarServer/trunk/txdav/who/augment.py</a></li>
<li><a href="#CalendarServertrunktxdavwhoxmlpy">CalendarServer/trunk/txdav/who/xml.py</a></li>
</ul>

<h3>Added Paths</h3>
<ul>
<li><a href="#CalendarServertrunktwistedcaldavcontrolapipy">CalendarServer/trunk/twistedcaldav/controlapi.py</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="CalendarServertrunkcalendarservertaputilpy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/calendarserver/tap/util.py (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/calendarserver/tap/util.py        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/calendarserver/tap/util.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -61,6 +61,7 @@
</span><span class="cx"> from twext.enterprise.ienterprise import POSTGRES_DIALECT
</span><span class="cx"> from twistedcaldav.bind import doBind
</span><span class="cx"> from twistedcaldav.cache import CacheStoreNotifierFactory
</span><ins>+from twistedcaldav.controlapi import ControlAPIResource
</ins><span class="cx"> from twistedcaldav.directory.addressbook import DirectoryAddressBookHomeProvisioningResource
</span><span class="cx"> from twistedcaldav.directory.calendar import DirectoryCalendarHomeProvisioningResource
</span><span class="cx"> from twistedcaldav.directory.digest import QopDigestCredentialFactory
</span><span class="lines">@@ -380,6 +381,7 @@
</span><span class="cx">     directoryBackedAddressBookResourceClass = DirectoryBackedAddressBookResource
</span><span class="cx">     apnSubscriptionResourceClass = APNSubscriptionResource
</span><span class="cx">     principalResourceClass = DirectoryPrincipalProvisioningResource
</span><ins>+    controlResourceClass = ControlAPIResource
</ins><span class="cx"> 
</span><span class="cx">     directory = newStore.directoryService()
</span><span class="cx">     principalCollection = principalResourceClass(&quot;/principals/&quot;, directory)
</span><span class="lines">@@ -669,6 +671,19 @@
</span><span class="cx">         root.putChild(&quot;admin&quot;, webAdmin)
</span><span class="cx"> 
</span><span class="cx">     #
</span><ins>+    # Control API
+    #
+    if config.EnableControlAPI:
+        log.info(&quot;Setting up Control API resource&quot;)
+        controlAPI = controlResourceClass(
+            root,
+            directory,
+            newStore,
+            principalCollections=(principalCollection,),
+        )
+        root.putChild(&quot;control&quot;, controlAPI)
+
+    #
</ins><span class="cx">     # Apple Push Notification Subscriptions
</span><span class="cx">     #
</span><span class="cx">     apnConfig = config.Notifications.Services.APNS
</span></span></pre></div>
<a id="CalendarServertrunkcalendarservertoolsprincipalspy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/calendarserver/tools/principals.py (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/calendarserver/tools/principals.py        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/calendarserver/tools/principals.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -133,8 +133,9 @@
</span><span class="cx">                 &quot;remove&quot;,
</span><span class="cx">                 &quot;search=&quot;,
</span><span class="cx">                 &quot;list-principal-types&quot;,
</span><del>-                &quot;print-group-info&quot;,
</del><span class="cx">                 &quot;list-principals=&quot;,
</span><ins>+
+                # Proxies
</ins><span class="cx">                 &quot;list-read-proxies&quot;,
</span><span class="cx">                 &quot;list-write-proxies&quot;,
</span><span class="cx">                 &quot;list-proxies&quot;,
</span><span class="lines">@@ -142,17 +143,27 @@
</span><span class="cx">                 &quot;add-read-proxy=&quot;,
</span><span class="cx">                 &quot;add-write-proxy=&quot;,
</span><span class="cx">                 &quot;remove-proxy=&quot;,
</span><ins>+
+                # Groups
+                &quot;list-group-members&quot;,
+                &quot;add-group-member=&quot;,
+                &quot;remove-group-member=&quot;,
+                &quot;print-group-info&quot;,
+                &quot;refresh-groups&quot;,
+
+                # Scheduling
</ins><span class="cx">                 &quot;set-auto-schedule-mode=&quot;,
</span><span class="cx">                 &quot;get-auto-schedule-mode&quot;,
</span><span class="cx">                 &quot;set-auto-accept-group=&quot;,
</span><span class="cx">                 &quot;get-auto-accept-group&quot;,
</span><ins>+
+                # Principal details
</ins><span class="cx">                 &quot;set-geo=&quot;,
</span><span class="cx">                 &quot;get-geo&quot;,
</span><span class="cx">                 &quot;set-address=&quot;,
</span><span class="cx">                 &quot;get-address&quot;,
</span><span class="cx">                 &quot;set-street-address=&quot;,
</span><span class="cx">                 &quot;get-street-address&quot;,
</span><del>-                &quot;refresh-groups&quot;,
</del><span class="cx">                 &quot;verbose&quot;,
</span><span class="cx">             ],
</span><span class="cx">         )
</span><span class="lines">@@ -195,12 +206,6 @@
</span><span class="cx">         elif opt in (&quot;&quot;, &quot;--list-principal-types&quot;):
</span><span class="cx">             listPrincipalTypes = True
</span><span class="cx"> 
</span><del>-        elif opt in (&quot;&quot;, &quot;--print-group-info&quot;):
-            printGroupInfo = True
-
-        elif opt in (&quot;&quot;, &quot;--refresh-groups&quot;):
-            scheduleGroupRefresh = True
-
</del><span class="cx">         elif opt in (&quot;&quot;, &quot;--list-principals&quot;):
</span><span class="cx">             listPrincipals = arg
</span><span class="cx"> 
</span><span class="lines">@@ -231,6 +236,21 @@
</span><span class="cx">         elif opt in (&quot;&quot;, &quot;--remove-proxy&quot;):
</span><span class="cx">             principalActions.append((action_removeProxy, arg))
</span><span class="cx"> 
</span><ins>+        elif opt in (&quot;&quot;, &quot;--list-group-members&quot;):
+            principalActions.append((action_listGroupMembers,))
+
+        elif opt in (&quot;--add-group-member&quot;):
+            principalActions.append((action_addGroupMember, arg))
+
+        elif opt in (&quot;&quot;, &quot;--remove-group-member&quot;):
+            principalActions.append((action_removeGroupMember, arg))
+
+        elif opt in (&quot;&quot;, &quot;--print-group-info&quot;):
+            printGroupInfo = True
+
+        elif opt in (&quot;&quot;, &quot;--refresh-groups&quot;):
+            scheduleGroupRefresh = True
+
</ins><span class="cx">         elif opt in (&quot;&quot;, &quot;--set-auto-schedule-mode&quot;):
</span><span class="cx">             try:
</span><span class="cx">                 if arg not in allowedAutoScheduleModes:
</span><span class="lines">@@ -658,6 +678,128 @@
</span><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx"> 
</span><ins>+@inlineCallbacks
+def action_listGroupMembers(store, record):
+    members = yield record.members()
+    if members:
+        print(&quot;Group members for %s:\n&quot; % (
+            prettyRecord(record)
+        ))
+        printRecordList(members)
+        print(&quot;&quot;)
+    else:
+        print(&quot;No group members for %s&quot; % (prettyRecord(record),))
+
+
+
+@inlineCallbacks
+def action_addGroupMember(store, record, *memberIDs):
+    directory = store.directoryService()
+    existingMembers = yield record.members()
+    existingMemberUIDs = set([member.uid for member in existingMembers])
+    add = set()
+    for memberID in memberIDs:
+        memberRecord = yield recordForPrincipalID(directory, memberID)
+        if memberRecord is None:
+            print(&quot;Invalid member ID: %s&quot; % (memberID,))
+        elif memberRecord.uid in existingMemberUIDs:
+            print(&quot;Existing member ID: %s&quot; % (memberID,))
+        else:
+            add.add(memberRecord)
+
+    if add:
+        yield record.addMembers(add)
+        for memberRecord in add:
+            print(
+                &quot;Added {member} for {record}&quot;.format(
+                    member=prettyRecord(memberRecord),
+                    record=prettyRecord(record)
+                )
+            )
+        yield record.service.updateRecords([record], create=False)
+
+
+
+@inlineCallbacks
+def action_removeGroupMember(store, record, *memberIDs):
+    directory = store.directoryService()
+    existingMembers = yield record.members()
+    existingMemberUIDs = set([member.uid for member in existingMembers])
+    remove = set()
+    for memberID in memberIDs:
+        memberRecord = yield recordForPrincipalID(directory, memberID)
+        if memberRecord is None:
+            print(&quot;Invalid member ID: %s&quot; % (memberID,))
+        elif memberRecord.uid not in existingMemberUIDs:
+            print(&quot;Missing member ID: %s&quot; % (memberID,))
+        else:
+            remove.add(memberRecord)
+
+    if remove:
+        yield record.removeMembers(remove)
+        for memberRecord in remove:
+            print(
+                &quot;Removed {member} for {record}&quot;.format(
+                    member=prettyRecord(memberRecord),
+                    record=prettyRecord(record)
+                )
+            )
+        yield record.service.updateRecords([record], create=False)
+
+
+
+@inlineCallbacks
+def printGroupCacherInfo(service, store):
+    &quot;&quot;&quot;
+    Print all groups that have been delegated to, their cached members, and
+    who delegated to those groups.
+    &quot;&quot;&quot;
+    directory = store.directoryService()
+    txn = store.newTransaction()
+    groupUIDs = yield txn.allGroupDelegates()
+    for groupUID in groupUIDs:
+        (
+            groupID, name, _ignore_membershipHash, modified, _ignore_extant
+        ) = yield txn.groupByUID(
+            groupUID
+        )
+        print(&quot;Group: \&quot;{name}\&quot; ({uid})&quot;.format(name=name, uid=groupUID))
+
+        for txt, readWrite in ((&quot;read-only&quot;, False), (&quot;read-write&quot;, True)):
+            delegatorUIDs = yield txn.delegatorsToGroup(groupID, readWrite)
+            for delegatorUID in delegatorUIDs:
+                delegator = yield directory.recordWithUID(delegatorUID)
+                print(
+                    &quot;...has {rw} access to {rec}&quot;.format(
+                        rw=txt, rec=prettyRecord(delegator)
+                    )
+                )
+
+        print(&quot;Group members:&quot;)
+        memberUIDs = yield txn.groupMemberUIDs(groupID)
+        for memberUID in memberUIDs:
+            record = yield directory.recordWithUID(memberUID)
+            print(prettyRecord(record))
+
+        print(&quot;Last cached: {} GMT&quot;.format(modified))
+        print()
+
+    yield txn.commit()
+
+
+
+@inlineCallbacks
+def scheduleGroupRefreshJob(service, store):
+    &quot;&quot;&quot;
+    Schedule GroupCacherPollingWork
+    &quot;&quot;&quot;
+    txn = store.newTransaction()
+    print(&quot;Scheduling a group refresh&quot;)
+    yield GroupCacherPollingWork.reschedule(txn, 0, force=True)
+    yield txn.commit()
+
+
+
</ins><span class="cx"> def action_getAutoScheduleMode(store, record):
</span><span class="cx">     print(
</span><span class="cx">         &quot;Auto-schedule mode for {record} is {mode}&quot;.format(
</span><span class="lines">@@ -812,58 +954,6 @@
</span><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx"> 
</span><del>-@inlineCallbacks
-def printGroupCacherInfo(service, store):
-    &quot;&quot;&quot;
-    Print all groups that have been delegated to, their cached members, and
-    who delegated to those groups.
-    &quot;&quot;&quot;
-    directory = store.directoryService()
-    txn = store.newTransaction()
-    groupUIDs = yield txn.allGroupDelegates()
-    for groupUID in groupUIDs:
-        (
-            groupID, name, _ignore_membershipHash, modified, _ignore_extant
-        ) = yield txn.groupByUID(
-            groupUID
-        )
-        print(&quot;Group: \&quot;{name}\&quot; ({uid})&quot;.format(name=name, uid=groupUID))
-
-        for txt, readWrite in ((&quot;read-only&quot;, False), (&quot;read-write&quot;, True)):
-            delegatorUIDs = yield txn.delegatorsToGroup(groupID, readWrite)
-            for delegatorUID in delegatorUIDs:
-                delegator = yield directory.recordWithUID(delegatorUID)
-                print(
-                    &quot;...has {rw} access to {rec}&quot;.format(
-                        rw=txt, rec=prettyRecord(delegator)
-                    )
-                )
-
-        print(&quot;Group members:&quot;)
-        memberUIDs = yield txn.groupMemberUIDs(groupID)
-        for memberUID in memberUIDs:
-            record = yield directory.recordWithUID(memberUID)
-            print(prettyRecord(record))
-
-        print(&quot;Last cached: {} GMT&quot;.format(modified))
-        print()
-
-    yield txn.commit()
-
-
-
-@inlineCallbacks
-def scheduleGroupRefreshJob(service, store):
-    &quot;&quot;&quot;
-    Schedule GroupCacherPollingWork
-    &quot;&quot;&quot;
-    txn = store.newTransaction()
-    print(&quot;Scheduling a group refresh&quot;)
-    yield GroupCacherPollingWork.reschedule(txn, 0, force=True)
-    yield txn.commit()
-
-
-
</del><span class="cx"> def abort(msg, status=1):
</span><span class="cx">     sys.stdout.write(&quot;%s\n&quot; % (msg,))
</span><span class="cx">     try:
</span></span></pre></div>
<a id="CalendarServertrunkcalendarservertoolstesttest_principalspy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/calendarserver/tools/test/test_principals.py (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/calendarserver/tools/test/test_principals.py        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/calendarserver/tools/test/test_principals.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -293,7 +293,66 @@
</span><span class="cx">             self.fail(&quot;Expected command failure&quot;)
</span><span class="cx"> 
</span><span class="cx"> 
</span><ins>+    @inlineCallbacks
+    def test_groupChanges(self):
+        results = yield self.runCommand(
+            &quot;--list-group-members&quot;, &quot;groups:testgroup1&quot;
+        )
+        self.assertTrue(&quot;user01&quot; in results)
+        self.assertTrue(&quot;user02&quot; in results)
+        self.assertTrue(&quot;user03&quot; not in results)
</ins><span class="cx"> 
</span><ins>+        results = yield self.runCommand(
+            &quot;--add-group-member&quot;, &quot;users:user03&quot;, &quot;groups:testgroup1&quot;
+        )
+        self.assertTrue(&quot;Added&quot; in results)
+        self.assertTrue(&quot;Existing&quot; not in results)
+        self.assertTrue(&quot;Invalid&quot; not in results)
+
+        results = yield self.runCommand(
+            &quot;--list-group-members&quot;, &quot;groups:testgroup1&quot;
+        )
+        self.assertTrue(&quot;user01&quot; in results)
+        self.assertTrue(&quot;user02&quot; in results)
+        self.assertTrue(&quot;user03&quot; in results)
+
+        results = yield self.runCommand(
+            &quot;--add-group-member&quot;, &quot;users:user03&quot;, &quot;groups:testgroup1&quot;
+        )
+        self.assertTrue(&quot;Added&quot; not in results)
+        self.assertTrue(&quot;Existing&quot; in results)
+        self.assertTrue(&quot;Invalid&quot; not in results)
+
+        results = yield self.runCommand(
+            &quot;--add-group-member&quot;, &quot;users:bogus&quot;, &quot;groups:testgroup1&quot;
+        )
+        self.assertTrue(&quot;Added&quot; not in results)
+        self.assertTrue(&quot;Existing&quot; not in results)
+        self.assertTrue(&quot;Invalid&quot; in results)
+
+        results = yield self.runCommand(
+            &quot;--remove-group-member&quot;, &quot;users:user03&quot;, &quot;groups:testgroup1&quot;
+        )
+        self.assertTrue(&quot;Removed&quot; in results)
+        self.assertTrue(&quot;Missing&quot; not in results)
+        self.assertTrue(&quot;Invalid&quot; not in results)
+
+        results = yield self.runCommand(
+            &quot;--list-group-members&quot;, &quot;groups:testgroup1&quot;
+        )
+        self.assertTrue(&quot;user01&quot; in results)
+        self.assertTrue(&quot;user02&quot; in results)
+        self.assertTrue(&quot;user03&quot; not in results)
+
+        results = yield self.runCommand(
+            &quot;--remove-group-member&quot;, &quot;users:user03&quot;, &quot;groups:testgroup1&quot;
+        )
+        self.assertTrue(&quot;Removed&quot; not in results)
+        self.assertTrue(&quot;Missing&quot; in results)
+        self.assertTrue(&quot;Invalid&quot; not in results)
+
+
+
</ins><span class="cx"> class SetProxiesTestCase(StoreTestCase):
</span><span class="cx"> 
</span><span class="cx">     @inlineCallbacks
</span></span></pre></div>
<a id="CalendarServertrunkconfcaldavdtestplist"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/conf/caldavd-test.plist (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/conf/caldavd-test.plist        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/conf/caldavd-test.plist        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -823,6 +823,10 @@
</span><span class="cx">     &lt;key&gt;EnableWebAdmin&lt;/key&gt;
</span><span class="cx">     &lt;true/&gt;
</span><span class="cx"> 
</span><ins>+    &lt;!-- JSON Control API - only for testing --&gt;
+        &lt;key&gt;EnableControlAPI&lt;/key&gt;
+        &lt;true/&gt;
+
</ins><span class="cx">     &lt;!-- Support for Content-Encoding compression --&gt;
</span><span class="cx">     &lt;key&gt;ResponseCompression&lt;/key&gt;
</span><span class="cx">     &lt;false/&gt;  &lt;!-- Off for testing, as debugging is easier that way. --&gt;
</span></span></pre></div>
<a id="CalendarServertrunktwistedcaldavcontrolapipy"></a>
<div class="addfile"><h4>Added: CalendarServer/trunk/twistedcaldav/controlapi.py (0 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/twistedcaldav/controlapi.py                                (rev 0)
+++ CalendarServer/trunk/twistedcaldav/controlapi.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -0,0 +1,337 @@
</span><ins>+##
+# Copyright (c) 2014 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);
+# 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 &quot;AS IS&quot; 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.
+##
+
+&quot;&quot;&quot;
+Timezone service resource and operations.
+
+This is based on http://tools.ietf.org/html/draft-douglass-timezone-service which is the CalConnect
+proposal for a standard timezone service.
+&quot;&quot;&quot;
+
+__all__ = [
+    &quot;TimezoneStdServiceResource&quot;,
+]
+
+from calendarserver.tools.util import recordForPrincipalID
+
+from twext.enterprise.jobqueue import JobItem
+from twext.python.log import Logger
+
+from twisted.internet import reactor
+from twisted.internet.defer import succeed, inlineCallbacks, returnValue
+
+from twistedcaldav.config import config
+from twistedcaldav.extensions import DAVResource, \
+    DAVResourceWithoutChildrenMixin
+from twistedcaldav.resource import ReadOnlyNoCopyResourceMixIn
+
+from txdav.who.groups import GroupCacherPollingWork, GroupRefreshWork, \
+    GroupAttendeeReconciliationWork, GroupDelegateChangesWork, \
+    GroupShareeReconciliationWork
+from txdav.xml import element as davxml
+
+from txweb2 import responsecode
+from txweb2.dav.method.propfind import http_PROPFIND
+from txweb2.dav.noneprops import NonePropertyStore
+from txweb2.dav.util import allDataFromStream
+from txweb2.http import HTTPError, JSONResponse, StatusResponse
+from txweb2.http import Response
+from txweb2.http_headers import MimeType
+
+import json
+
+log = Logger()
+
+class ControlAPIResource (ReadOnlyNoCopyResourceMixIn, DAVResourceWithoutChildrenMixin, DAVResource):
+    &quot;&quot;&quot;
+    Resource used to execute admin commands.
+
+    Extends L{DAVResource} to provide service functionality.
+    &quot;&quot;&quot;
+
+    def __init__(self, root, directory, store, principalCollections=()):
+        &quot;&quot;&quot;
+        @param parent: the parent resource of this one.
+        &quot;&quot;&quot;
+
+        DAVResource.__init__(self, principalCollections=principalCollections)
+
+        self.parent = root
+        self._store = store
+        self._directory = directory
+
+
+    def deadProperties(self):
+        if not hasattr(self, &quot;_dead_properties&quot;):
+            self._dead_properties = NonePropertyStore(self)
+        return self._dead_properties
+
+
+    def etag(self):
+        return succeed(None)
+
+
+    def checkPreconditions(self, request):
+        return None
+
+
+    def defaultAccessControlList(self):
+        return succeed(davxml.ACL(*config.AdminACEs))
+
+
+    def contentType(self):
+        return MimeType.fromString(&quot;text/html; charset=utf-8&quot;)
+
+
+    def resourceType(self):
+        return None
+
+
+    def isCollection(self):
+        return False
+
+
+    def isCalendarCollection(self):
+        return False
+
+
+    def isPseudoCalendarCollection(self):
+        return False
+
+
+    def render(self, request):
+        output = &quot;&quot;&quot;&lt;html&gt;
+&lt;head&gt;
+&lt;title&gt;Control API Resource&lt;/title&gt;
+&lt;/head&gt;
+&lt;body&gt;
+&lt;h1&gt;Control API Resource.&lt;/h1&gt;
+&lt;/body
+&lt;/html&gt;&quot;&quot;&quot;
+
+        response = Response(200, {}, output)
+        response.headers.setHeader(&quot;content-type&quot;, MimeType(&quot;text&quot;, &quot;html&quot;))
+        return response
+
+    http_PROPFIND = http_PROPFIND
+
+    def http_GET(self, request):
+        &quot;&quot;&quot;
+        GET just returns HTML description.
+        &quot;&quot;&quot;
+        return self.render(request)
+
+
+    def _ok(self, status, description, result=None):
+        if result is None:
+            result = {}
+        result[&quot;status&quot;] = status
+        result[&quot;description&quot;] = description
+        return JSONResponse(
+            responsecode.OK,
+            result,
+        )
+
+
+    def _error(self, status, description):
+        raise HTTPError(JSONResponse(
+            responsecode.BAD_REQUEST,
+            {
+                &quot;status&quot;: status,
+                &quot;description&quot;: description,
+            },
+        ))
+
+
+    def _recordsToJSON(self, records):
+        results = []
+        for record in sorted(records, key=lambda r: r.uid):
+            try:
+                shortNames = record.shortNames
+            except AttributeError:
+                shortNames = []
+            results.append(
+                {
+                    &quot;type&quot;: record.recordType.name,
+                    &quot;cn&quot;: record.displayName,
+                    &quot;uid&quot;: record.uid,
+                    &quot;sn&quot;: shortNames
+                }
+            )
+        return results
+
+
+    @inlineCallbacks
+    def http_POST(self, request):
+        &quot;&quot;&quot;
+        POST method with JSON body is used for control.
+        &quot;&quot;&quot;
+
+        #
+        # Check authentication and access controls
+        #
+        yield self.authorize(request, (davxml.Read(),))
+
+        contentType = request.headers.getHeader(&quot;content-type&quot;)
+        # Check content first
+        if &quot;{}/{}&quot;.format(contentType.mediaType, contentType.mediaSubtype) != &quot;application/json&quot;:
+            self.log.error(&quot;MIME type {mime} not allowed in request&quot;, mime=contentType)
+            raise HTTPError(StatusResponse(responsecode.BAD_REQUEST, &quot;MIME type {} not allowed in request&quot;.format(contentType)))
+
+        body = (yield allDataFromStream(request.stream))
+        try:
+            j = json.loads(body)
+        except (ValueError, TypeError) as e:
+            self.log.error(&quot;Invalid JSON data in request: {ex}\n{body}&quot;, ex=e, body=body)
+            raise HTTPError(StatusResponse(responsecode.BAD_REQUEST, &quot;Invalid JSON data in request: {}\n{}&quot;.format(e, body)))
+
+        try:
+            action = j[&quot;action&quot;]
+        except KeyError:
+            self._error(&quot;error&quot;, &quot;No 'action' member in root JSON object.&quot;)
+
+        method = &quot;action_{}&quot;.format(action)
+        if not hasattr(self, method):
+            self._error(&quot;error&quot;, &quot;The action '{}' is not supported.&quot;.format(action))
+
+        result = yield getattr(self, method)(j)
+        returnValue(result)
+
+
+    @inlineCallbacks
+    def action_listgroupmembers(self, j):
+        try:
+            grpID = j[&quot;group&quot;]
+        except KeyError:
+            self._error(&quot;error&quot;, &quot;No 'group' member in root JSON object.&quot;)
+
+        try:
+            record = yield recordForPrincipalID(self._directory, grpID)
+        except ValueError:
+            record = None
+        if record is None:
+            self._error(&quot;error&quot;, &quot;No group with id '{}' in the directory.&quot;.format(grpID))
+
+        members = yield record.members()
+
+        returnValue(self._ok(&quot;ok&quot;, &quot;Group membership&quot;, {
+            &quot;group&quot;: grpID,
+            &quot;members&quot;: self._recordsToJSON(members),
+        }))
+
+
+    @inlineCallbacks
+    def action_addgroupmembers(self, j):
+        try:
+            grpID = j[&quot;group&quot;]
+        except KeyError:
+            self._error(&quot;error&quot;, &quot;No 'group' member in root JSON object.&quot;)
+        try:
+            memberIDs = j[&quot;members&quot;]
+        except KeyError:
+            self._error(&quot;error&quot;, &quot;No 'members' member in root JSON object.&quot;)
+
+        try:
+            record = yield recordForPrincipalID(self._directory, grpID)
+        except ValueError:
+            record = None
+        if record is None:
+            self._error(&quot;error&quot;, &quot;No group with id '{}' in the directory.&quot;.format(grpID))
+
+        existingMembers = yield record.members()
+        existingMemberUIDs = set([member.uid for member in existingMembers])
+        add = set()
+        invalid = set()
+        exists = set()
+        for memberID in memberIDs:
+            memberRecord = yield recordForPrincipalID(self._directory, memberID)
+            if memberRecord is None:
+                invalid.add(memberID)
+            elif memberRecord.uid in existingMemberUIDs:
+                exists.add(memberRecord)
+            else:
+                add.add(memberRecord)
+
+        if add:
+            yield record.addMembers(add)
+            yield record.service.updateRecords([record], create=False)
+
+        returnValue(self._ok(&quot;ok&quot;, &quot;Added group members&quot;, {
+            &quot;group&quot;: grpID,
+            &quot;added&quot;: self._recordsToJSON(add),
+            &quot;exists&quot;: self._recordsToJSON(exists),
+            &quot;invalid&quot;: sorted(invalid),
+        }))
+
+
+    @inlineCallbacks
+    def action_removegroupmembers(self, j):
+        try:
+            grpID = j[&quot;group&quot;]
+        except KeyError:
+            self._error(&quot;error&quot;, &quot;No 'group' member in root JSON object.&quot;)
+        try:
+            memberIDs = j[&quot;members&quot;]
+        except KeyError:
+            self._error(&quot;error&quot;, &quot;No 'members' member in root JSON object.&quot;)
+
+        try:
+            record = yield recordForPrincipalID(self._directory, grpID)
+        except ValueError:
+            record = None
+        if record is None:
+            self._error(&quot;error&quot;, &quot;No group with id '{}' in the directory.&quot;.format(grpID))
+
+        existingMembers = yield record.members()
+        existingMemberUIDs = set([member.uid for member in existingMembers])
+        remove = set()
+        invalid = set()
+        missing = set()
+        for memberID in memberIDs:
+            memberRecord = yield recordForPrincipalID(self._directory, memberID)
+            if memberRecord is None:
+                invalid.add(memberID)
+            elif memberRecord.uid not in existingMemberUIDs:
+                missing.add(memberRecord)
+            else:
+                remove.add(memberRecord)
+
+        if remove:
+            record.removeMembers(remove)
+            yield record.service.updateRecords([record], create=False)
+
+        returnValue(self._ok(&quot;ok&quot;, &quot;Removed group members&quot;, {
+            &quot;group&quot;: grpID,
+            &quot;removed&quot;: self._recordsToJSON(remove),
+            &quot;missing&quot;: self._recordsToJSON(missing),
+            &quot;invalid&quot;: sorted(invalid),
+        }))
+
+
+    @inlineCallbacks
+    def action_refreshgroups(self, j):
+        txn = self._store.newTransaction()
+        wp = yield GroupCacherPollingWork.reschedule(txn, 0, force=True)
+        jobID = wp.workItem.jobID
+        yield txn.commit()
+
+        if &quot;wait&quot; in j and j[&quot;wait&quot;]:
+            yield JobItem.waitJobDone(self._store.newTransaction, reactor, 60.0, jobID)
+            yield JobItem.waitWorkDone(self._store.newTransaction, reactor, 60.0, (
+                GroupRefreshWork, GroupAttendeeReconciliationWork, GroupDelegateChangesWork, GroupShareeReconciliationWork,
+            ))
+
+        returnValue(self._ok(&quot;ok&quot;, &quot;Group refresh scheduled&quot;))
</ins></span></pre></div>
<a id="CalendarServertrunktwistedcaldavdirectoryaugmentpy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/twistedcaldav/directory/augment.py (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/twistedcaldav/directory/augment.py        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/twistedcaldav/directory/augment.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -469,13 +469,11 @@
</span><span class="cx">     def _updateRecordInXMLDB(self, record, recordNode):
</span><span class="cx">         del recordNode[:]
</span><span class="cx">         addSubElement(recordNode, xmlaugmentsparser.ELEMENT_UID, record.uid)
</span><del>-        addSubElement(recordNode, xmlaugmentsparser.ELEMENT_ENABLE, &quot;true&quot; if record.enabled else &quot;false&quot;)
</del><span class="cx">         if record.serverID:
</span><span class="cx">             addSubElement(recordNode, xmlaugmentsparser.ELEMENT_SERVERID, record.serverID)
</span><span class="cx">         addSubElement(recordNode, xmlaugmentsparser.ELEMENT_ENABLECALENDAR, &quot;true&quot; if record.enabledForCalendaring else &quot;false&quot;)
</span><span class="cx">         addSubElement(recordNode, xmlaugmentsparser.ELEMENT_ENABLEADDRESSBOOK, &quot;true&quot; if record.enabledForAddressBooks else &quot;false&quot;)
</span><span class="cx">         addSubElement(recordNode, xmlaugmentsparser.ELEMENT_ENABLELOGIN, &quot;true&quot; if record.enabledForLogin else &quot;false&quot;)
</span><del>-        addSubElement(recordNode, xmlaugmentsparser.ELEMENT_AUTOSCHEDULE, &quot;true&quot; if record.autoSchedule else &quot;false&quot;)
</del><span class="cx">         if record.autoScheduleMode:
</span><span class="cx">             addSubElement(recordNode, xmlaugmentsparser.ELEMENT_AUTOSCHEDULE_MODE, record.autoScheduleMode)
</span><span class="cx">         if record.autoAcceptGroup:
</span></span></pre></div>
<a id="CalendarServertrunktwistedcaldavstdconfigpy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/twistedcaldav/stdconfig.py (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/twistedcaldav/stdconfig.py        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/twistedcaldav/stdconfig.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -621,6 +621,11 @@
</span><span class="cx">     &quot;EnableWebAdmin&quot;          : True,
</span><span class="cx"> 
</span><span class="cx">     #
</span><ins>+    # JSON control API - only for testing
+    #
+    &quot;EnableControlAPI&quot;        : False,
+
+    #
</ins><span class="cx">     # Scheduling related options
</span><span class="cx">     #
</span><span class="cx"> 
</span></span></pre></div>
<a id="CalendarServertrunktxdavdpsclientpy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/txdav/dps/client.py (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/txdav/dps/client.py        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/txdav/dps/client.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -42,7 +42,9 @@
</span><span class="cx">     MembersCommand, GroupsCommand, SetMembersCommand,
</span><span class="cx">     VerifyPlaintextPasswordCommand, VerifyHTTPDigestCommand,
</span><span class="cx">     WikiAccessForUIDCommand, ContinuationCommand,
</span><del>-    StatsCommand, ExternalDelegatesCommand, ExpandedMemberUIDsCommand
</del><ins>+    StatsCommand, ExternalDelegatesCommand, ExpandedMemberUIDsCommand,
+    AddMembersCommand, RemoveMembersCommand,
+    UpdateRecordsCommand
</ins><span class="cx"> )
</span><span class="cx"> from txdav.who.delegates import RecordType as DelegatesRecordType
</span><span class="cx"> from txdav.who.directory import (
</span><span class="lines">@@ -407,6 +409,19 @@
</span><span class="cx">         )
</span><span class="cx"> 
</span><span class="cx"> 
</span><ins>+    def updateRecords(self, records, create=False):
+        # FIXME: cannot create right now
+        if create:
+            raise ValueError(&quot;Cannot create records using DPS&quot;)
+
+        recordUIDs = [r.uid.encode(&quot;utf-8&quot;) for r in records]
+        return self._sendCommand(
+            UpdateRecordsCommand,
+            uids=recordUIDs,
+            create=False,
+        )
+
+
</ins><span class="cx">     @inlineCallbacks
</span><span class="cx">     def stats(self):
</span><span class="cx">         try:
</span><span class="lines">@@ -476,6 +491,28 @@
</span><span class="cx">         )
</span><span class="cx"> 
</span><span class="cx"> 
</span><ins>+    def addMembers(self, members):
+        log.debug(&quot;DPS Client addMembers&quot;)
+        memberUIDs = [m.uid.encode(&quot;utf-8&quot;) for m in members]
+        return self.service._call(
+            AddMembersCommand,
+            lambda x: x['success'],
+            uid=self.uid.encode(&quot;utf-8&quot;),
+            memberUIDs=memberUIDs
+        )
+
+
+    def removeMembers(self, members):
+        log.debug(&quot;DPS Client removeMembers&quot;)
+        memberUIDs = [m.uid.encode(&quot;utf-8&quot;) for m in members]
+        return self.service._call(
+            RemoveMembersCommand,
+            lambda x: x['success'],
+            uid=self.uid.encode(&quot;utf-8&quot;),
+            memberUIDs=memberUIDs
+        )
+
+
</ins><span class="cx">     def setMembers(self, members):
</span><span class="cx">         log.debug(&quot;DPS Client setMembers&quot;)
</span><span class="cx">         memberUIDs = [m.uid.encode(&quot;utf-8&quot;) for m in members]
</span></span></pre></div>
<a id="CalendarServertrunktxdavdpscommandspy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/txdav/dps/commands.py (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/txdav/dps/commands.py        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/txdav/dps/commands.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -119,7 +119,7 @@
</span><span class="cx"> 
</span><span class="cx"> class UpdateRecordsCommand(amp.Command):
</span><span class="cx">     arguments = [
</span><del>-        ('items', amp.ListOf(amp.String())),
</del><ins>+        ('uids', amp.ListOf(amp.String())),
</ins><span class="cx">         ('create', amp.Boolean(optional=True)),
</span><span class="cx">     ]
</span><span class="cx">     response = [
</span><span class="lines">@@ -160,6 +160,28 @@
</span><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx"> 
</span><ins>+class AddMembersCommand(amp.Command):
+    arguments = [
+        ('uid', amp.String()),
+        ('memberUIDs', amp.ListOf(amp.String())),
+    ]
+    response = [
+        ('success', amp.Boolean()),
+    ]
+
+
+
+class RemoveMembersCommand(amp.Command):
+    arguments = [
+        ('uid', amp.String()),
+        ('memberUIDs', amp.ListOf(amp.String())),
+    ]
+    response = [
+        ('success', amp.Boolean()),
+    ]
+
+
+
</ins><span class="cx"> class SetMembersCommand(amp.Command):
</span><span class="cx">     arguments = [
</span><span class="cx">         ('uid', amp.String()),
</span></span></pre></div>
<a id="CalendarServertrunktxdavdpsserverpy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/txdav/dps/server.py (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/txdav/dps/server.py        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/txdav/dps/server.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -38,8 +38,9 @@
</span><span class="cx">     MembersCommand, GroupsCommand, SetMembersCommand,
</span><span class="cx">     VerifyPlaintextPasswordCommand, VerifyHTTPDigestCommand,
</span><span class="cx">     WikiAccessForUIDCommand, ContinuationCommand,
</span><del>-    ExternalDelegatesCommand, StatsCommand, ExpandedMemberUIDsCommand
-    # UpdateRecordsCommand, RemoveRecordsCommand
</del><ins>+    ExternalDelegatesCommand, StatsCommand, ExpandedMemberUIDsCommand,
+    AddMembersCommand, RemoveMembersCommand,
+    UpdateRecordsCommand, # RemoveRecordsCommand,
</ins><span class="cx"> )
</span><span class="cx"> from txdav.who.cache import CachingDirectoryService
</span><span class="cx"> from txdav.who.util import directoryFromConfig
</span><span class="lines">@@ -396,6 +397,66 @@
</span><span class="cx">         returnValue(response)
</span><span class="cx"> 
</span><span class="cx"> 
</span><ins>+    @AddMembersCommand.responder
+    @inlineCallbacks
+    def addMembers(self, uid, memberUIDs):
+        uid = uid.decode(&quot;utf-8&quot;)
+        memberUIDs = [m.decode(&quot;utf-8&quot;) for m in memberUIDs]
+        log.debug(&quot;Add Members: {u} -&gt; {m}&quot;, u=uid, m=memberUIDs)
+        try:
+            record = (yield self._directory.recordWithUID(uid))
+        except Exception as e:
+            log.error(&quot;Failed in addMembers&quot;, error=e)
+            record = None
+
+        if record is not None:
+            memberRecords = []
+            for memberUID in memberUIDs:
+                memberRecord = yield self._directory.recordWithUID(memberUID)
+                if memberRecord is not None:
+                    memberRecords.append(memberRecord)
+            yield record.addMembers(memberRecords)
+            success = True
+        else:
+            success = False
+
+        response = {
+            &quot;success&quot;: success,
+        }
+        # log.debug(&quot;Responding with: {response}&quot;, response=response)
+        returnValue(response)
+
+
+    @RemoveMembersCommand.responder
+    @inlineCallbacks
+    def removeMembers(self, uid, memberUIDs):
+        uid = uid.decode(&quot;utf-8&quot;)
+        memberUIDs = [m.decode(&quot;utf-8&quot;) for m in memberUIDs]
+        log.debug(&quot;Remove Members: {u} -&gt; {m}&quot;, u=uid, m=memberUIDs)
+        try:
+            record = (yield self._directory.recordWithUID(uid))
+        except Exception as e:
+            log.error(&quot;Failed in removeMembers&quot;, error=e)
+            record = None
+
+        if record is not None:
+            memberRecords = []
+            for memberUID in memberUIDs:
+                memberRecord = yield self._directory.recordWithUID(memberUID)
+                if memberRecord is not None:
+                    memberRecords.append(memberRecord)
+            yield record.removeMembers(memberRecords)
+            success = True
+        else:
+            success = False
+
+        response = {
+            &quot;success&quot;: success,
+        }
+        # log.debug(&quot;Responding with: {response}&quot;, response=response)
+        returnValue(response)
+
+
</ins><span class="cx">     @SetMembersCommand.responder
</span><span class="cx">     @inlineCallbacks
</span><span class="cx">     def setMembers(self, uid, memberUIDs):
</span><span class="lines">@@ -426,6 +487,31 @@
</span><span class="cx">         returnValue(response)
</span><span class="cx"> 
</span><span class="cx"> 
</span><ins>+    @UpdateRecordsCommand.responder
+    @inlineCallbacks
+    def updateRecords(self, uids, create):
+        # FIXME: does not support creation right now
+        if not create:
+            recordUIDs = [m.decode(&quot;utf-8&quot;) for m in uids]
+            log.debug(&quot;Update Records: {u} create:{c}&quot;, u=recordUIDs, c=create)
+            records = []
+            for uid in recordUIDs:
+                record = yield self._directory.recordWithUID(uid)
+                if record is not None:
+                    records.append(record)
+            yield self._directory.updateRecords(records, create=False)
+            success = True
+        else:
+            log.debug(&quot;Update Records - cannot create&quot;)
+            success = False
+
+        response = {
+            &quot;success&quot;: success,
+        }
+        # log.debug(&quot;Responding with: {response}&quot;, response=response)
+        returnValue(response)
+
+
</ins><span class="cx">     @GroupsCommand.responder
</span><span class="cx">     @inlineCallbacks
</span><span class="cx">     def groups(self, uid):
</span></span></pre></div>
<a id="CalendarServertrunktxdavdpstesttest_clientpy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/txdav/dps/test/test_client.py (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/txdav/dps/test/test_client.py        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/txdav/dps/test/test_client.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -81,8 +81,11 @@
</span><span class="cx"> 
</span><span class="cx">         # The &quot;remote&quot; directory service
</span><span class="cx">         if testMode == &quot;xml&quot;:
</span><del>-            path = os.path.join(os.path.dirname(__file__), &quot;test.xml&quot;)
-            remoteDirectory = CalendarXMLDirectoryService(FilePath(path))
</del><ins>+            # Need a copy as it might change
+            path = FilePath(os.path.join(os.path.dirname(__file__), &quot;test.xml&quot;))
+            copy = FilePath(self.mktemp())
+            path.copyTo(copy)
+            remoteDirectory = CalendarXMLDirectoryService(copy)
</ins><span class="cx">         elif testMode == &quot;od&quot;:
</span><span class="cx">             remoteDirectory = CalendarODDirectoryService()
</span><span class="cx"> 
</span><span class="lines">@@ -95,16 +98,16 @@
</span><span class="cx">         # actual networking
</span><span class="cx">         self.patch(self.directory, &quot;_getConnection&quot;, lambda: succeed(client))
</span><span class="cx"> 
</span><del>-        # Wrap the normal _call method with one that flushes the IOPump
</del><ins>+        # Wrap the normal _sendCommand method with one that flushes the IOPump
</ins><span class="cx">         # afterwards
</span><del>-        origCall = self.directory._call
</del><ins>+        origCall = self.directory._sendCommand
</ins><span class="cx"> 
</span><span class="cx">         def newCall(*args, **kwds):
</span><span class="cx">             d = origCall(*args, **kwds)
</span><span class="cx">             pump.flush()
</span><span class="cx">             return d
</span><span class="cx"> 
</span><del>-        self.patch(self.directory, &quot;_call&quot;, newCall)
</del><ins>+        self.patch(self.directory, &quot;_sendCommand&quot;, newCall)
</ins><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx">     @inlineCallbacks
</span><span class="lines">@@ -286,6 +289,53 @@
</span><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx">     @inlineCallbacks
</span><ins>+    def test_group_changes(self):
+        group = yield self.directory.recordWithUID(u&quot;__twisted__&quot;)
+        members = yield group.members()
+        self.assertEquals(len(members), 5)
+
+        # Add new member
+        group = yield self.directory.recordWithUID(u&quot;__twisted__&quot;)
+        user = yield self.directory.recordWithUID(u&quot;__cdaboo__&quot;)
+        yield group.addMembers((user,))
+        yield self.directory.updateRecords((group,), False)
+
+        group = yield self.directory.recordWithUID(u&quot;__twisted__&quot;)
+        members = yield group.members()
+        self.assertEquals(len(members), 6)
+
+        # Add existing member
+        group = yield self.directory.recordWithUID(u&quot;__twisted__&quot;)
+        user = yield self.directory.recordWithUID(u&quot;__wsanchez__&quot;)
+        yield group.addMembers((user,))
+        yield self.directory.updateRecords((group,), False)
+
+        group = yield self.directory.recordWithUID(u&quot;__twisted__&quot;)
+        members = yield group.members()
+        self.assertEquals(len(members), 6)
+
+        # Remove existing member
+        group = yield self.directory.recordWithUID(u&quot;__twisted__&quot;)
+        user = yield self.directory.recordWithUID(u&quot;__cdaboo__&quot;)
+        yield group.removeMembers((user,))
+        yield self.directory.updateRecords((group,), False)
+
+        group = yield self.directory.recordWithUID(u&quot;__twisted__&quot;)
+        members = yield group.members()
+        self.assertEquals(len(members), 5)
+
+        # Remove missing member
+        group = yield self.directory.recordWithUID(u&quot;__twisted__&quot;)
+        user = yield self.directory.recordWithUID(u&quot;__cdaboo__&quot;)
+        yield group.removeMembers((user,))
+        yield self.directory.updateRecords((group,), False)
+
+        group = yield self.directory.recordWithUID(u&quot;__twisted__&quot;)
+        members = yield group.members()
+        self.assertEquals(len(members), 5)
+
+
+    @inlineCallbacks
</ins><span class="cx">     def test_verifyPlaintextPassword(self):
</span><span class="cx">         expectations = (
</span><span class="cx">             (testPassword, True),  # Correct
</span><span class="lines">@@ -369,16 +419,16 @@
</span><span class="cx">         # actual networking
</span><span class="cx">         self.patch(self.client, &quot;_getConnection&quot;, lambda: succeed(client))
</span><span class="cx"> 
</span><del>-        # Wrap the normal _call method with one that flushes the IOPump
</del><ins>+        # Wrap the normal _sendCommand method with one that flushes the IOPump
</ins><span class="cx">         # afterwards
</span><del>-        origCall = self.client._call
</del><ins>+        origCall = self.client._sendCommand
</ins><span class="cx"> 
</span><span class="cx">         def newCall(*args, **kwds):
</span><span class="cx">             d = origCall(*args, **kwds)
</span><span class="cx">             pump.flush()
</span><span class="cx">             return d
</span><span class="cx"> 
</span><del>-        self.patch(self.client, &quot;_call&quot;, newCall)
</del><ins>+        self.patch(self.client, &quot;_sendCommand&quot;, newCall)
</ins><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx">     def configure(self):
</span><span class="lines">@@ -620,6 +670,53 @@
</span><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx">     @inlineCallbacks
</span><ins>+    def test_group_changes(self):
+        group = yield self.directory.recordWithUID(u&quot;__top_group_1__&quot;)
+        members = yield group.members()
+        self.assertEquals(len(members), 3)
+
+        # Add new member
+        group = yield self.directory.recordWithUID(u&quot;__top_group_1__&quot;)
+        user = yield self.directory.recordWithUID(u&quot;__cdaboo1__&quot;)
+        yield group.addMembers((user,))
+        yield self.directory.updateRecords((group,), False)
+
+        group = yield self.directory.recordWithUID(u&quot;__top_group_1__&quot;)
+        members = yield group.members()
+        self.assertEquals(len(members), 4)
+
+        # Add existing member
+        group = yield self.directory.recordWithUID(u&quot;__top_group_1__&quot;)
+        user = yield self.directory.recordWithUID(u&quot;__wsanchez1__&quot;)
+        yield group.addMembers((user,))
+        yield self.directory.updateRecords((group,), False)
+
+        group = yield self.directory.recordWithUID(u&quot;__top_group_1__&quot;)
+        members = yield group.members()
+        self.assertEquals(len(members), 4)
+
+        # Remove existing member
+        group = yield self.directory.recordWithUID(u&quot;__top_group_1__&quot;)
+        user = yield self.directory.recordWithUID(u&quot;__cdaboo1__&quot;)
+        yield group.removeMembers((user,))
+        yield self.directory.updateRecords((group,), False)
+
+        group = yield self.directory.recordWithUID(u&quot;__top_group_1__&quot;)
+        members = yield group.members()
+        self.assertEquals(len(members), 3)
+
+        # Remove missing member
+        group = yield self.directory.recordWithUID(u&quot;__top_group_1__&quot;)
+        user = yield self.directory.recordWithUID(u&quot;__cdaboo1__&quot;)
+        yield group.removeMembers((user,))
+        yield self.directory.updateRecords((group,), False)
+
+        group = yield self.directory.recordWithUID(u&quot;__top_group_1__&quot;)
+        members = yield group.members()
+        self.assertEquals(len(members), 3)
+
+
+    @inlineCallbacks
</ins><span class="cx">     def test_verifyPlaintextPassword(self):
</span><span class="cx">         expectations = (
</span><span class="cx">             (u&quot;zehcnasw&quot;, True),  # Correct
</span></span></pre></div>
<a id="CalendarServertrunktxdavwhoaugmentpy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/txdav/who/augment.py (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/txdav/who/augment.py        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/txdav/who/augment.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -294,7 +294,8 @@
</span><span class="cx">             # Split out the base fields from the augment fields
</span><span class="cx">             baseFields, augmentFields = self._splitFields(record)
</span><span class="cx"> 
</span><del>-            if augmentFields:
</del><ins>+            # Ignore groups for now
+            if augmentFields and record.recordType != RecordType.group:
</ins><span class="cx">                 # Create an AugmentRecord
</span><span class="cx">                 autoScheduleMode = {
</span><span class="cx">                     AutoScheduleMode.none: &quot;none&quot;,
</span><span class="lines">@@ -324,7 +325,7 @@
</span><span class="cx">                 augmentRecords.append(augmentRecord)
</span><span class="cx"> 
</span><span class="cx">             # Create new base records:
</span><del>-            baseRecords.append(DirectoryRecord(self._directory, baseFields))
</del><ins>+            baseRecords.append(DirectoryRecord(self._directory, record._baseRecord.fields if hasattr(record, &quot;_baseRecord&quot;) else baseFields))
</ins><span class="cx"> 
</span><span class="cx">         # Apply the augment records
</span><span class="cx">         if augmentRecords:
</span><span class="lines">@@ -494,6 +495,18 @@
</span><span class="cx">         returnValue(augmented)
</span><span class="cx"> 
</span><span class="cx"> 
</span><ins>+    def addMembers(self, memberRecords):
+        return self._baseRecord.addMembers(memberRecords)
+
+
+    def removeMembers(self, memberRecords):
+        return self._baseRecord.removeMembers(memberRecords)
+
+
+    def setMembers(self, memberRecords):
+        return self._baseRecord.setMembers(memberRecords)
+
+
</ins><span class="cx">     @timed
</span><span class="cx">     @inlineCallbacks
</span><span class="cx">     def groups(self):
</span></span></pre></div>
<a id="CalendarServertrunktxdavwhoxmlpy"></a>
<div class="modfile"><h4>Modified: CalendarServer/trunk/txdav/who/xml.py (13987 => 13988)</h4>
<pre class="diff"><span>
<span class="info">--- CalendarServer/trunk/txdav/who/xml.py        2014-09-22 15:24:13 UTC (rev 13987)
+++ CalendarServer/trunk/txdav/who/xml.py        2014-09-22 17:00:48 UTC (rev 13988)
</span><span class="lines">@@ -186,3 +186,13 @@
</span><span class="cx">     recordTypeValue = ConstantsContainer(
</span><span class="cx">         (BaseDirectoryService.recordTypeValue, RecordTypeValue)
</span><span class="cx">     )
</span><ins>+
+
+    xmlFieldOrder = BaseDirectoryService.xmlFieldOrder + (
+        FieldName.capacity,
+        FieldName.floor,
+        FieldName.associatedAddress,
+        FieldName.abbreviatedName,
+        FieldName.streetAddress,
+        FieldName.geographicLocation,
+    )
</ins></span></pre>
</div>
</div>

</body>
</html>