[CalendarServer-changes] [7985] CalendarServer/trunk

source_changes at macosforge.org source_changes at macosforge.org
Thu Aug 11 22:15:28 PDT 2011


Revision: 7985
          http://trac.macosforge.org/projects/calendarserver/changeset/7985
Author:   glyph at apple.com
Date:     2011-08-11 22:15:28 -0700 (Thu, 11 Aug 2011)
Log Message:
-----------
Change HTML rendering in iMIP message generation and web admin interface from
string interpolation to twisted.web.template templating.

Modified Paths:
--------------
    CalendarServer/trunk/calendarserver/tap/util.py
    CalendarServer/trunk/calendarserver/webadmin/resource.py
    CalendarServer/trunk/pyflakes
    CalendarServer/trunk/setup.py
    CalendarServer/trunk/twistedcaldav/mail.py
    CalendarServer/trunk/twistedcaldav/test/test_mail.py

Added Paths:
-----------
    CalendarServer/trunk/calendarserver/webadmin/template.html
    CalendarServer/trunk/calendarserver/webadmin/test/
    CalendarServer/trunk/calendarserver/webadmin/test/__init__.py
    CalendarServer/trunk/calendarserver/webadmin/test/test_resource.py

Removed Paths:
-------------
    CalendarServer/trunk/calendarserver/webadmin/test/__init__.py
    CalendarServer/trunk/calendarserver/webadmin/test/test_resource.py

Property Changed:
----------------
    CalendarServer/trunk/


Property changes on: CalendarServer/trunk
___________________________________________________________________
Modified: svn:mergeinfo
   - /CalendarServer/branches/config-separation:4379-4443
/CalendarServer/branches/egg-info-351:4589-4625
/CalendarServer/branches/generic-sqlstore:6167-6191
/CalendarServer/branches/new-store:5594-5934
/CalendarServer/branches/new-store-no-caldavfile:5911-5935
/CalendarServer/branches/new-store-no-caldavfile-2:5936-5981
/CalendarServer/branches/users/cdaboo/batchupload-6699:6700-7198
/CalendarServer/branches/users/cdaboo/cached-subscription-calendars-5692:5693-5702
/CalendarServer/branches/users/cdaboo/directory-cache-on-demand-3627:3628-3644
/CalendarServer/branches/users/cdaboo/more-sharing-5591:5592-5601
/CalendarServer/branches/users/cdaboo/partition-4464:4465-4957
/CalendarServer/branches/users/cdaboo/pods:7297-7377
/CalendarServer/branches/users/cdaboo/pycalendar:7085-7206
/CalendarServer/branches/users/cdaboo/pycard:7227-7237
/CalendarServer/branches/users/cdaboo/relative-config-paths-5070:5071-5105
/CalendarServer/branches/users/cdaboo/shared-calendars-5187:5188-5440
/CalendarServer/branches/users/cdaboo/timezones:7443-7699
/CalendarServer/branches/users/glyph/conn-limit:6574-6577
/CalendarServer/branches/users/glyph/contacts-server-merge:4971-5080
/CalendarServer/branches/users/glyph/dalify:6932-7023
/CalendarServer/branches/users/glyph/db-reconnect:6824-6876
/CalendarServer/branches/users/glyph/deploybuild:7563-7572
/CalendarServer/branches/users/glyph/disable-quota:7718-7727
/CalendarServer/branches/users/glyph/dont-start-postgres:6592-6614
/CalendarServer/branches/users/glyph/linux-tests:6893-6900
/CalendarServer/branches/users/glyph/misc-portability-fixes:7365-7374
/CalendarServer/branches/users/glyph/more-deferreds-6:6322-6368
/CalendarServer/branches/users/glyph/more-deferreds-7:6369-6445
/CalendarServer/branches/users/glyph/new-export:7444-7485
/CalendarServer/branches/users/glyph/oracle:7106-7155
/CalendarServer/branches/users/glyph/oracle-nulls:7340-7351
/CalendarServer/branches/users/glyph/quota:7604-7637
/CalendarServer/branches/users/glyph/sendfdport:5388-5424
/CalendarServer/branches/users/glyph/sharedpool:6490-6550
/CalendarServer/branches/users/glyph/sql-store:5929-6073
/CalendarServer/branches/users/glyph/subtransactions:7248-7258
/CalendarServer/branches/users/glyph/uidexport:7673-7676
/CalendarServer/branches/users/glyph/use-system-twisted:5084-5149
/CalendarServer/branches/users/glyph/xattrs-from-files:7757-7769
/CalendarServer/branches/users/sagen/inboxitems:7380-7381
/CalendarServer/branches/users/sagen/locations-resources:5032-5051
/CalendarServer/branches/users/sagen/locations-resources-2:5052-5061
/CalendarServer/branches/users/sagen/purge_old_events:6735-6746
/CalendarServer/branches/users/sagen/resource-delegates-4038:4040-4067
/CalendarServer/branches/users/sagen/resource-delegates-4066:4068-4075
/CalendarServer/branches/users/sagen/resources-2:5084-5093
/CalendarServer/branches/users/wsanchez/transations:5515-5593
   + /CalendarServer/branches/config-separation:4379-4443
/CalendarServer/branches/egg-info-351:4589-4625
/CalendarServer/branches/generic-sqlstore:6167-6191
/CalendarServer/branches/new-store:5594-5934
/CalendarServer/branches/new-store-no-caldavfile:5911-5935
/CalendarServer/branches/new-store-no-caldavfile-2:5936-5981
/CalendarServer/branches/users/cdaboo/batchupload-6699:6700-7198
/CalendarServer/branches/users/cdaboo/cached-subscription-calendars-5692:5693-5702
/CalendarServer/branches/users/cdaboo/directory-cache-on-demand-3627:3628-3644
/CalendarServer/branches/users/cdaboo/more-sharing-5591:5592-5601
/CalendarServer/branches/users/cdaboo/partition-4464:4465-4957
/CalendarServer/branches/users/cdaboo/pods:7297-7377
/CalendarServer/branches/users/cdaboo/pycalendar:7085-7206
/CalendarServer/branches/users/cdaboo/pycard:7227-7237
/CalendarServer/branches/users/cdaboo/relative-config-paths-5070:5071-5105
/CalendarServer/branches/users/cdaboo/shared-calendars-5187:5188-5440
/CalendarServer/branches/users/cdaboo/timezones:7443-7699
/CalendarServer/branches/users/glyph/conn-limit:6574-6577
/CalendarServer/branches/users/glyph/contacts-server-merge:4971-5080
/CalendarServer/branches/users/glyph/dalify:6932-7023
/CalendarServer/branches/users/glyph/db-reconnect:6824-6876
/CalendarServer/branches/users/glyph/deploybuild:7563-7572
/CalendarServer/branches/users/glyph/disable-quota:7718-7727
/CalendarServer/branches/users/glyph/dont-start-postgres:6592-6614
/CalendarServer/branches/users/glyph/imip-and-admin-html:7866-7984
/CalendarServer/branches/users/glyph/linux-tests:6893-6900
/CalendarServer/branches/users/glyph/misc-portability-fixes:7365-7374
/CalendarServer/branches/users/glyph/more-deferreds-6:6322-6368
/CalendarServer/branches/users/glyph/more-deferreds-7:6369-6445
/CalendarServer/branches/users/glyph/new-export:7444-7485
/CalendarServer/branches/users/glyph/oracle:7106-7155
/CalendarServer/branches/users/glyph/oracle-nulls:7340-7351
/CalendarServer/branches/users/glyph/quota:7604-7637
/CalendarServer/branches/users/glyph/sendfdport:5388-5424
/CalendarServer/branches/users/glyph/sharedpool:6490-6550
/CalendarServer/branches/users/glyph/sql-store:5929-6073
/CalendarServer/branches/users/glyph/subtransactions:7248-7258
/CalendarServer/branches/users/glyph/uidexport:7673-7676
/CalendarServer/branches/users/glyph/use-system-twisted:5084-5149
/CalendarServer/branches/users/glyph/xattrs-from-files:7757-7769
/CalendarServer/branches/users/sagen/inboxitems:7380-7381
/CalendarServer/branches/users/sagen/locations-resources:5032-5051
/CalendarServer/branches/users/sagen/locations-resources-2:5052-5061
/CalendarServer/branches/users/sagen/purge_old_events:6735-6746
/CalendarServer/branches/users/sagen/resource-delegates-4038:4040-4067
/CalendarServer/branches/users/sagen/resource-delegates-4066:4068-4075
/CalendarServer/branches/users/sagen/resources-2:5084-5093
/CalendarServer/branches/users/wsanchez/transations:5515-5593

Modified: CalendarServer/trunk/calendarserver/tap/util.py
===================================================================
--- CalendarServer/trunk/calendarserver/tap/util.py	2011-08-12 04:56:02 UTC (rev 7984)
+++ CalendarServer/trunk/calendarserver/tap/util.py	2011-08-12 05:15:28 UTC (rev 7985)
@@ -675,10 +675,11 @@
 
 class FakeRequest(object):
 
-    def __init__(self, rootResource, method, path):
+    def __init__(self, rootResource, method, path, uri='/'):
         self.rootResource = rootResource
         self.method = method
         self.path = path
+        self.uri = uri
         self._resourcesByURL = {}
         self._urlsByResource = {}
         self.headers = Headers()

Modified: CalendarServer/trunk/calendarserver/webadmin/resource.py
===================================================================
--- CalendarServer/trunk/calendarserver/webadmin/resource.py	2011-08-12 04:56:02 UTC (rev 7984)
+++ CalendarServer/trunk/calendarserver/webadmin/resource.py	2011-08-12 05:15:28 UTC (rev 7985)
@@ -1,3 +1,4 @@
+# -*- test-case-name: calendarserver.webadmin.test.test_resource -*-
 ##
 # Copyright (c) 2009-2010 Apple Inc. All rights reserved.
 #
@@ -20,25 +21,474 @@
 
 __all__ = [
     "WebAdminResource",
+    "WebAdminPage",
 ]
 
 import cgi
 import operator
 import urlparse
 
-from calendarserver.tools.principals import principalForPrincipalID, proxySubprincipal, action_addProxyPrincipal, action_removeProxyPrincipal
+from calendarserver.tools.principals import (
+    principalForPrincipalID, proxySubprincipal, action_addProxyPrincipal,
+    action_removeProxyPrincipal
+)
 
 from twistedcaldav.config import config
 from twistedcaldav.extensions import DAVFile, ReadOnlyResourceMixIn
 
-from twisted.internet.defer import inlineCallbacks, returnValue
+from twisted.internet.defer import inlineCallbacks, returnValue, succeed
 from twext.web2.http import Response
+from twisted.python.modules import getModule
 from twext.web2.http_headers import MimeType
+from zope.interface.declarations import implements
 from twext.web2.stream import MemoryStream
+from twext.web2.http import HTTPError
 from twext.web2.dav import davxml
 
+from twisted.web.iweb import ITemplateLoader
+from twisted.web.template import (
+    Element, renderer, XMLFile, flattenString
+)
 
+
+
+class WebAdminPage(Element):
+    """
+    Web administration renderer for HTML.
+
+    @ivar resource: a L{WebAdminResource}.
+    """
+
+    loader = XMLFile(
+        getModule(__name__).filePath.sibling("template.html").open()
+    )
+
+    def __init__(self, resource):
+        super(WebAdminPage, self).__init__()
+        self.resource = resource
+
+
+    @renderer
+    def main(self, request, tag):
+        """
+        Main renderer, which fills page-global slots like 'title'.
+        """
+        searchTerm = request.args.get('resourceSearch', [''])[0]
+        return tag.fillSlots(resourceSearch=searchTerm)
+
+
+    @renderer
+    @inlineCallbacks
+    def hasSearchResults(self, request, tag):
+        """
+        Renderer which detects if there are resource search results and
+        continues if so.
+        """
+        if 'resourceSearch' not in request.args:
+            returnValue('')
+        if (yield self.performSearch(request)):
+            returnValue(tag)
+        else:
+            returnValue('')
+
+
+    @renderer
+    @inlineCallbacks
+    def noSearchResults(self, request, tag):
+        """
+        Renderer which detects if there are resource search results and
+        continues if so.
+        """
+        if 'resourceSearch' not in request.args:
+            returnValue('')
+        rows = yield self.performSearch(request)
+        if rows:
+            returnValue("")
+        else:
+            returnValue(tag)
+
+
+    _searchResults = None
+
+    @inlineCallbacks
+    def performSearch(self, request):
+        """
+        Perform a directory search for users, groups, and resources based on the
+        resourceSearch query parameter.  Cache the results of that search so
+        that it will only be done once per request.
+        """
+        if self._searchResults is not None:
+            returnValue(self._searchResults)
+        searchTerm = request.args.get('resourceSearch', [''])[0]
+        if searchTerm:
+            results = sorted((yield self.resource.search(searchTerm)),
+                             key=lambda record: record.shortNames[0])
+        else:
+            results = []
+        self._searchResults = results
+        returnValue(results)
+
+
+    @renderer
+    def searchResults(self, request, tag):
+        """
+        Renderer which renders resource search results.
+        """
+        d = self.performSearch(request)
+        return d.addCallback(searchToSlots, tag)
+
+
+    @renderer
+    def resourceDetails(self, request, tag):
+        """
+        Renderer which fills slots for details of the resource selected by
+        the resourceId request parameter.
+        """
+        resourceId = request.args.get('resourceId', [''])[0]
+        propertyName = request.args.get('davPropertyName', [''])[0]
+        proxySearch = request.args.get('proxySearch', [''])[0]
+        if resourceId:
+            principalResource = self.resource.getResourceById(
+                request, resourceId)
+            return DetailsElement(
+                resourceId, principalResource, propertyName, proxySearch, tag,
+                self.resource
+            )
+        else:
+            return ""
+
+
+def searchToSlots(results, tag):
+    """
+    Convert the result of doing a search to an iterable of tags.
+    """
+    for idx, record in enumerate(results):
+        yield tag.clone().fillSlots(
+            rowClass="even" if (idx % 2 == 0) else "odd",
+            type=record.recordType,
+            shortName=record.shortNames[0],
+            name=record.fullName,
+            typeStr={
+                "users"     : "User",
+                "groups"    : "Group",
+                "locations" : "Place",
+                "resources" : "Resource",
+            }.get(record.recordType, repr(record.recordType)),
+            shortNames=str(", ".join(record.shortNames)),
+            authIds=str(", ".join(record.authIDs)),
+            emails=str(", ".join(record.emailAddresses)),
+        )
+
+
+
+class stan(object):
+    """
+    L{ITemplateLoader} wrapper for an existing tag, in the style of Nevow's
+    'stan' loader.
+    """
+    implements(ITemplateLoader)
+
+    def __init__(self, tag):
+        self.tag = tag
+
+
+    def load(self):
+        return self.tag
+
+
+
+class DetailsElement(Element):
+
+    def __init__(self, resourceId, principalResource, davPropertyName,
+                 proxySearch, tag, adminResource):
+        self.principalResource = principalResource
+        self.adminResource = adminResource
+        self.proxySearch = proxySearch
+        tag.fillSlots(resourceTitle=unicode(principalResource),
+                      resourceId=resourceId,
+                      davPropertyName=davPropertyName,
+                      proxySearch=proxySearch)
+        try:
+            namespace, name = davPropertyName.split("#")
+        except Exception:
+            self.namespace = None
+            self.name = None
+            if davPropertyName:
+                self.error = davPropertyName
+            else:
+                self.error = None
+        else:
+            self.namespace = namespace
+            self.name = name
+            self.error = None
+
+        super(DetailsElement, self).__init__(loader=stan(tag))
+
+
+    @renderer
+    def propertyParseError(self, request, tag):
+        """
+        Renderer to display an error when the user specifies an invalid property
+        name.
+        """
+        if self.error is None:
+            return ""
+        else:
+            return tag.fillSlots(davPropertyName=self.error)
+
+
+    @renderer
+    @inlineCallbacks
+    def davProperty(self, request, tag):
+        """
+        Renderer to display an error when the user specifies an invalid property
+        name.
+        """
+        if self.name is not None:
+            try:
+                propval = yield self.principalResource.readProperty(
+                    (self.namespace, self.name), request
+                )
+            except HTTPError:
+                propval = "No such property: " + "#".join([self.namespace,
+                                                           self.name])
+            else:
+                propval = propval.toxml()
+            returnValue(tag.fillSlots(value=propval))
+        else:
+            returnValue("")
+
+
+    @renderer
+    def autoSchedule(self, request, tag):
+        """
+        Renderer which elides its tag for non-resource-type principals.
+        """
+        if (self.principalResource.record.recordType != "users" and
+            self.principalResource.record.recordType != "groups"):
+            return tag
+        return ""
+
+
+    @renderer
+    def isAutoSchedule(self, request, tag):
+        """
+        Renderer which sets the 'selected' attribute on its tag if the resource
+        is auto-schedule.
+        """
+        if self.principalResource.getAutoSchedule():
+            tag(selected='selected')
+        return tag
+
+
+    @renderer
+    def isntAutoSchedule(self, request, tag):
+        """
+        Renderer which sets the 'selected' attribute on its tag if the resource
+        is not auto-schedule.
+        """
+        if not self.principalResource.getAutoSchedule():
+            tag(selected='selected')
+        return tag
+
+
+    _matrix = None
+
+    @inlineCallbacks
+    def proxyMatrix(self, request):
+        """
+        Compute a matrix of proxies to display in a 2-column table.
+
+        This value is cached so that multiple renderers may refer to it without
+        causing additional back-end queries.
+
+        @return: a L{Deferred} which fires with a list of 2-tuples of
+            (readProxy, writeProxy).  If there is an unequal number of read and
+            write proxies, the tables will be padded out with C{None}s so that
+            some readProxy or writeProxy values will be C{None} at the end of
+            the table.
+        """
+        if self._matrix is not None:
+            returnValue(self._matrix)
+        (readSubPrincipal, writeSubPrincipal) = (
+            proxySubprincipal(self.principalResource, "read"),
+            proxySubprincipal(self.principalResource, "write")
+        )
+        if readSubPrincipal or writeSubPrincipal:
+            (readMembers, writeMembers) = (
+                (yield readSubPrincipal.readProperty(davxml.GroupMemberSet,
+                                                     None)),
+                (yield writeSubPrincipal.readProperty(davxml.GroupMemberSet,
+                                                      None))
+            )
+            if readMembers.children or writeMembers.children:
+                # FIXME: 'else' case needs to be handled by separate renderer
+                readProxies = []
+                writeProxies = []
+                def getres(ref):
+                    return self.adminResource.getResourceById(request,
+                                                              str(proxyHRef))
+                for proxyHRef in sorted(readMembers.children, key=str):
+                    readProxies.append((yield getres(proxyHRef)))
+                for proxyHRef in sorted(writeMembers.children, key=str):
+                    writeProxies.append((yield getres(proxyHRef)))
+                lendiff = len(readProxies) - len(writeProxies)
+                if lendiff > 0:
+                    writeProxies += [None] * lendiff
+                elif lendiff < 0:
+                    readProxies += [None] * -lendiff
+                self._matrix = zip(readProxies, writeProxies)
+            else:
+                self._matrix = []
+        else:
+            self._matrix = []
+        returnValue(self._matrix)
+
+
+    @renderer
+    @inlineCallbacks
+    def noProxies(self, request, tag):
+        """
+        Renderer which shows its tag if there are no proxies for this resource.
+        """
+        mtx = yield self.proxyMatrix(request)
+        if mtx:
+            returnValue("")
+        returnValue(tag)
+
+
+    @renderer
+    @inlineCallbacks
+    def hasProxies(self, request, tag):
+        """
+        Renderer which shows its tag if there are any proxies for this resource.
+        """
+        mtx = yield self.proxyMatrix(request)
+        if mtx:
+            returnValue(tag)
+        returnValue("")
+
+
+    @renderer
+    @inlineCallbacks
+    def noProxyResults(self, request, tag):
+        """
+        Renderer which shows its tag if there are no proxy search results for
+        this request.
+        """
+        if not self.proxySearch:
+            returnValue("")
+        results = yield self.performProxySearch()
+        if results:
+            returnValue("")
+        else:
+            returnValue(tag)
+
+
+    @renderer
+    @inlineCallbacks
+    def hasProxyResults(self, request, tag):
+        """
+        Renderer which shows its tag if there are any proxy search results for
+        this request.
+        """
+        results = yield self.performProxySearch()
+        if results:
+            returnValue(tag)
+        else:
+            returnValue("")
+
+
+    @renderer
+    @inlineCallbacks
+    def proxyRows(self, request, tag):
+        """
+        Renderer which does zipping logic to render read-only and read-write
+        rows of existing proxies for the currently-viewed resource.
+        """
+        result = []
+        mtx = yield self.proxyMatrix(request)
+        for idx, (readProxy, writeProxy) in enumerate(mtx):
+            result.append(ProxyRow(tag.clone(), idx, readProxy, writeProxy))
+        returnValue(result)
+
+
+    _proxySearchResults = None
+
+    def performProxySearch(self):
+        if self._proxySearchResults is not None:
+            return succeed(self._proxySearchResults)
+
+        if self.proxySearch:
+            def nameSorted(records):
+                records.sort(key=operator.attrgetter('fullName'))
+                self._proxySearchResults = records
+                return records
+            return self.adminResource.search(
+                self.proxySearch).addCallback(nameSorted)
+        else:
+            return succeed([])
+
+
+    @renderer
+    def proxySearchRows(self, request, tag):
+        """
+        Renderer which renders search results for the proxy form.
+        """
+        d = self.performProxySearch()
+        return d.addCallback(searchToSlots, tag)
+
+
+
+class ProxyRow(Element):
+
+    def __init__(self, tag, index, readProxy, writeProxy):
+        tag.fillSlots(rowClass="even" if (index % 2 == 0) else "odd")
+        super(ProxyRow, self).__init__(loader=stan(tag))
+        self.readProxy = readProxy
+        self.writeProxy = writeProxy
+
+
+    def proxies(self, proxyResource, tag):
+        if proxyResource is None:
+            return ''
+        return tag.fillSlots(proxy=str(proxyResource),
+                             type=proxyResource.record.recordType,
+                             shortName=proxyResource.record.shortNames[0])
+
+
+    def noProxies(self, proxyResource, tag):
+        if proxyResource is None:
+            return tag
+        else:
+            return ""
+
+
+    @renderer
+    def readOnlyProxies(self, request, tag):
+        return self.proxies(self.readProxy, tag)
+
+
+    @renderer
+    def noReadOnlyProxies(self, request, tag):
+        return self.noProxies(self.readProxy, tag)
+
+
+    @renderer
+    def readWriteProxies(self, request, tag):
+        return self.proxies(self.writeProxy, tag)
+
+
+    @renderer
+    def noReadWriteProxies(self, request, tag):
+        return self.noProxies(self.writeProxy, tag)
+
+
+
 class WebAdminResource (ReadOnlyResourceMixIn, DAVFile):
+    """
+    Web administration HTTP resource.
+    """
 
     def __init__(self, path, root, directory, principalCollections=()):
         self.root = root
@@ -49,7 +499,7 @@
     # Only allow administrators to access
     def defaultAccessControlList(self):
         return davxml.ACL(*config.AdminACEs)
-    
+
     def etag(self):
         # Can't be calculated here
         return None
@@ -76,51 +526,15 @@
     def createSimilarFile(self, path):
         return DAVFile(path, principalCollections=self.principalCollections())
 
-    def directoryStyleSheet(self):
-        return (
-            "th, .even td, .odd td { padding-right: 0.5em; font-family: monospace}"
-            ".even-dir { background-color: #efe0ef }"
-            ".even { background-color: #eee }"
-            ".odd-dir {background-color: #f0d0ef }"
-            ".odd { background-color: #dedede }"
-            ".icon { text-align: center }"
-            ".listing {"
-              "margin-left: auto;"
-              "margin-right: auto;"
-              "width: 50%;"
-              "padding: 0.1em;"
-            "}"
-            "body { border: 0; padding: 0; margin: 0; background-color: #efefef;}"
-            "h1 {padding: 0.1em; padding-left:10px; padding-right:10px; background-color: #777; color: white; border-bottom: thin white dashed;}"
-        )
 
-    def header(self, title):
-
-        if title is None or title == "":
-            title = "Calendar Server Web Administration"
-        else:
-            title = "Calendar Server Web Administration: " + title
-        
-        return ( "<html>\n"
-                 "<head>\n"
-                 "<h1>%(title)s</h1>\n" 
-                 "<title>%(title)s</title>\n"
-                 "<style>\n  %(style)s\n</style>\n"
-                 "</head>\n"
-                 "<body>\n"
-                 "<div style=\"padding-left:10px; padding-right:10px\">\n" % { "title": title, "style": self.directoryStyleSheet() })
-
-    def footer(self) :
-        return ( "\n</div>\n"
-                 "</body>\n"
-                 "</html>" )
-              
     @inlineCallbacks
-    def htmlContent(self, directory, request):
+    def resourceActions(self, request, principal):
+        """
+        Take all actions on the given principal based on the given request.
+        """
 
         def queryValue(arg):
-            query = cgi.parse_qs(urlparse.urlparse(request.uri).query, True)
-            return query.get(arg, [""])[0]
+            return request.args.get(arg, [""])[0]
 
         def queryValues(arg):
             query = cgi.parse_qs(urlparse.urlparse(request.uri).query, True)
@@ -130,310 +544,54 @@
                     matches.append(key[len(arg):])
             return matches
 
-        # Read request parameters.
-        resourceId = queryValue("resourceId")
-        resourceSearch = queryValue("resourceSearch")
-        davPropertyName = queryValue("davPropertyName")
         autoSchedule = queryValue("autoSchedule")
-        proxySearch = queryValue("proxySearch")
         makeReadProxies = queryValues("mkReadProxy|")
         makeWriteProxies = queryValues("mkWriteProxy|")
         removeProxies = queryValues("rmProxy|")
 
-        # Begin the content
-        content = ("%(header)s\n"
-                   "<h2>Resource Management</h2>\n"
-                   "%(search)s\n" % { "header": self.header(None),
-                                     "search": (yield self.searchContent(directory, resourceSearch)) })
+        # Update the auto-schedule value if specified.
+        if autoSchedule is not None and (autoSchedule == "true" or
+                                         autoSchedule == "false"):
+            if ( principal.record.recordType != "users" and
+                 principal.record.recordType != "groups"):
+                principal.setAutoSchedule(autoSchedule == "true")
 
-        # Add details if a resource has been selected.
-        if resourceId:
-        
-            principal = self.getResourceById(request, resourceId)
-    
-            # Update the auto-schedule value if specified.
-            if autoSchedule is not None and (autoSchedule == "true" or autoSchedule == "false"):
-                if principal.record.recordType != "users" and principal.record.recordType != "groups":
-                    principal.setAutoSchedule(autoSchedule == "true")
+        # Update the proxies if specified.
+        for proxyId in removeProxies:
+            proxy = self.getResourceById(request, proxyId)
+            (yield action_removeProxyPrincipal(principal, proxy,
+                                               proxyTypes=["read", "write"]))
 
-            # Update the proxies if specified.
-            for proxyId in removeProxies:
-                proxy = self.getResourceById(request, proxyId)
-                (yield action_removeProxyPrincipal(principal, proxy, proxyTypes=["read", "write"]))
+        for proxyId in makeReadProxies:
+            proxy = self.getResourceById(request, proxyId)
+            (yield action_addProxyPrincipal(principal, "read", proxy))
 
-            for proxyId in makeReadProxies:
-                proxy = self.getResourceById(request, proxyId)
-                (yield action_addProxyPrincipal(principal, "read", proxy))
+        for proxyId in makeWriteProxies:
+            proxy = self.getResourceById(request, proxyId)
+            (yield action_addProxyPrincipal(principal, "write", proxy))
 
-            for proxyId in makeWriteProxies:
-                proxy = self.getResourceById(request, proxyId)
-                (yield action_addProxyPrincipal(principal, "write", proxy))
-                
-            # Add the detailed content
-            content += (yield self.detailContent(directory, request, principal, resourceId, davPropertyName, proxySearch))
 
-        # Add the footer
-        content += self.footer()
-
-        returnValue(content)
-        
     @inlineCallbacks
-    def searchContent(self, directory, resourceSearch):
-        
-        formHtml = ("""
-<form id=\"frm_resource\" name=\"resourceForm\" action=\"/admin/\">
-  Search for resource to manage:
-  <input type=\"text\" id=\"txt_resourceSearch\" name=\"resourceSearch\" value=\"%s\" size=\"40\" />
-  <input type=\"submit\" value=\"Search\" />
-</form>
-""" % resourceSearch)
-
-        # Perform the search if a parameter was specified.
-        resultHtml = ""
-        if resourceSearch is not None and resourceSearch != "":
-
-            records = (yield self.search(resourceSearch))
-            if records:
-                records.sort(key=operator.attrgetter('fullName'))
-                resultHtml = """
-<table cellspacing=\"0\" cellpadding=\"3\" border=\"1\" style=\"margin-top:2px\">
-  <tr class=\"odd\">
-    <th>ID</th>
-    <th>Full Name</th>
-    <th>Type</th>
-    <th>Short Names</th>
-    <th>Auth IDs</th>
-    <th>Email Addresses</th>
-  </tr>""" % { "resourceSearch": resourceSearch }
-
-                for _i in range(0, len(records)):
-                    resultHtml += """
-  <tr class=\"%(rowClass)s\">
-    <td><a href=\"/admin/?resourceId=%(type)s:%(shortName)s\">select</a></td>
-    <td>%(name)s</td>
-    <td>%(typeStr)s</td>
-    <td>%(shortNames)s</td>
-    <td>%(authIds)s</td>
-    <td>%(emails)s</td>
-  </tr>""" % { "rowClass": "even" if _i%2 == 0 else "odd",
-               "type": records[_i].recordType,
-               "shortName": records[_i].shortNames[0],
-               "name": records[_i].fullName,
-               "typeStr": { "users"     : "User",
-                         "groups"    : "Group",
-                         "locations" : "Place",
-                         "resources" : "Resource",
-                       }.get(records[_i].recordType),
-               "shortNames": str(", ".join(records[_i].shortNames),),
-               "authIds": str(", ".join(records[_i].authIDs),),
-               "emails": str(", ".join(records[_i].emailAddresses),)
-             }
-                resultHtml += "\n</table>"
-            else:
-                resultHtml += "<div style=\"margin-top:4px\">No matches found for resource <b>%(resourceSearch)s</b>.</div>\n" % { "resourceSearch": resourceSearch }
-
-        result = "%s%s" % (formHtml, resultHtml)
-        returnValue(result)
-
-    @inlineCallbacks
-    def detailContent(self, directory, request, resource, resourceId, davPropertyName, proxySearch):
-
-        ###
-        # Resource title
-        ###
-        headerHtml = """
-<div style=\"margin-top:15px; background-color: #777; border-bottom:1px #ffffff dotted\"></div>
-<div style=\"background-color: #777; padding-top:2px; border-bottom:1px #ffffff dotted\"></div>
-<h3>Resource Details: %(resourceTitle)s</h3>""" % { "resourceTitle": resource }
-
-        ###
-        # DAV properties
-        ###
-        propertyHtml = """
-<div style=\"margin-top:15px; border-bottom:1px #444444 dotted\"></div>
-<form id=\"frm_davProperty\" name=\"davPropertyForm\" action=\"/admin/\" style=\"margin-top:15px; margin-bottom:0; padding-bottom:0\">
-  Show a DAV property value:
-  <input type=\"hidden\" id=\"hdn_resourceId\" name=\"resourceId\" value=\"%(resourceId)s\" />
-  <input type=\"text\" id=\"txt_davPropertyName\" name=\"davPropertyName\" value=\"%(davPropertyName)s\" size=\"40\" />
-  <input type=\"submit\" value=\"Get Value\" />
-</form>
-""" % { "resourceId": resourceId,
-        "davPropertyName": davPropertyName if davPropertyName is not None and davPropertyName != "" else "DAV:#" }
-        
-        if davPropertyName:
-            try:
-                namespace, name = davPropertyName.split("#")
-            except Exception:
-                propertyHtml += "<div>Unable to parse property to read: <b>%s</b></div>" % davPropertyName
-
-            result = (yield resource.readProperty((namespace, name), None))
-            propertyHtml += "<div style=\"margin-top:7px\">Value of property <b>%(name)s</b>:</div><pre style=\"margin-top:5px; padding-top:0\">%(value)s</pre>" % { 
-                "name": davPropertyName, 
-                "value": cgi.escape(result.toxml())
-            }
-
-        ###
-        # Auto-schedule
-        ###
-        autoScheduleHtml = ""
-        if resource.record.recordType != "users" and resource.record.recordType != "groups":
-            autoSchedule = resource.getAutoSchedule()
-            autoScheduleHtml = """
-<div style=\"margin-top:15px; border-bottom:1px #444444 dotted\"></div>
-<form id=\"frm_autoSchedule\" name=\"autoScheduleForm\" action=\"/admin/\" style=\"margin-top:15px\">
-  <input type=\"hidden\" id=\"hdn_resourceId\" name=\"resourceId\" value=\"%(resourceId)s\" />
-  <div style=\"margin-top:7px\">
-    Auto-Schedule
-    <select id=\"sel_autoSchedule\" name=\"autoSchedule\">
-      <option value=\"true\"%(trueSelected)s>Yes</option>
-      <option value=\"false\"%(falseSelected)s>No</option>
-    </select>
-    <input type=\"submit\" value=\"Change\" />
-  </div>
-</form>\n""" % { "resourceId": resourceId,
-               "trueSelected": " selected=\"selected\"" if autoSchedule else "",
-               "falseSelected": "" if autoSchedule else " selected=\"selected\"" }
-
-        ###
-        # Current proxies
-        ###
-        currentProxiesHtml = "\n<div style=\"margin-top:15px; border-bottom:1px #444444 dotted\"></div>"
-        
-        (readSubPrincipal, writeSubPrincipal) = (proxySubprincipal(resource, "read"), proxySubprincipal(resource, "write"))
-        if readSubPrincipal or writeSubPrincipal:
-            (readMembers, writeMembers) = ((yield readSubPrincipal.readProperty(davxml.GroupMemberSet, None)), (yield writeSubPrincipal.readProperty(davxml.GroupMemberSet, None)))
-
-            if readMembers.children or writeMembers.children:
-                currentProxiesHtml += """
-<form id=\"frm_proxies\" name=\"proxiesForm\" action=\"/admin/\" style=\"margin-top:15px\">
-  <input type=\"hidden\" id=\"hdn_resourceId\" name=\"resourceId\" value=\"%(resourceId)s\" />
-  <table cellspacing=\"0\" cellpadding=\"3\" border=\"1\">
-    <tr class=\"odd\">
-      <th colspan=\"2\">Read-Only Proxies</th>
-      <th colspan=\"2\">Read-Write Proxies</th>
-    </tr>\n""" % { "resourceTitle": resource,
-                   "resourceId": resourceId }
-
-                for _i in range(0, max(len(readMembers.children), len(writeMembers.children))):
-                    currentProxiesHtml += "    <tr class=\"%(rowClass)s\">" % { "rowClass": "even" if _i%2 == 0 else "odd" }
-                    if (_i < len(readMembers.children)) :
-                        proxyResource = (yield self.getResourceById(request, str(readMembers.children[_i])))
-                        currentProxiesHtml += """
-      <td>%(proxy)s</td>
-      <td>
-        <input type=\"submit\" name=\"mkWriteProxy|%(type)s:%(shortName)s\" value=\"Make Read-Write\" />
-        <input type=\"submit\" name=\"rmProxy|%(type)s:%(shortName)s\" value=\"Remove Proxy\" />
-      </td>""" % { "proxy": proxyResource,
-                   "type": proxyResource.record.recordType,
-                   "shortName": proxyResource.record.shortNames[0]
-                 }
-                    else :
-                        currentProxiesHtml += "\n      <td colspan=\"2\"></td>"
-                    if (_i < len(writeMembers.children)) :
-                        proxyResource = (yield self.getResourceById(request, str(writeMembers.children[_i])))
-                        currentProxiesHtml += """
-      <td>%(proxy)s</td>
-      <td>
-        <input type=\"submit\" name=\"mkReadProxy|%(type)s:%(shortName)s\" value=\"Make Read-Only\" />
-        <input type=\"submit\" name=\"rmProxy|%(type)s:%(shortName)s\" value=\"Remove Proxy\" />
-      </td>""" % { "proxy": proxyResource,
-                   "type": proxyResource.record.recordType,
-                   "shortName": proxyResource.record.shortNames[0]
-                 }
-                    else :
-                        currentProxiesHtml += "\n      <td colspan=\"2\"></td>"
-                    currentProxiesHtml += "\n    </tr>\n"
-    
-                currentProxiesHtml += "  </table>\n</form>\n"
-            else:
-                currentProxiesHtml += "<div style=\"margin-top:15px\">This resource has no proxies.</div>\n"
-        else:
-            currentProxiesHtml += "<div style=\"margin-top:15px\">This resource has no proxies.</div>\n"
-
-        ###
-        # Search for new proxies
-        ###
-        proxySearchHtml = """
-<div style=\"margin-top:15px; border-bottom:1px #444444 dotted\"></div>
-<form id=\"frm_proxySearch\" name=\"proxySearchForm\" action=\"/admin/\" style=\"margin-top:15px; margin-bottom:0; padding-bottom:0\">
-  Search to add proxies:
-  <input type=\"hidden\" id=\"hdn_resourceId\" name=\"resourceId\" value=\"%(resourceId)s\" />
-  <input type=\"text\" id=\"txt_proxySearch\" name=\"proxySearch\" value=\"%(proxySearch)s\" size=\"40\" />
-  <input type=\"submit\" value=\"Search\" />
-</form>
-""" % { "resourceId": resourceId,
-        "proxySearch": proxySearch }
-
-        # Perform the search if a parameter was specified.
-        if proxySearch:
-            records = (yield self.search(proxySearch))
-            if records:
-                records.sort(key=operator.attrgetter('fullName'))
-
-                proxySearchHtml += """
-<form id=\"frm_proxyAdd\" name=\"proxyAddForm\" action=\"/admin/\" style=\"margin-top:2px; padding-top:0\">
-  <input type=\"hidden\" id=\"hdn_resourceId\" name=\"resourceId\" value=\"%(resourceId)s\" />
-  <table cellspacing=\"0\" cellpadding=\"3\" border=\"1\">
-    <tr class=\"odd\">
-      <th>Full Name</th>
-      <th>Type</th>
-      <th>Short Names</th>
-      <th>Email Addresses</th>
-      <th></th>
-    </tr>""" % { "resourceId": resourceId }
-
-                for _i in range(0, len(records)):
-                    proxySearchHtml += """
-    <tr class=\"%(rowClass)s\">
-      <td>%(name)s</td>
-      <td>%(typeStr)s</td>
-      <td>%(shortNames)s</td>
-      <td>%(emails)s</td>
-      <td>
-        <input type=\"submit\" name=\"mkReadProxy|%(type)s:%(shortName)s\" value=\"Make Read-Only Proxy\" />
-        <input type=\"submit\" name=\"mkWriteProxy|%(type)s:%(shortName)s\" value=\"Make Read-Write Proxy\" />
-      </td>
-    </tr>""" % { "rowClass": "even" if _i%2 == 0 else "odd",
-                 "type": records[_i].recordType,
-                 "shortName": records[_i].shortNames[0],
-                 "name": records[_i].fullName,
-                 "typeStr": { "users"     : "User",
-                           "groups"    : "Group",
-                           "locations" : "Place",
-                           "resources" : "Resource",
-                           }.get(records[_i].recordType),
-                 "shortNames": str(", ".join(records[_i].shortNames),),
-                 "emails": str(", ".join(records[_i].emailAddresses),)
-             }
-                proxySearchHtml += "\n  </table>\n</form>\n"
-            else:
-                proxySearchHtml += "<div style=\"margin-top:4px\">No matches found for proxy resource <b>%(proxySearch)s</b>.</div>\n" % { "proxySearch": proxySearch }
-        
-        ###
-        # Put it all together
-        ###
-        detailHtml = "%s%s%s%s%s" % (headerHtml, propertyHtml, autoScheduleHtml, currentProxiesHtml, proxySearchHtml)
-
-        returnValue(detailHtml)
-
     def render(self, request):
-
-        # The response-generation will be deferred.
-        def _defer(htmlContent):
-            response = Response()
-            response.stream = MemoryStream(str(htmlContent))
-            for (header, value) in (
+        """
+        Create a L{WebAdminPage} to render HTML content for this request, and
+        return a response.
+        """
+        resourceId = request.args.get('resourceId', [''])[0]
+        if resourceId:
+            principal = self.getResourceById(request, resourceId)
+            yield self.resourceActions(request, principal)
+        htmlContent = yield flattenString(request, WebAdminPage(self))
+        response = Response()
+        response.stream = MemoryStream(htmlContent)
+        for (header, value) in (
                 ("content-type", self.contentType()),
                 ("content-encoding", self.contentEncoding()),
             ):
-                if value is not None:
-                    response.headers.setHeader(header, value)
-            return response
+            if value is not None:
+                response.headers.setHeader(header, value)
+        returnValue(response)
 
-        # Generate the HTML and return the response when it's ready.
-        htmlContent = self.htmlContent(self.directory, request)
-        htmlContent.addCallback(_defer)
-        return htmlContent
 
     def getResourceById(self, request, resourceId):
         if resourceId.startswith("/"):
@@ -441,11 +599,13 @@
         else:
             return principalForPrincipalID(resourceId, directory=self.directory)
 
+
     @inlineCallbacks
     def search(self, searchStr):
         fields = []
-        for fieldName in ("fullName", "firstName", "lastName", "emailAddresses"):
+        for fieldName in ("fullName", "firstName", "lastName",
+                          "emailAddresses"):
             fields.append((fieldName, searchStr, True, "contains"))
-        
+
         records = list((yield self.directory.recordsMatchingFields(fields)))
         returnValue(records)

Copied: CalendarServer/trunk/calendarserver/webadmin/template.html (from rev 7984, CalendarServer/branches/users/glyph/imip-and-admin-html/calendarserver/webadmin/template.html)
===================================================================
--- CalendarServer/trunk/calendarserver/webadmin/template.html	                        (rev 0)
+++ CalendarServer/trunk/calendarserver/webadmin/template.html	2011-08-12 05:15:28 UTC (rev 7985)
@@ -0,0 +1,235 @@
+<html xmlns:t="http://twistedmatrix.com/ns/twisted.web.template/0.1"
+      t:render="main">
+  <head>
+    <style>
+      th, .even td, .odd td {
+        padding-right: 0.5em;
+        font-family: monospace;
+      }
+      .even-dir {
+        background-color: #efe0ef;
+      }
+      .even {
+        background-color: #eee;
+      }
+      .odd-dir {
+        background-color: #f0d0ef;
+      }
+      .odd {
+        background-color: #dedede;
+      }
+      .icon {
+        text-align: center;
+      }
+      .listing {
+        margin-left: auto;
+        margin-right: auto;
+        width: 50%;
+        padding: 0.1em;
+      }
+      .content {
+        padding-left: 10px;
+        padding-right: 10px;
+      }
+      body {
+        border: 0;
+        padding: 0;
+        margin: 0;
+        background-color: #efefef;
+      }
+      h1 {
+        padding: 0.1em;
+        padding-left:10px;
+        padding-right:10px;
+        background-color: #777;
+        color: white;
+        border-bottom: thin white dashed;
+      }
+    </style>
+    <title>Calendar Server Web Administration</title>
+  </head>
+  <body>
+    <h1>Calendar Server Web Administration</h1>
+    <div class="content">
+      <h2>Resource Management</h2>
+      <form id="frm_resource" name="resourceForm">
+        Search for resource to manage:
+        <input type="text" id="txt_resourceSearch" name="resourceSearch"
+        size="40"><t:attr name="value"><t:slot name="resourceSearch"
+        /></t:attr></input>
+        <input type="submit" value="Search" />
+      </form>
+      <div t:render="noSearchResults" style="margin-top:4px"
+        >No matches found for resource <b><t:slot name="resourceSearch" /></b>.
+      </div>
+      <table id="tab_searchResults" t:render="hasSearchResults" cellspacing="0"
+             cellpadding="3" border="1" style="margin-top: 2px">
+        <tr class="odd">
+          <th>ID</th>
+          <th>Full Name</th>
+          <th>Type</th>
+          <th>Short Names</th>
+          <th>Auth IDs</th>
+          <th>Email Addresses</th>
+        </tr>
+        <tr t:render="searchResults">
+          <t:attr name="class"><t:slot name="rowClass" /></t:attr>
+          <td><a>select<t:attr name="href" >/admin/?resourceId=<t:slot
+              name="type" />:<t:slot name="shortName" /></t:attr></a></td>
+          <td><t:slot name="name" /></td>
+          <td><t:slot name="typeStr" /></td>
+          <td><t:slot name="shortNames" /></td>
+          <td><t:slot name="authIds" /></td>
+          <td><t:slot name="emails" /></td>
+        </tr>
+      </table>
+      <div style="margin-top:15px; background-color: #777;
+                  border-bottom:1px #ffffff dotted"></div>
+      <div style="background-color: #777; padding-top:2px;
+                  border-bottom:1px #ffffff dotted"></div>
+
+      <t:transparent t:render="resourceDetails">
+      <h3>Resource Details: <t:slot name="resourceTitle" /></h3>
+
+      <!-- propertyHTML -->
+      <div style="margin-top:15px; border-bottom:1px #444444 dotted"></div>
+      <form id="frm_davProperty" name="davPropertyForm" action="/admin/"
+        style="margin-top:15px; margin-bottom:0; padding-bottom:0">
+        Show a DAV property value:
+        <input type="hidden" id="hdn_resourceId" name="resourceId"><t:attr
+        name="value"><t:slot name="resourceId" /></t:attr></input>
+        <input type="text" id="txt_davPropertyName" name="davPropertyName"
+        size="40"><t:attr name="value"><t:slot name="davPropertyName"
+        /></t:attr></input>
+        <input type="submit" value="Get Value" />
+      </form>
+
+      <div t:render="propertyParseError">
+      Unable to parse property to read: <b><t:slot name="davPropertyName" /></b>
+      </div>
+
+      <t:transparent t:render="davProperty">
+      <div style="margin-top: 7px">Value of property
+        <b><t:slot name="davPropertyName" /></b>:</div>
+      <pre style="margin-top:5px; padding-top:0"><t:slot name="value" /></pre>
+      </t:transparent>
+
+      <!-- autoScheduleHtml -->
+
+      <t:transparent t:render="autoSchedule">
+      <div style="margin-top:15px; border-bottom:1px #444444 dotted"></div>
+      <form id="frm_autoSchedule" name="autoScheduleForm" action="/admin/"
+        style="margin-top:15px">
+        <input type="hidden" id="hdn_resourceId" name="resourceId"
+        ><t:attr name="value"><t:slot name="resourceId" /></t:attr></input>
+        <div style="margin-top:7px">
+          Auto-Schedule
+          <select id="sel_autoSchedule" name="autoSchedule">
+            <option t:render="isAutoSchedule" value="true">Yes</option>
+            <option t:render="isntAutoSchedule" value="false">No</option>
+          </select>
+          <input type="submit" value="Change" />
+        </div>
+      </form>
+      </t:transparent>
+
+      <!-- currentProxiesHtml -->
+      <div style="margin-top:15px; border-bottom:1px #444444 dotted"></div>
+      <form id="frm_proxies" name="proxiesForm" action="/admin/"
+        style="margin-top:15px">
+        <input type="hidden" id="hdn_resourceId" name="resourceId"
+        ><t:attr name="value"><t:slot name="resourceId" /></t:attr></input>
+        <div t:render="noProxies" style="margin-top:15px"
+          >This resource has no proxies.</div>
+        <table cellspacing="0" cellpadding="3" border="1"
+          t:render="hasProxies">
+          <tr class="odd">
+            <th colspan="2">Read-Only Proxies</th>
+            <th colspan="2">Read-Write Proxies</th>
+          </tr>
+          <tr t:render="proxyRows">
+            <t:attr name="class"><t:slot name="rowClass" /> </t:attr>
+            <t:transparent t:render="readOnlyProxies">
+            <td><t:slot name="proxy" /></td>
+            <td>
+              <input type="submit" value="Make Read-Write"><t:attr
+              name="name">mkWriteProxy|<t:slot name="type" />:<t:slot
+              name="shortName"/></t:attr></input>
+              <input type="submit" value="Remove Proxy"><t:attr
+              name="name">rmProxy|<t:slot name="type" />:<t:slot
+              name="shortName"/></t:attr></input>
+            </td>
+            </t:transparent>
+
+            <t:transparent t:render="noReadOnlyProxies">
+            <td colspan="2"></td>
+            </t:transparent>
+            <t:transparent t:render="readWriteProxies">
+            <td><t:slot name="proxy" /></td>
+            <td>
+              <input type="submit" value="Make Read-Only"><t:attr
+              name="name">mkReadProxy|<t:slot name="type" />:<t:slot
+              name="shortName"/></t:attr></input>
+              <input type="submit" value="Remove Proxy"><t:attr
+              name="name">rmProxy|<t:slot name="type" />:<t:slot
+              name="shortName"/></t:attr></input>
+            </td>
+            </t:transparent>
+            <t:transparent t:render="noReadWriteProxies">
+            <td colspan="2"></td>
+            </t:transparent>
+          </tr>
+        </table>
+      </form>
+
+      <!-- proxySearchHtml -->
+
+      <div style="margin-top:15px; border-bottom:1px #444444 dotted"></div>
+      <div t:render="noProxyResults"
+        style="margin-top:4px"
+        >No matches found for proxy resource <b><t:slot
+          name="proxySearch" /></b>.</div>
+      <form id="frm_proxySearch" name="proxySearchForm" action="/admin/"
+        style="margin-top:15px; margin-bottom:0; padding-bottom:0">
+        Search to add proxies:
+        <input type="hidden" id="hdn_resourceId" name="resourceId"
+        ><t:attr name="value"><t:slot name="resourceId" /></t:attr></input>
+        <input type="text" id="txt_proxySearch" name="proxySearch" size="40"
+        ><t:attr name="value"><t:slot name="proxySearch" /></t:attr></input>
+        <input type="submit" value="Search"></input>
+      </form>
+      <form t:render="hasProxyResults"
+        id="frm_proxyAdd" name="proxyAddForm" action="/admin/"
+        style="margin-top:2px; padding-top:0">
+        <input type="hidden" id="hdn_resourceId" name="resourceId"
+        ><t:attr name="value"><t:slot name="resourceId" /></t:attr></input>
+        <table cellspacing="0" cellpadding="3" border="1">
+          <tr class="odd">
+            <th>Full Name</th>
+            <th>Type</th>
+            <th>Short Names</th>
+            <th>Email Addresses</th>
+            <th></th>
+          </tr>
+          <tr t:render="proxySearchRows">
+            <t:attr name="class"><t:slot name="rowClass" /> </t:attr>
+            <td><t:slot name="name" /></td>
+            <td><t:slot name="typeStr" /></td>
+            <td><t:slot name="shortNames" /></td>
+            <td><t:slot name="emails" /></td>
+            <td>
+              <input type="submit" value="Make Read-Only Proxy"><t:attr
+              name="name">mkReadProxy|<t:slot name="type" />:<t:slot
+              name="shortName"/></t:attr></input>
+              <input type="submit" value="Make Read-Write Proxy"><t:attr
+              name="name">mkWriteProxy|<t:slot name="type" />:<t:slot
+              name="shortName"/></t:attr></input>
+            </td>
+          </tr>
+        </table>
+      </form>
+      </t:transparent>
+    </div>
+
+  </body>
+</html>

Deleted: CalendarServer/trunk/calendarserver/webadmin/test/__init__.py
===================================================================
Copied: CalendarServer/trunk/calendarserver/webadmin/test/__init__.py (from rev 7984, CalendarServer/branches/users/glyph/imip-and-admin-html/calendarserver/webadmin/test/__init__.py)
===================================================================
Deleted: CalendarServer/trunk/calendarserver/webadmin/test/test_resource.py
===================================================================
--- CalendarServer/branches/users/glyph/imip-and-admin-html/calendarserver/webadmin/test/test_resource.py	2011-08-12 04:56:02 UTC (rev 7984)
+++ CalendarServer/trunk/calendarserver/webadmin/test/test_resource.py	2011-08-12 05:15:28 UTC (rev 7985)
@@ -1,471 +0,0 @@
-##
-# Copyright (c) 2011 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.
-##
-
-"""
-Tests for L{calendarserver.webadmin.resource}.
-"""
-
-import cgi
-
-from functools import partial
-
-from twisted.trial.unittest import TestCase
-
-from twisted.web.microdom import parseString, getElementsByTagName
-from twisted.web.domhelpers import gatherTextNodes
-
-
-from calendarserver.tap.util import FakeRequest
-from twisted.internet.defer import inlineCallbacks
-from twisted.internet.defer import returnValue
-from calendarserver.webadmin.resource import WebAdminResource
-
-from twext.web2.dav.element.rfc3744 import GroupMemberSet
-from twext.web2.dav.element.rfc2518 import DisplayName
-
-from twext.web2.http import HTTPError
-from twext.web2.responsecode import CONFLICT
-from twext.web2.dav.element.rfc2518 import HRef
-from twistedcaldav.directory.directory import DirectoryRecord
-
-
-
-class RenderingTests(TestCase):
-    """
-    Tests for HTML rendering L{WebAdminResource}.
-    """
-
-    def expectRecordSearch(self, searchString, result):
-        """
-        Expect that a search will be issued via with the given fields, and will
-        yield the given result.
-        """
-        fields = []
-        for field in 'fullName', 'firstName', 'lastName', 'emailAddresses':
-            fields.append((field, searchString, True, "contains"))
-        self.expectedSearches[tuple(fields)] = result
-
-
-    def recordsMatchingFields(self, fields):
-        """
-        Pretend to be a directory object for the purposes of testing.
-        """
-        # 'fields' will be a list of 4-tuples of (fieldName, searchStr, True,
-        # "contains"; implement this for tests which will want to call
-        # 'search()')
-        return self.expectedSearches.pop(tuple(fields))
-
-
-    def setUp(self):
-        self.expectedSearches = {}
-        self.resource = WebAdminResource(self.mktemp(), None, self)
-
-
-    @inlineCallbacks
-    def renderPage(self, args={}):
-        """
-        Render a page, returning a Deferred that fires with the HTML as a
-        result.
-        """
-        req = FakeRequest(method='GET', path='/admin',
-                          rootResource=self.resource)
-        req.args = args
-        response = yield self.resource.render(req)
-        self.assertEquals(response.code, 200)
-        content = response.stream.mem
-        document = parseString(content)
-        returnValue(document)
-
-
-    @inlineCallbacks
-    def test_simplestRender(self):
-        """
-        Rendering a L{WebAdminResource} will result in something vaguely
-        parseable as HTML.
-        """
-        document = yield self.renderPage()
-        self.assertEquals(document.documentElement.tagName, 'html')
-
-
-    def expectSomeRecords(self):
-        """
-        Sample invocation of expectRecordSearch that includes two sample
-        directory records.
-        """
-        self.expectRecordSearch(
-            "bob", [
-                DirectoryRecord(
-                    service=self, recordType=recordType, guid=None,
-                    authIDs=authIds, emailAddresses=tuple(emails),
-                    shortNames=tuple(shortNames), fullName=fullName
-                )
-                for (shortNames, fullName, authIds, emails, recordType)
-                in [
-                    (["bobd"], "Bob Dobson", ["bobdlogin"],
-                     ["bobd at example.com"], 'sudoers'),
-                    (["bob"], "Bob Bobson", ["boblogin"],
-                     ["bob at example.com", "bob at other.example.com"], 'users'),
-                   ]
-            ])
-
-
-    @inlineCallbacks
-    def test_resourceSearch(self):
-        """
-        Searching for resources should result in an HTML table resource search.
-        """
-        self.expectSomeRecords()
-        document = yield self.renderPage(dict(resourceSearch=["bob"]))
-
-        # Form is filled out with existing input.
-        self.assertEquals(
-            document.getElementById("txt_resourceSearch").getAttribute("value"),
-            "bob"
-        )
-        tables = getElementsByTagName(document, "table")
-        # search results are the first table
-        rows = getElementsByTagName(tables[0], 'tr')
-        self.assertEquals(len(rows), 3)
-        firstRowCells = getElementsByTagName(rows[1], 'td')
-        self.assertEquals(
-            [gatherTextNodes(cell) for cell in firstRowCells[1:]],
-            ["Bob Bobson", "User", "bob", "boblogin",
-             "bob at example.com, bob at other.example.com"]
-        )
-        [resourceLink] = getElementsByTagName(
-            firstRowCells[0], 'a')
-        self.assertEquals(
-            resourceLink.getAttribute("href"),
-            "/admin/?resourceId=users:bob"
-        )
-        self.assertEquals(gatherTextNodes(resourceLink), "select")
-        self.assertNotIn(
-            "No matches found for resource bob",
-            gatherTextNodes(document)
-        )
-
-
-    @inlineCallbacks
-    def test_proxySearch(self):
-        """
-        When the user searches for a proxy, the results are displayed in a
-        table, in a form that will allow them to submit it to add new read or
-        write proxies.
-        """
-        self.expectSomeRecords()
-        self.resource.getResourceById = partial(FakePrincipalResource, self)
-        document = yield self.renderPage(dict(resourceId=["qux"],
-                                              proxySearch=["bob"]))
-
-        # Form is filled out with existing input.
-        self.assertEquals(
-            document.getElementById("txt_proxySearch").getAttribute("value"),
-            "bob"
-        )
-        proxyAddForm = document.getElementById("frm_proxyAdd")
-        [proxyResultsTable] = getElementsByTagName(proxyAddForm, "table")
-
-        rows = getElementsByTagName(proxyResultsTable, 'tr')
-        self.assertEquals(len(rows), 3)
-        firstRowCells = getElementsByTagName(rows[1], 'td')
-        self.assertEquals(
-            [gatherTextNodes(cell) for cell in firstRowCells[1:]],
-            ["User", "bob", "bob at example.com, bob at other.example.com", ""]
-        )
-        self.assertNotIn(
-            "No matches found for proxy resource bob",
-            gatherTextNodes(document)
-        )
-
-
-    @inlineCallbacks
-    def test_emptyProxySearch(self):
-        """
-        When no results are found for a search for proxies, a relevant message
-        should be displayed and the table/form for results should not be.
-        """
-        self.resource.getResourceById = partial(FakePrincipalResource, self)
-        self.expectRecordSearch("bob", [])
-        document = yield self.renderPage(dict(resourceId=["qux"],
-                                              proxySearch=["bob"]))
-        self.assertEquals(
-            document.getElementById("txt_proxySearch").getAttribute("value"),
-            "bob"
-        )
-        proxyAddForm = document.getElementById("frm_proxyAdd")
-        self.assertIdentical(proxyAddForm, None)
-        self.assertIn(
-            "No matches found for proxy resource bob",
-            gatherTextNodes(document)
-        )
-
-
-    @inlineCallbacks
-    def test_noResourceFound(self):
-        """
-        Searching for resources which don't exist should result in an
-        informative message.
-        """
-        self.expectRecordSearch("bob", [])
-        document = yield self.renderPage(dict(resourceSearch=["bob"]))
-        self.assertIn(
-            "No matches found for resource bob",
-            gatherTextNodes(document)
-        )
-        # Search results table should not be displayed.
-        self.assertIdentical(document.getElementById("tab_searchResults"), None)
-
-
-    @inlineCallbacks
-    def test_selectResourceById(self):
-        """
-        When a resource is selected by a 'resourceId' parameter, 
-        """
-        self.resource.getResourceById = partial(FakePrincipalResource, self)
-        document = yield self.renderPage(dict(resourceId=["qux"]))
-        [detailsTitle] = getElementsByTagName(document, 'h3')
-        detailString = gatherTextNodes(detailsTitle)
-        self.assertEquals(detailString,
-                          "Resource Details: Hello Fake Resource: 'qux'")
-        hiddenResourceId = document.getElementById(
-            "hdn_resourceId").getAttribute("value")
-        self.assertEquals(hiddenResourceId, "qux")
-
-        autoScheduleMenu = document.getElementById("sel_autoSchedule")
-
-        # Now, some assertions about features that are covered in other tests
-        # which should be turned _off_ here since we're not asking for them.
-
-        # Not an auto-schedule resource; there should be no auto-schedule menu.
-        self.assertIdentical(autoScheduleMenu, None)
-        # No resource search present; we shouldn't be performing the search.
-        self.assertNotIn("No matches found for resource",
-                         gatherTextNodes(document))
-        self.assertIdentical(document.getElementById("tab_searchResults"), None)
-        # I'm not attempting to retrieve a property, there's nothing to fail to
-        # parse.
-        self.assertNotIn("Unable to parse property", gatherTextNodes(document))
-        # I'm not searching for proxies, so there shouldn't be any results.
-        self.assertNotIn("No matches found for proxy resource",
-                         gatherTextNodes(document))
-
-
-    @inlineCallbacks
-    def test_davProperty(self):
-        """
-        When a resource is selected by a resourceId parameter, and a DAV
-        property is selected by the 'davPropertyName' parameter, that property
-        will displayed.
-        """
-        self.resource.getResourceById = partial(FakePrincipalResource, self)
-        document = yield self.renderPage(
-            dict(resourceId=["qux"],
-                 davPropertyName=["DAV:#displayname"])
-        )
-        propertyName = document.getElementById('txt_davPropertyName')
-        self.assertEquals(propertyName.getAttribute("value"),
-                          "DAV:#displayname")
-        propertyValue = DisplayName("The Name To Display").toxml()
-        self.assertIn(cgi.escape(propertyValue),
-                      gatherTextNodes(document))
-        self.assertNotIn("Unable to parse property to read:",
-                         gatherTextNodes(document))
-
-
-    @inlineCallbacks
-    def test_notADavProperty(self):
-        """
-        When a DAV property is selected without the proper syntax (i.e. no "#"
-        to separate namespace and name), an error will be displayed.
-        """
-        self.resource.getResourceById = partial(FakePrincipalResource, self)
-        document = yield self.renderPage(
-            dict(resourceId=["qux"],
-                 davPropertyName=["blub"])
-        )
-        propertyName = document.getElementById('txt_davPropertyName')
-        self.assertEquals(propertyName.getAttribute("value"),
-                          "blub")
-        propertyValue = "Unable to parse property to read: blub"
-        self.assertIn(cgi.escape(propertyValue),
-                      gatherTextNodes(document))
-
-
-    @inlineCallbacks
-    def test_autoScheduleMenu(self):
-        """
-        When rendering a resource, an "Auto-Schedule" menu with "Yes/No" options
-        should be displayed.
-        """
-        for expectValue in [True, False]:
-
-            self.resource.getResourceById = partial(FakePrincipalResource, self,
-                                                    recordType='resources',
-                                                    autosched=expectValue)
-            document = yield self.renderPage(dict(resourceId=["qux"]))
-            autoScheduleMenu = document.getElementById("sel_autoSchedule")
-            self.assertEquals(autoScheduleMenu.getAttribute("name"),
-                              "autoSchedule")
-
-            yesno = getElementsByTagName(autoScheduleMenu, 'option')
-
-            # Sanity checks to make sure we got the right items
-            self.assertEquals(yesno[0].getAttribute("value"), "true")
-            self.assertEquals(yesno[1].getAttribute("value"), "false")
-
-            [expectedTrue, expectedFalse] = [yesno[not expectValue],
-                                             yesno[expectValue]]
-
-            self.assertEquals(expectedTrue.hasAttribute("selected"), True)
-            self.assertEquals(expectedFalse.hasAttribute("selected"), False)
-            self.assertEquals(expectedTrue.getAttribute("selected"), "selected")
-
-
-    @inlineCallbacks
-    def test_proxiesListing(self):
-        """
-        Resource principals will have their proxies listed in a table.
-        """
-        def fakeResourceById(request, resid):
-            return FakePrincipalResource(self, request, resid,
-                                         recordType="resources")
-
-        self.resource.getResourceById = fakeResourceById
-        document = yield self.renderPage(dict(resourceId=["qux"]))
-        proxiesForm = document.getElementById("frm_proxies")
-        [proxiesTable] = getElementsByTagName(proxiesForm, "table")
-        rows = getElementsByTagName(proxiesTable, "tr")
-
-        # header + 3 data rows (see FakePrincipalResource)
-        self.assertEquals(len(rows), 4)
-        firstRowCells = getElementsByTagName(rows[1], "td")
-        # name, buttons, name, buttons
-        self.assertEquals(len(firstRowCells), 4)
-        lastRowCells = getElementsByTagName(rows[-1], "td")
-        # name, buttons, blank space
-        self.assertEquals(len(lastRowCells), 3)
-        self.assertEquals(lastRowCells[-1].getAttribute("colspan"), "2")
-
-        self.assertNotIn("This resource has no proxies.",
-                         ''.join(gatherTextNodes(document)))
-
-
-    @inlineCallbacks
-    def test_noProxiesListing(self):
-        """
-        When the selected resource principal has no proxies, the page should
-        display a message saying so.
-        """
-        self.resource.getResourceById = partial(FakePrincipalResource, self,
-                                                recordType='resources',
-                                                hasProxies=False)
-        document = yield self.renderPage(dict(resourceId=['qux']))
-        self.assertIn("This resource has no proxies.",
-                      ''.join(gatherTextNodes(document)))
-
-
-    @inlineCallbacks
-    def test_noDavProperty(self):
-        """
-        When a DAV property is not found, an error will be displayed.
-        """
-        self.resource.getResourceById = partial(FakePrincipalResource, self)
-        document = yield self.renderPage(
-            dict(resourceId=["qux"],
-                 davPropertyName=["DAV:#blub"])
-        )
-        propertyName = document.getElementById('txt_davPropertyName')
-        self.assertEquals(propertyName.getAttribute("value"),
-                          "DAV:#blub")
-        propertyValue = "No such property: DAV:#blub"
-        self.assertIn(cgi.escape(propertyValue),
-                      gatherTextNodes(document))
-
-
-    # Properties for being a fake directory service as far as the implementation
-    # of DirectoryRecord is concerned.
-    realmName = 'Fake'
-    guid = '28c57671-2bf8-4ebd-bc45-fda5ffcee1e8'
-
-
-
-class FakePrincipalResource(object):
-    def __init__(self, test, req=None, resid='no-id-given', autosched=True,
-                 recordType="users", extraProperties=(), hasProxies=True):
-        self.test = test
-        self.resid = resid
-        self.autosched = autosched
-        self.recordType = recordType
-        self.extraProperties = extraProperties
-        self.hasProxies = hasProxies
-
-
-    @property
-    def record(self):
-        authIds = ['fake auth id']
-        emails = ['fake email']
-        shortNames = [self.resid]
-        fullName = 'nobody'
-        return DirectoryRecord(
-            service=self.test, recordType=self.recordType, guid=None,
-            authIDs=authIds, emailAddresses=tuple(emails),
-            shortNames=tuple(shortNames), fullName=fullName
-        )
-
-
-    def __str__(self):
-        return 'Hello Fake Resource: %r' % (self.resid,)
-
-
-    def getChild(self, name):
-        if name == 'calendar-proxy-read':
-            if self.hasProxies:
-                proxyProps = [HRef("read-1"), HRef("read-2"), HRef("read-3")]
-            else:
-                proxyProps = []
-            return FakePrincipalResource(
-                self.test,
-                extraProperties=[GroupMemberSet(*proxyProps)]
-            )
-        elif name == 'calendar-proxy-write':
-            if self.hasProxies:
-                proxyProps = [HRef("write-1"), HRef("write-2")]
-            else:
-                proxyProps = []
-            return FakePrincipalResource(
-                self.test,
-                extraProperties=[GroupMemberSet(*proxyProps)]
-            )
-
-
-    @inlineCallbacks
-    def readProperty(self, name, request):
-        yield None
-        if not isinstance(name, tuple):
-            name = name.qname()
-        for prop in self.extraProperties:
-            if name == prop.qname():
-                returnValue(prop)
-        if name == DisplayName.qname():
-            returnValue(DisplayName("The Name To Display"))
-        else:
-            raise HTTPError(CONFLICT)
-
-
-    def getAutoSchedule(self):
-        return self.autosched
-
-

Copied: CalendarServer/trunk/calendarserver/webadmin/test/test_resource.py (from rev 7984, CalendarServer/branches/users/glyph/imip-and-admin-html/calendarserver/webadmin/test/test_resource.py)
===================================================================
--- CalendarServer/trunk/calendarserver/webadmin/test/test_resource.py	                        (rev 0)
+++ CalendarServer/trunk/calendarserver/webadmin/test/test_resource.py	2011-08-12 05:15:28 UTC (rev 7985)
@@ -0,0 +1,471 @@
+##
+# Copyright (c) 2011 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.
+##
+
+"""
+Tests for L{calendarserver.webadmin.resource}.
+"""
+
+import cgi
+
+from functools import partial
+
+from twisted.trial.unittest import TestCase
+
+from twisted.web.microdom import parseString, getElementsByTagName
+from twisted.web.domhelpers import gatherTextNodes
+
+
+from calendarserver.tap.util import FakeRequest
+from twisted.internet.defer import inlineCallbacks
+from twisted.internet.defer import returnValue
+from calendarserver.webadmin.resource import WebAdminResource
+
+from twext.web2.dav.element.rfc3744 import GroupMemberSet
+from twext.web2.dav.element.rfc2518 import DisplayName
+
+from twext.web2.http import HTTPError
+from twext.web2.responsecode import CONFLICT
+from twext.web2.dav.element.rfc2518 import HRef
+from twistedcaldav.directory.directory import DirectoryRecord
+
+
+
+class RenderingTests(TestCase):
+    """
+    Tests for HTML rendering L{WebAdminResource}.
+    """
+
+    def expectRecordSearch(self, searchString, result):
+        """
+        Expect that a search will be issued via with the given fields, and will
+        yield the given result.
+        """
+        fields = []
+        for field in 'fullName', 'firstName', 'lastName', 'emailAddresses':
+            fields.append((field, searchString, True, "contains"))
+        self.expectedSearches[tuple(fields)] = result
+
+
+    def recordsMatchingFields(self, fields):
+        """
+        Pretend to be a directory object for the purposes of testing.
+        """
+        # 'fields' will be a list of 4-tuples of (fieldName, searchStr, True,
+        # "contains"; implement this for tests which will want to call
+        # 'search()')
+        return self.expectedSearches.pop(tuple(fields))
+
+
+    def setUp(self):
+        self.expectedSearches = {}
+        self.resource = WebAdminResource(self.mktemp(), None, self)
+
+
+    @inlineCallbacks
+    def renderPage(self, args={}):
+        """
+        Render a page, returning a Deferred that fires with the HTML as a
+        result.
+        """
+        req = FakeRequest(method='GET', path='/admin',
+                          rootResource=self.resource)
+        req.args = args
+        response = yield self.resource.render(req)
+        self.assertEquals(response.code, 200)
+        content = response.stream.mem
+        document = parseString(content)
+        returnValue(document)
+
+
+    @inlineCallbacks
+    def test_simplestRender(self):
+        """
+        Rendering a L{WebAdminResource} will result in something vaguely
+        parseable as HTML.
+        """
+        document = yield self.renderPage()
+        self.assertEquals(document.documentElement.tagName, 'html')
+
+
+    def expectSomeRecords(self):
+        """
+        Sample invocation of expectRecordSearch that includes two sample
+        directory records.
+        """
+        self.expectRecordSearch(
+            "bob", [
+                DirectoryRecord(
+                    service=self, recordType=recordType, guid=None,
+                    authIDs=authIds, emailAddresses=tuple(emails),
+                    shortNames=tuple(shortNames), fullName=fullName
+                )
+                for (shortNames, fullName, authIds, emails, recordType)
+                in [
+                    (["bobd"], "Bob Dobson", ["bobdlogin"],
+                     ["bobd at example.com"], 'sudoers'),
+                    (["bob"], "Bob Bobson", ["boblogin"],
+                     ["bob at example.com", "bob at other.example.com"], 'users'),
+                   ]
+            ])
+
+
+    @inlineCallbacks
+    def test_resourceSearch(self):
+        """
+        Searching for resources should result in an HTML table resource search.
+        """
+        self.expectSomeRecords()
+        document = yield self.renderPage(dict(resourceSearch=["bob"]))
+
+        # Form is filled out with existing input.
+        self.assertEquals(
+            document.getElementById("txt_resourceSearch").getAttribute("value"),
+            "bob"
+        )
+        tables = getElementsByTagName(document, "table")
+        # search results are the first table
+        rows = getElementsByTagName(tables[0], 'tr')
+        self.assertEquals(len(rows), 3)
+        firstRowCells = getElementsByTagName(rows[1], 'td')
+        self.assertEquals(
+            [gatherTextNodes(cell) for cell in firstRowCells[1:]],
+            ["Bob Bobson", "User", "bob", "boblogin",
+             "bob at example.com, bob at other.example.com"]
+        )
+        [resourceLink] = getElementsByTagName(
+            firstRowCells[0], 'a')
+        self.assertEquals(
+            resourceLink.getAttribute("href"),
+            "/admin/?resourceId=users:bob"
+        )
+        self.assertEquals(gatherTextNodes(resourceLink), "select")
+        self.assertNotIn(
+            "No matches found for resource bob",
+            gatherTextNodes(document)
+        )
+
+
+    @inlineCallbacks
+    def test_proxySearch(self):
+        """
+        When the user searches for a proxy, the results are displayed in a
+        table, in a form that will allow them to submit it to add new read or
+        write proxies.
+        """
+        self.expectSomeRecords()
+        self.resource.getResourceById = partial(FakePrincipalResource, self)
+        document = yield self.renderPage(dict(resourceId=["qux"],
+                                              proxySearch=["bob"]))
+
+        # Form is filled out with existing input.
+        self.assertEquals(
+            document.getElementById("txt_proxySearch").getAttribute("value"),
+            "bob"
+        )
+        proxyAddForm = document.getElementById("frm_proxyAdd")
+        [proxyResultsTable] = getElementsByTagName(proxyAddForm, "table")
+
+        rows = getElementsByTagName(proxyResultsTable, 'tr')
+        self.assertEquals(len(rows), 3)
+        firstRowCells = getElementsByTagName(rows[1], 'td')
+        self.assertEquals(
+            [gatherTextNodes(cell) for cell in firstRowCells[1:]],
+            ["User", "bob", "bob at example.com, bob at other.example.com", ""]
+        )
+        self.assertNotIn(
+            "No matches found for proxy resource bob",
+            gatherTextNodes(document)
+        )
+
+
+    @inlineCallbacks
+    def test_emptyProxySearch(self):
+        """
+        When no results are found for a search for proxies, a relevant message
+        should be displayed and the table/form for results should not be.
+        """
+        self.resource.getResourceById = partial(FakePrincipalResource, self)
+        self.expectRecordSearch("bob", [])
+        document = yield self.renderPage(dict(resourceId=["qux"],
+                                              proxySearch=["bob"]))
+        self.assertEquals(
+            document.getElementById("txt_proxySearch").getAttribute("value"),
+            "bob"
+        )
+        proxyAddForm = document.getElementById("frm_proxyAdd")
+        self.assertIdentical(proxyAddForm, None)
+        self.assertIn(
+            "No matches found for proxy resource bob",
+            gatherTextNodes(document)
+        )
+
+
+    @inlineCallbacks
+    def test_noResourceFound(self):
+        """
+        Searching for resources which don't exist should result in an
+        informative message.
+        """
+        self.expectRecordSearch("bob", [])
+        document = yield self.renderPage(dict(resourceSearch=["bob"]))
+        self.assertIn(
+            "No matches found for resource bob",
+            gatherTextNodes(document)
+        )
+        # Search results table should not be displayed.
+        self.assertIdentical(document.getElementById("tab_searchResults"), None)
+
+
+    @inlineCallbacks
+    def test_selectResourceById(self):
+        """
+        When a resource is selected by a 'resourceId' parameter, 
+        """
+        self.resource.getResourceById = partial(FakePrincipalResource, self)
+        document = yield self.renderPage(dict(resourceId=["qux"]))
+        [detailsTitle] = getElementsByTagName(document, 'h3')
+        detailString = gatherTextNodes(detailsTitle)
+        self.assertEquals(detailString,
+                          "Resource Details: Hello Fake Resource: 'qux'")
+        hiddenResourceId = document.getElementById(
+            "hdn_resourceId").getAttribute("value")
+        self.assertEquals(hiddenResourceId, "qux")
+
+        autoScheduleMenu = document.getElementById("sel_autoSchedule")
+
+        # Now, some assertions about features that are covered in other tests
+        # which should be turned _off_ here since we're not asking for them.
+
+        # Not an auto-schedule resource; there should be no auto-schedule menu.
+        self.assertIdentical(autoScheduleMenu, None)
+        # No resource search present; we shouldn't be performing the search.
+        self.assertNotIn("No matches found for resource",
+                         gatherTextNodes(document))
+        self.assertIdentical(document.getElementById("tab_searchResults"), None)
+        # I'm not attempting to retrieve a property, there's nothing to fail to
+        # parse.
+        self.assertNotIn("Unable to parse property", gatherTextNodes(document))
+        # I'm not searching for proxies, so there shouldn't be any results.
+        self.assertNotIn("No matches found for proxy resource",
+                         gatherTextNodes(document))
+
+
+    @inlineCallbacks
+    def test_davProperty(self):
+        """
+        When a resource is selected by a resourceId parameter, and a DAV
+        property is selected by the 'davPropertyName' parameter, that property
+        will displayed.
+        """
+        self.resource.getResourceById = partial(FakePrincipalResource, self)
+        document = yield self.renderPage(
+            dict(resourceId=["qux"],
+                 davPropertyName=["DAV:#displayname"])
+        )
+        propertyName = document.getElementById('txt_davPropertyName')
+        self.assertEquals(propertyName.getAttribute("value"),
+                          "DAV:#displayname")
+        propertyValue = DisplayName("The Name To Display").toxml()
+        self.assertIn(cgi.escape(propertyValue),
+                      gatherTextNodes(document))
+        self.assertNotIn("Unable to parse property to read:",
+                         gatherTextNodes(document))
+
+
+    @inlineCallbacks
+    def test_notADavProperty(self):
+        """
+        When a DAV property is selected without the proper syntax (i.e. no "#"
+        to separate namespace and name), an error will be displayed.
+        """
+        self.resource.getResourceById = partial(FakePrincipalResource, self)
+        document = yield self.renderPage(
+            dict(resourceId=["qux"],
+                 davPropertyName=["blub"])
+        )
+        propertyName = document.getElementById('txt_davPropertyName')
+        self.assertEquals(propertyName.getAttribute("value"),
+                          "blub")
+        propertyValue = "Unable to parse property to read: blub"
+        self.assertIn(cgi.escape(propertyValue),
+                      gatherTextNodes(document))
+
+
+    @inlineCallbacks
+    def test_autoScheduleMenu(self):
+        """
+        When rendering a resource, an "Auto-Schedule" menu with "Yes/No" options
+        should be displayed.
+        """
+        for expectValue in [True, False]:
+
+            self.resource.getResourceById = partial(FakePrincipalResource, self,
+                                                    recordType='resources',
+                                                    autosched=expectValue)
+            document = yield self.renderPage(dict(resourceId=["qux"]))
+            autoScheduleMenu = document.getElementById("sel_autoSchedule")
+            self.assertEquals(autoScheduleMenu.getAttribute("name"),
+                              "autoSchedule")
+
+            yesno = getElementsByTagName(autoScheduleMenu, 'option')
+
+            # Sanity checks to make sure we got the right items
+            self.assertEquals(yesno[0].getAttribute("value"), "true")
+            self.assertEquals(yesno[1].getAttribute("value"), "false")
+
+            [expectedTrue, expectedFalse] = [yesno[not expectValue],
+                                             yesno[expectValue]]
+
+            self.assertEquals(expectedTrue.hasAttribute("selected"), True)
+            self.assertEquals(expectedFalse.hasAttribute("selected"), False)
+            self.assertEquals(expectedTrue.getAttribute("selected"), "selected")
+
+
+    @inlineCallbacks
+    def test_proxiesListing(self):
+        """
+        Resource principals will have their proxies listed in a table.
+        """
+        def fakeResourceById(request, resid):
+            return FakePrincipalResource(self, request, resid,
+                                         recordType="resources")
+
+        self.resource.getResourceById = fakeResourceById
+        document = yield self.renderPage(dict(resourceId=["qux"]))
+        proxiesForm = document.getElementById("frm_proxies")
+        [proxiesTable] = getElementsByTagName(proxiesForm, "table")
+        rows = getElementsByTagName(proxiesTable, "tr")
+
+        # header + 3 data rows (see FakePrincipalResource)
+        self.assertEquals(len(rows), 4)
+        firstRowCells = getElementsByTagName(rows[1], "td")
+        # name, buttons, name, buttons
+        self.assertEquals(len(firstRowCells), 4)
+        lastRowCells = getElementsByTagName(rows[-1], "td")
+        # name, buttons, blank space
+        self.assertEquals(len(lastRowCells), 3)
+        self.assertEquals(lastRowCells[-1].getAttribute("colspan"), "2")
+
+        self.assertNotIn("This resource has no proxies.",
+                         ''.join(gatherTextNodes(document)))
+
+
+    @inlineCallbacks
+    def test_noProxiesListing(self):
+        """
+        When the selected resource principal has no proxies, the page should
+        display a message saying so.
+        """
+        self.resource.getResourceById = partial(FakePrincipalResource, self,
+                                                recordType='resources',
+                                                hasProxies=False)
+        document = yield self.renderPage(dict(resourceId=['qux']))
+        self.assertIn("This resource has no proxies.",
+                      ''.join(gatherTextNodes(document)))
+
+
+    @inlineCallbacks
+    def test_noDavProperty(self):
+        """
+        When a DAV property is not found, an error will be displayed.
+        """
+        self.resource.getResourceById = partial(FakePrincipalResource, self)
+        document = yield self.renderPage(
+            dict(resourceId=["qux"],
+                 davPropertyName=["DAV:#blub"])
+        )
+        propertyName = document.getElementById('txt_davPropertyName')
+        self.assertEquals(propertyName.getAttribute("value"),
+                          "DAV:#blub")
+        propertyValue = "No such property: DAV:#blub"
+        self.assertIn(cgi.escape(propertyValue),
+                      gatherTextNodes(document))
+
+
+    # Properties for being a fake directory service as far as the implementation
+    # of DirectoryRecord is concerned.
+    realmName = 'Fake'
+    guid = '28c57671-2bf8-4ebd-bc45-fda5ffcee1e8'
+
+
+
+class FakePrincipalResource(object):
+    def __init__(self, test, req=None, resid='no-id-given', autosched=True,
+                 recordType="users", extraProperties=(), hasProxies=True):
+        self.test = test
+        self.resid = resid
+        self.autosched = autosched
+        self.recordType = recordType
+        self.extraProperties = extraProperties
+        self.hasProxies = hasProxies
+
+
+    @property
+    def record(self):
+        authIds = ['fake auth id']
+        emails = ['fake email']
+        shortNames = [self.resid]
+        fullName = 'nobody'
+        return DirectoryRecord(
+            service=self.test, recordType=self.recordType, guid=None,
+            authIDs=authIds, emailAddresses=tuple(emails),
+            shortNames=tuple(shortNames), fullName=fullName
+        )
+
+
+    def __str__(self):
+        return 'Hello Fake Resource: %r' % (self.resid,)
+
+
+    def getChild(self, name):
+        if name == 'calendar-proxy-read':
+            if self.hasProxies:
+                proxyProps = [HRef("read-1"), HRef("read-2"), HRef("read-3")]
+            else:
+                proxyProps = []
+            return FakePrincipalResource(
+                self.test,
+                extraProperties=[GroupMemberSet(*proxyProps)]
+            )
+        elif name == 'calendar-proxy-write':
+            if self.hasProxies:
+                proxyProps = [HRef("write-1"), HRef("write-2")]
+            else:
+                proxyProps = []
+            return FakePrincipalResource(
+                self.test,
+                extraProperties=[GroupMemberSet(*proxyProps)]
+            )
+
+
+    @inlineCallbacks
+    def readProperty(self, name, request):
+        yield None
+        if not isinstance(name, tuple):
+            name = name.qname()
+        for prop in self.extraProperties:
+            if name == prop.qname():
+                returnValue(prop)
+        if name == DisplayName.qname():
+            returnValue(DisplayName("The Name To Display"))
+        else:
+            raise HTTPError(CONFLICT)
+
+
+    def getAutoSchedule(self):
+        return self.autosched
+
+

Modified: CalendarServer/trunk/pyflakes
===================================================================
--- CalendarServer/trunk/pyflakes	2011-08-12 04:56:02 UTC (rev 7984)
+++ CalendarServer/trunk/pyflakes	2011-08-12 05:15:28 UTC (rev 7985)
@@ -12,7 +12,7 @@
   set - calendarserver twext twisted twistedcaldav txdav contrib;
 fi;
 
-tmp="$(mktemp -t pyflakes)";
+tmp="$(mktemp "/tmp/pyflakes.XXXXX")";
 
 cd "${wd}" && "${flakes}/bin/pyflakes" "$@" | sed                     \
   -e "/undefined name '_'/d"                                          \

Modified: CalendarServer/trunk/setup.py
===================================================================
--- CalendarServer/trunk/setup.py	2011-08-12 04:56:02 UTC (rev 7984)
+++ CalendarServer/trunk/setup.py	2011-08-12 05:15:28 UTC (rev 7985)
@@ -113,6 +113,12 @@
                                "zoneinfo/*/*/*.ics",
                                "images/*/*.jpg",
                              ],
+                             "calendarserver.webadmin": [
+                                 "*.html"
+                             ],
+                             "twistedcaldav": [
+                                 "*.html"
+                             ],
                              "txdav.common.datastore": [
                                "sql_schema/*.sql",
                                "sql_schema/*/*.sql",

Modified: CalendarServer/trunk/twistedcaldav/mail.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/mail.py	2011-08-12 04:56:02 UTC (rev 7984)
+++ CalendarServer/trunk/twistedcaldav/mail.py	2011-08-12 05:15:28 UTC (rev 7985)
@@ -1,3 +1,4 @@
+# -*- test-case-name: twistedcaldav.test.test_mail -*-
 ##
 # Copyright (c) 2005-2010 Apple Inc. All rights reserved.
 #
@@ -16,8 +17,8 @@
 
 """
 Mail Gateway for Calendar Server
+"""
 
-"""
 from __future__ import with_statement
 
 import datetime
@@ -25,11 +26,7 @@
 import os
 import uuid
 
-try:
-    from cStringIO import StringIO
-    StringIO
-except ImportError:
-    from StringIO import StringIO
+from cStringIO import StringIO
 
 from email.mime.image import MIMEImage
 from email.mime.multipart import MIMEMultipart
@@ -44,7 +41,13 @@
 from twisted.mail.smtp import messageid, rfc822date, ESMTPSenderFactory
 from twisted.plugin import IPlugin
 from twisted.python.usage import Options, UsageError
+
 from twisted.web import client
+from twisted.web.template import (
+    XMLString, TEMPLATE_NAMESPACE, Element, renderer, flattenString, tags
+)
+from twisted.web.microdom import parseString
+from twisted.web.microdom import Text as DOMText, Element as DOMElement
 
 from twext.web2 import server, responsecode
 from twext.web2.channel.http import HTTPFactory
@@ -53,7 +56,6 @@
 from twext.web2.http import Response, HTTPError
 from twext.web2.http_headers import MimeType
 
-
 from twext.python.log import Logger, LoggingMixIn
 
 from twistedcaldav import ical, caldavxml
@@ -71,6 +73,7 @@
 from twistedcaldav.util import AuthorizedHTTPGetter
 from twistedcaldav.stdconfig import DEFAULT_CONFIG, DEFAULT_CONFIG_FILE
 
+
 from calendarserver.tap.util import getRootResource, directoryFromConfig
 
 
@@ -85,6 +88,195 @@
 log = Logger()
 
 #
+# Templates
+#
+
+plainCancelTemplate = u"""%(subject)s
+
+%(orgLabel)s: %(plainOrganizer)s
+%(dateLabel)s: %(dateInfo)s %(recurrenceInfo)s
+%(timeLabel)s: %(timeInfo)s %(durationInfo)s
+"""
+
+plainInviteTemplate = u"""%(subject)s
+
+%(orgLabel)s: %(plainOrganizer)s
+%(locLabel)s: %(location)s
+%(dateLabel)s: %(dateInfo)s %(recurrenceInfo)s
+%(timeLabel)s: %(timeInfo)s %(durationInfo)s
+%(descLabel)s: %(description)s
+%(attLabel)s: %(plainAttendees)s
+"""
+
+
+htmlCancelTemplate = u"""<html>
+    <body><div>
+
+    <h1>%(subject)s</h1>
+    <p>
+    <h3>%(orgLabel)s:</h3> %(htmlOrganizer)s
+    </p>
+    <p>
+    <h3>%(dateLabel)s:</h3> %(dateInfo)s %(recurrenceInfo)s
+    </p>
+    <p>
+    <h3>%(timeLabel)s:</h3> %(timeInfo)s %(durationInfo)s
+    </p>
+    """.encode("utf-8")
+
+
+htmlInviteTemplate = u"""<html>
+    <body><div>
+    <p>%(inviteLabel)s</p>
+
+    <h1>%(summary)s</h1>
+    <p>
+    <h3>%(orgLabel)s:</h3> %(htmlOrganizer)s
+    </p>
+    <p>
+    <h3>%(locLabel)s:</h3> %(location)s
+    </p>
+    <p>
+    <h3>%(dateLabel)s:</h3> %(dateInfo)s %(recurrenceInfo)s
+    </p>
+    <p>
+    <h3>%(timeLabel)s:</h3> %(timeInfo)s %(durationInfo)s
+    </p>
+    <p>
+    <h3>%(descLabel)s:</h3> %(description)s
+    </p>
+    <p>
+    <h3>%(attLabel)s:</h3> %(htmlAttendees)s
+    </p>
+    """.encode("utf-8")
+
+def _visit(document, node):
+    if isinstance(node, DOMText):
+        idx = node.parentNode.childNodes.index(node)
+        splitted = node.data.split("%(")
+        firstTextNode = document.createTextNode(splitted[0])
+        firstTextNode.parentNode = node.parentNode
+        replacements = [firstTextNode]
+        for moreText in splitted[1:]:
+            slotName, extra = moreText.split(')', 1)
+            extra = extra[1:]
+            slotElement = document.createElement('t:slot')
+            slotElement.setAttribute("name", slotName)
+            slotElement.parentNode = node.parentNode
+            textNode = document.createTextNode(extra)
+            textNode.parentNode = node.parentNode
+            replacements.append(slotElement)
+            replacements.append(textNode)
+        node.parentNode.childNodes[idx:idx+1] = replacements
+
+    elif isinstance(node, DOMElement):
+        for attrName, attrVal in node.attributes.items():
+            if '%(' in attrVal:
+                del node.attributes[attrName]
+                elem = document.createElement('t:attr')
+                elem.setAttribute('name', attrName)
+                textNode = document.createTextNode(attrVal)
+                elem.appendChild(textNode)
+                node.appendChild(elem)
+
+
+def _walk(document, n):
+    _visit(document, n)
+    for subn in n.childNodes:
+        _walk(document, subn)
+
+
+def _fixup(data, rendererName):
+    document = parseString(data, beExtremelyLenient=True)
+    document.documentElement.setAttribute(
+        "xmlns:t", TEMPLATE_NAMESPACE
+    )
+    document.documentElement.setAttribute(
+        "t:render", rendererName
+    )
+    _walk(document, document)
+    result = document.toxml()
+    return result
+
+
+
+class StringFormatTemplateLoader(object):
+    """
+    Loader for twisted.web.template that converts a template with %()s slots.
+    """
+    def __init__(self, fileFactory, rendererName):
+        """
+        @param fileFactory: a 1-argument callable which returns a file-like
+            object that contains the %()s-format template.
+
+        @param rendererName: the name of the renderer.
+
+        @type rendererName: C{str}
+        """
+        self.fileFactory = fileFactory
+        self.rendererName = rendererName
+
+
+    def load(self):
+        html = _fixup(self.fileFactory().read(), self.rendererName)
+        return XMLString(html).load()
+
+
+
+def localizedLabels(language, canceled, inviteState):
+    """
+    Generate localized labels for an email in the given language.
+
+    @param language: a 2-letter language code
+
+    @type language: C{str}
+
+    @return: a 2-tuple of (subjectFormatString, labelDict), where the first is a
+        format string for use in the subject, and the latter is a dictionary
+        with labels suitable for filling out HTML and plain-text templates.  All
+        values are C{str}s.
+    """
+    with translationTo(language):
+        if canceled:
+            subjectFormatString = _("Event canceled: %(summary)s")
+        elif inviteState == "new":
+            subjectFormatString = _("Event invitation: %(summary)s")
+        elif inviteState == "update":
+            subjectFormatString = _("Event update: %(summary)s")
+        else:
+            subjectFormatString = _("Event reply: %(summary)s")
+
+        if canceled:
+            inviteLabel = _("Event Canceled")
+        else:
+            if inviteState == "new":
+                inviteLabel = _("Event Invitation")
+            elif inviteState == "update":
+                inviteLabel = _("Event Update")
+            else:
+                inviteLabel = _("Event Reply")
+
+        labels = dict(
+            dateLabel = _("Date"),
+            timeLabel = _("Time"),
+            durationLabel = _("Duration"),
+            recurrenceLabel = _("Occurs"),
+            descLabel = _("Description"),
+            orgLabel = _("Organizer"),
+            attLabel = _("Attendees"),
+            locLabel = _("Location"),
+            inviteLabel = inviteLabel,
+        )
+
+        # The translations we get back from gettext are utf-8 encoded
+        # strings, so convert to unicode
+        for key in labels.keys():
+            if isinstance(labels[key], str):
+                labels[key] = labels[key].decode("utf-8")
+
+    return subjectFormatString.decode("utf-8"), labels
+
+#
 # Mail gateway service config
 #
 
@@ -189,7 +381,9 @@
         """
         assert parent is not None
 
-        CalDAVResource.__init__(self, principalCollections=parent.principalCollections())
+        CalDAVResource.__init__(
+            self, principalCollections=parent.principalCollections()
+        )
 
         self.parent = parent
         self._newStore = store
@@ -203,7 +397,8 @@
             self.iMIPACL = davxml.ACL(
                 davxml.ACE(
                     davxml.Principal(
-                        davxml.HRef.fromString("/principals/__uids__/%s/" % (guid,))
+                        davxml.HRef.fromString("/principals/__uids__/%s/"
+                                               % (guid,))
                     ),
                     davxml.Grant(
                         davxml.Privilege(caldavxml.ScheduleDeliver()),
@@ -270,7 +465,8 @@
         if config.Scheduling.CalDAV.OldDraftCompatibility:
             privs += (davxml.Privilege(caldavxml.Schedule()),)
         return davxml.ACL(
-            # DAV:Read, CalDAV:schedule-deliver for all principals (includes anonymous)
+            # DAV:Read, CalDAV:schedule-deliver for all principals (includes
+            # anonymous)
             davxml.ACE(
                 davxml.Principal(davxml.All()),
                 davxml.Grant(*privs),
@@ -578,7 +774,9 @@
             client.setServiceParent(mailGatewayService)
 
             # Set up /inbox -- server POSTs to it to send out iMIP invites
-            IScheduleService(settings, mailer).setServiceParent(mailGatewayService)
+            IScheduleService(settings, mailer).setServiceParent(
+                mailGatewayService
+            )
 
         else:
             mailer = None
@@ -619,13 +817,16 @@
 
 class MailHandler(LoggingMixIn):
 
-    def __init__(self, dataRoot=None):
+    def __init__(self, dataRoot=None, directory=None):
         if dataRoot is None:
             dataRoot = config.DataRoot
+        if directory is None:
+            directory = directoryFromConfig(config)
         self.db = MailGatewayTokensDatabase(dataRoot)
         self.days = config.Scheduling['iMIP']['InvitationDaysToLive']
-        self.directory = directoryFromConfig(config)
+        self.directory = directory
 
+
     def purge(self):
         """
         Purge old database tokens
@@ -633,6 +834,7 @@
         self.db.purgeOldTokens(datetime.date.today() -
             datetime.timedelta(days=self.days))
 
+
     def checkDSN(self, message):
         # returns (isDSN, Action, icalendar attachment)
 
@@ -681,6 +883,7 @@
         except ValueError:
             return None
 
+
     def processDSN(self, calBody, msgId, fn):
         calendar = ical.Component.fromString(calBody)
         # Extract the token (from organizer property)
@@ -693,7 +896,8 @@
         result = self.db.lookupByToken(token)
         if result is None:
             # This isn't a token we recognize
-            self.log_error("Mail gateway found a token (%s) but didn't recognize it in DSN %s" % (token, msgId))
+            self.log_error("Mail gateway found a token (%s) but didn't "
+                           "recognize it in DSN %s" % (token, msgId))
             return
 
         organizer, attendee, icaluid = result
@@ -722,16 +926,20 @@
             # addr looks like: server_address+token at example.com
             token = self._extractToken(addr)
             if not token:
-                self.log_error("Mail gateway didn't find a token in message %s (%s)" % (msg['Message-ID'], msg['To']))
+                self.log_error("Mail gateway didn't find a token in message "
+                               "%s (%s)" % (msg['Message-ID'], msg['To']))
                 return
         else:
-            self.log_error("Mail gateway couldn't parse To: address (%s) in message %s" % (msg['To'], msg['Message-ID']))
+            self.log_error("Mail gateway couldn't parse To: address (%s) in "
+                           "message %s" % (msg['To'], msg['Message-ID']))
             return
 
         result = self.db.lookupByToken(token)
         if result is None:
             # This isn't a token we recognize
-            self.log_error("Mail gateway found a token (%s) but didn't recognize it in message %s" % (token, msg['Message-ID']))
+            self.log_error("Mail gateway found a token (%s) but didn't "
+                           "recognize it in message %s"
+                           % (token, msg['Message-ID']))
             return
 
         organizer, attendee, icaluid = result
@@ -745,7 +953,8 @@
                 break
         else:
             # No icalendar attachment
-            self.log_warn("Mail gateway didn't find an icalendar attachment in message %s" % (msg['Message-ID'],))
+            self.log_warn("Mail gateway didn't find an icalendar attachment "
+                          "in message %s" % (msg['Message-ID'],))
 
             toAddr = None
             fromAddr = attendee[7:]
@@ -759,7 +968,8 @@
                     toAddr = list(record.emailAddresses)[0]
 
             if toAddr is None:
-                self.log_error("Don't have an email address for the organizer; ignoring reply.")
+                self.log_error("Don't have an email address for the organizer; "
+                               "ignoring reply.")
                 return
 
             if testMode:
@@ -793,7 +1003,6 @@
             _reactor.connectTCP(settings["Server"], settings["Port"], factory)
             return deferred
 
-
         # Process the imip attachment; inject to calendar server
 
         self.log_debug(calBody)
@@ -804,7 +1013,8 @@
         organizerProperty = calendar.getOrganizerProperty()
         if organizerProperty is None:
             # ORGANIZER is required per rfc2446 section 3.2.3
-            self.log_warn("Mail gateway didn't find an ORGANIZER in REPLY %s" % (msg['Message-ID'],))
+            self.log_warn("Mail gateway didn't find an ORGANIZER in REPLY %s"
+                          % (msg['Message-ID'],))
             event.addProperty(Property("ORGANIZER", organizer))
         else:
             organizerProperty.setValue(organizer)
@@ -839,7 +1049,8 @@
                     return self.processDSN(calBody, msg['Message-ID'], fn)
                 else:
                     # It's a DSN without enough to go on
-                    self.log_error("Mail gateway can't process DSN %s" % (msg['Message-ID'],))
+                    self.log_error("Mail gateway can't process DSN %s"
+                                   % (msg['Message-ID'],))
                     return
 
             self.log_info("Mail gateway received message %s from %s to %s" %
@@ -852,9 +1063,8 @@
             self.log_error("Failed to process message: %s" % (e,))
 
 
-
-
-    def outbound(self, originator, recipient, calendar, language='en', send=True):
+    def outbound(self, originator, recipient, calendar, language='en',
+                 send=True):
         # create token, send email
 
         component = calendar.masterComponent()
@@ -891,7 +1101,8 @@
         recipient = recipient.lower()
         toAddr = recipient
         if not recipient.startswith("mailto:"):
-            raise ValueError("ATTENDEE address '%s' must be mailto: for iMIP operation." % (recipient,))
+            raise ValueError("ATTENDEE address '%s' must be mailto: for iMIP "
+                             "operation." % (recipient,))
         recipient = recipient[7:]
 
         settings = config.Scheduling['iMIP']['Sending']
@@ -904,10 +1115,14 @@
             token = self.db.getToken(originator, toAddr, icaluid)
             if token is None:
                 token = self.db.createToken(originator, toAddr, icaluid)
-                self.log_debug("Mail gateway created token %s for %s (originator), %s (recipient) and %s (icaluid)" % (token, originator, toAddr, icaluid))
+                self.log_debug("Mail gateway created token %s for %s "
+                               "(originator), %s (recipient) and %s (icaluid)"
+                               % (token, originator, toAddr, icaluid))
                 inviteState = "new"
             else:
-                self.log_debug("Mail gateway reusing token %s for %s (originator), %s (recipient) and %s (icaluid)" % (token, originator, toAddr, icaluid))
+                self.log_debug("Mail gateway reusing token %s for %s "
+                               "(originator), %s (recipient) and %s (icaluid)"
+                               % (token, originator, toAddr, icaluid))
                 inviteState = "update"
 
             fullServerAddress = settings['Address']
@@ -916,15 +1131,18 @@
             addressWithToken = "%s+%s@%s" % (pre, token, post)
 
             organizerProperty = calendar.getOrganizerProperty()
-            organizerEmailAddress = organizerProperty.parameterValue("EMAIL", None)
+            organizerEmailAddress = organizerProperty.parameterValue("EMAIL",
+                                                                     None)
             organizerValue = organizerProperty.value()
             organizerProperty.setValue("mailto:%s" % (addressWithToken,))
 
             # If the organizer is also an attendee, update that attendee value
             # to match
-            organizerAttendeeProperty = calendar.getAttendeeProperty([organizerValue])
+            organizerAttendeeProperty = calendar.getAttendeeProperty(
+                [organizerValue])
             if organizerAttendeeProperty is not None:
-                organizerAttendeeProperty.setValue("mailto:%s" % (addressWithToken,))
+                organizerAttendeeProperty.setValue("mailto:%s" %
+                                                   (addressWithToken,))
 
             # The email's From will include the originator's real name email
             # address if available.  Otherwise it will be the server's email
@@ -953,16 +1171,22 @@
 
             # Look up the attendee property corresponding to the originator
             # of this reply
-            originatorAttendeeProperty = calendar.getAttendeeProperty([originator])
+            originatorAttendeeProperty = calendar.getAttendeeProperty(
+                [originator])
             formattedFrom = fromAddr = originator = ""
             if originatorAttendeeProperty:
-                originatorAttendeeEmailAddress = originatorAttendeeProperty.parameterValue("EMAIL", None)
+                originatorAttendeeEmailAddress = (
+                    originatorAttendeeProperty.parameterValue("EMAIL", None)
+                )
                 if originatorAttendeeEmailAddress:
-                    formattedFrom = fromAddr = originator = originatorAttendeeEmailAddress
+                    formattedFrom = fromAddr = originator = (
+                        originatorAttendeeEmailAddress
+                    )
 
             organizerMailto = str(calendar.getOrganizer())
             if not organizerMailto.lower().startswith("mailto:"):
-                raise ValueError("ORGANIZER address '%s' must be mailto: for REPLY." % (organizerMailto,))
+                raise ValueError("ORGANIZER address '%s' must be mailto: "
+                                 "for REPLY." % (organizerMailto,))
             orgEmail = organizerMailto[7:]
 
             orgCN = calendar.getOrganizerProperty().parameterValue('CN', None)
@@ -993,8 +1217,10 @@
                 return True
 
             def _failure(failure, msgId, fromAddr, toAddr):
-                self.log_error("Mail gateway failed to send message %s from %s to %s (Reason: %s)" %
-                    (msgId, fromAddr, toAddr, failure.getErrorMessage()))
+                self.log_error("Mail gateway failed to send message %s from %s "
+                               "to %s (Reason: %s)" %
+                               (msgId, fromAddr, toAddr,
+                                failure.getErrorMessage()))
                 return False
 
             deferred = defer.Deferred()
@@ -1004,7 +1230,8 @@
             else:
                 contextFactory = None
 
-            factory = ESMTPSenderFactory(settings['Username'], settings['Password'],
+            factory = ESMTPSenderFactory(
+                settings['Username'], settings['Password'],
                 fromAddr, toAddr, StringIO(str(message)), deferred,
                 contextFactory=contextFactory,
                 requireAuthentication=False,
@@ -1046,192 +1273,96 @@
 
 
     def generateEmail(self, inviteState, calendar, orgEmail, orgCN,
-        attendees, fromAddress, replyToAddress, toAddress, language='en'):
+                      attendees, fromAddress, replyToAddress, toAddress,
+                      language='en'):
+        """
+        Generate MIME text containing an iMIP invitation, cancellation, update
+        or reply.
 
-        details = self.getEventDetails(calendar, language=language)
-        canceled = (calendar.propertyValue("METHOD") == "CANCEL")
-        iconPath = self.getIconPath(details, canceled, language=language)
+        @param inviteState: 'new', 'update', or 'reply'.
 
-        with translationTo(language):
-            msg = MIMEMultipart()
-            msg["From"] = fromAddress
-            msg["Reply-To"] = replyToAddress
-            msg["To"] = toAddress
-            msg["Date"] = rfc822date()
-            msgId = messageid()
-            msg["Message-ID"] = msgId
+        @type inviteState: C{str}
 
-            if canceled:
-                formatString = _("Event canceled: %(summary)s")
-            elif inviteState == "new":
-                formatString = _("Event invitation: %(summary)s")
-            elif inviteState == "update":
-                formatString = _("Event update: %(summary)s")
-            else:
-                formatString = _("Event reply: %(summary)s")
+        @param calendar: the iCalendar component to attach to the email.
 
-            # The translations we get back from gettext are utf-8 encoded
-            # strings, so convert to unicode
-            formatString = formatString.decode("utf-8")
+        @type calendar: L{twistedcaldav.ical.Component}
 
-            details['subject'] = msg['Subject'] = formatString % {
-                'summary' : details['summary']
-            }
+        @param orgEmail: The email for the organizer, in C{localhost at domain}
+            format, or C{None} if the organizer has no email address.
 
-            msgAlt = MIMEMultipart("alternative")
-            msg.attach(msgAlt)
+        @type orgEmail: C{str} or C{NoneType}
 
-            # Get localized labels
-            if canceled:
-                details['inviteLabel'] = _("Event Canceled")
-            else:
-                if inviteState == "new":
-                    details['inviteLabel'] = _("Event Invitation")
-                elif inviteState == "update":
-                    details['inviteLabel'] = _("Event Update")
-                else:
-                    details['inviteLabel'] = _("Event Reply")
+        @param orgCN: Common name / display name for the organizer.
 
-            details['dateLabel'] = _("Date")
-            details['timeLabel'] = _("Time")
-            details['durationLabel'] = _("Duration")
-            details['recurrenceLabel'] = _("Occurs")
-            details['descLabel'] = _("Description")
-            details['orgLabel'] = _("Organizer")
-            details['attLabel'] = _("Attendees")
-            details['locLabel'] = _("Location")
+        @type orgCN: C{unicode}
 
+        @param attendees: A C{list} of 2-C{tuple}s of (common name, email
+            address) similar to (orgEmail, orgCN).
 
-            plainAttendeeList = []
-            for cn, mailto in attendees:
-                if cn:
-                    plainAttendeeList.append(cn if not mailto else
-                        "%s <%s>" % (cn, mailto))
-                elif mailto:
-                    plainAttendeeList.append("<%s>" % (mailto,))
+        @param fromAddress: the address to use in the C{From:} header of the
+            email.
 
-            details['plainAttendees'] = ", ".join(plainAttendeeList)
+        @type fromAddress: C{str}
 
-            details['plainOrganizer'] = (orgCN if not orgEmail else
-                "%s <%s>" % (orgCN, orgEmail))
+        @param replyToAddress: the address to use in the C{Reply-To} header.
 
-            # The translations we get back from gettext are utf-8 encoded
-            # strings, so convert to unicode
-            for key in details.keys():
-                if isinstance(details[key], str):
-                    details[key] = details[key].decode("utf-8")
+        @type replyToAddress: C{str}
 
-            # plain text version
-            if canceled:
-                plainTemplate = u"""%(subject)s
+        @param toAddress: the address to use in the C{To} header.
 
-%(orgLabel)s: %(plainOrganizer)s
-%(dateLabel)s: %(dateInfo)s %(recurrenceInfo)s
-%(timeLabel)s: %(timeInfo)s %(durationInfo)s
-"""
-            else:
-                plainTemplate = u"""%(subject)s
+        @type toAddress: C{str}
 
-%(orgLabel)s: %(plainOrganizer)s
-%(locLabel)s: %(location)s
-%(dateLabel)s: %(dateInfo)s %(recurrenceInfo)s
-%(timeLabel)s: %(timeInfo)s %(durationInfo)s
-%(descLabel)s: %(description)s
-%(attLabel)s: %(plainAttendees)s
-"""
+        @param language: a 2-letter language code describing the target
+            language that the email should be generated in.
 
-            plainText = plainTemplate % details
+        @type language: C{str}
 
-            msgPlain = MIMEText(plainText.encode("UTF-8"), "plain", "UTF-8")
-            msgAlt.attach(msgPlain)
+        @return: a 2-tuple of C{str}s: (message ID, message text).  The message
+            ID is the value of the C{Message-ID} header, and the message text is
+            the full MIME message, ready for transport over SMTP.
+        """
 
-            # html version
-            msgHtmlRelated = MIMEMultipart("related", type="text/html")
-            msgAlt.attach(msgHtmlRelated)
+        details = self.getEventDetails(calendar, language=language)
+        canceled = (calendar.propertyValue("METHOD") == "CANCEL")
+        iconPath = self.getIconPath(details, canceled, language=language)
 
+        subjectFormat, labels = localizedLabels(language, canceled, inviteState)
+        details.update(labels)
 
-            htmlAttendees = []
-            for cn, mailto in attendees:
-                if mailto:
-                    htmlAttendees.append('<a href="mailto:%s">%s</a>' %
-                        (mailto, cn))
-                else:
-                    htmlAttendees.append(cn)
+        details['subject'] = subjectFormat % {'summary' : details['summary']}
+        details['iconName'] = iconName = "calicon.png"
 
-            details['htmlAttendees'] = ", ".join(htmlAttendees)
+        plainText = self.renderPlainText(details, (orgCN, orgEmail),
+                                         attendees, canceled)
 
-            if orgEmail:
-                details['htmlOrganizer'] = '<a href="mailto:%s">%s</a>' % (
-                    orgEmail, orgCN)
-            else:
-                details['htmlOrganizer'] = orgCN
+        [addIcon, htmlText] = self.renderHTML(details, (orgCN, orgEmail),
+                                              attendees, canceled)
 
-            details['iconName'] = iconName = "calicon.png"
+        msg = MIMEMultipart()
+        msg["From"] = fromAddress
+        msg["Subject"] = details['subject']
+        msg["Reply-To"] = replyToAddress
+        msg["To"] = toAddress
+        msg["Date"] = rfc822date()
+        msgId = messageid()
+        msg["Message-ID"] = msgId
 
-            templateDir = config.Scheduling.iMIP.MailTemplatesDirectory.rstrip("/")
-            templateName = "cancel.html" if canceled else "invite.html"
-            templatePath = os.path.join(templateDir, templateName)
+        msgAlt = MIMEMultipart("alternative")
+        msg.attach(msgAlt)
 
-            if not os.path.exists(templatePath):
-                # Fall back to built-in simple templates:
-                if canceled:
+        # plain version
+        msgPlain = MIMEText(plainText, "plain", "UTF-8")
+        msgAlt.attach(msgPlain)
 
-                    htmlTemplate = u"""<html>
-    <body><div>
+        # html version
+        msgHtmlRelated = MIMEMultipart("related", type="text/html")
+        msgAlt.attach(msgHtmlRelated)
 
-    <h1>%(subject)s</h1>
-    <p>
-    <h3>%(orgLabel)s:</h3> %(htmlOrganizer)s
-    </p>
-    <p>
-    <h3>%(dateLabel)s:</h3> %(dateInfo)s %(recurrenceInfo)s
-    </p>
-    <p>
-    <h3>%(timeLabel)s:</h3> %(timeInfo)s %(durationInfo)s
-    </p>
-
-    """
-
-                else:
-
-                    htmlTemplate = u"""<html>
-    <body><div>
-    <p>%(inviteLabel)s</p>
-
-    <h1>%(summary)s</h1>
-    <p>
-    <h3>%(orgLabel)s:</h3> %(htmlOrganizer)s
-    </p>
-    <p>
-    <h3>%(locLabel)s:</h3> %(location)s
-    </p>
-    <p>
-    <h3>%(dateLabel)s:</h3> %(dateInfo)s %(recurrenceInfo)s
-    </p>
-    <p>
-    <h3>%(timeLabel)s:</h3> %(timeInfo)s %(durationInfo)s
-    </p>
-    <p>
-    <h3>%(descLabel)s:</h3> %(description)s
-    </p>
-    <p>
-    <h3>%(attLabel)s:</h3> %(htmlAttendees)s
-    </p>
-
-    """
-            else: # HTML template file exists
-
-                with open(templatePath) as templateFile:
-                    htmlTemplate = templateFile.read()
-
-            htmlText = htmlTemplate % details
-
-        msgHtml = MIMEText(htmlText.encode("UTF-8"), "html", "UTF-8")
+        msgHtml = MIMEText(htmlText, "html", "UTF-8")
         msgHtmlRelated.attach(msgHtml)
 
         # an image for html version
-        if (iconPath != None and os.path.exists(iconPath) and
-            htmlTemplate.find("cid:%(iconName)s") != -1):
+        if addIcon and iconPath != None and os.path.exists(iconPath):
 
             with open(iconPath) as iconFile:
                 msgIcon = MIMEImage(iconFile.read(),
@@ -1243,9 +1374,11 @@
                 (iconName,))
             msgHtmlRelated.attach(msgIcon)
 
+        calendarText = str(calendar)
         # the icalendar attachment
-        self.log_debug("Mail gateway sending calendar body: %s" % (str(calendar)))
-        msgIcal = MIMEText(str(calendar), "calendar", "UTF-8")
+        self.log_debug("Mail gateway sending calendar body: %s"
+                       % (calendarText,))
+        msgIcal = MIMEText(calendarText, "calendar", "UTF-8")
         method = calendar.propertyValue("METHOD").lower()
         msgIcal.set_param("method", method)
         msgIcal.add_header("Content-ID", "<invitation.ics>")
@@ -1256,41 +1389,154 @@
         return msgId, msg.as_string()
 
 
+    def renderPlainText(self, details, (orgCN, orgEmail), attendees, canceled):
+        """
+        Render text/plain message part based on invitation details and a flag
+        indicating whether the message is a cancellation.
+        """
+        plainAttendeeList = []
+        for cn, mailto in attendees:
+            if cn:
+                plainAttendeeList.append(cn if not mailto else
+                    "%s <%s>" % (cn, mailto))
+            elif mailto:
+                plainAttendeeList.append("<%s>" % (mailto,))
+
+        details['plainAttendees'] = ", ".join(plainAttendeeList)
+
+        details['plainOrganizer'] = (orgCN if not orgEmail else
+            "%s <%s>" % (orgCN, orgEmail))
+
+        # plain text version
+        if canceled:
+            plainTemplate = plainCancelTemplate
+        else:
+            plainTemplate = plainInviteTemplate
+
+        return plainTemplate % details
+
+
+    def renderHTML(self, details, organizer, attendees, canceled):
+        """
+        Render HTML message part based on invitation details and a flag
+        indicating whether the message is a cancellation.
+
+        @return: a 2-tuple of (should add icon (C{bool}), html text (C{str})).
+            The first element indicates whether the MIME generator needs to add
+            a L{cid:} icon image part to satisfy the HTML links.
+        """
+        orgCN, orgEmail = organizer
+
+        # TODO: htmlAttendees needs to be a separate element with a separate
+        # template fragment.  Luckily that fragment is the same regardless
+        # of the rest of the template.
+        htmlAttendees = []
+        first = True
+        for cn, mailto in attendees:
+            if not first:
+                htmlAttendees.append(u", ")
+            else:
+                first = False
+
+            if mailto:
+                if not cn:
+                    cn = mailto
+                htmlAttendees.append(
+                    tags.a(href="mailto:%s" % (mailto,))(cn)
+                )
+            else:
+                htmlAttendees.append(cn)
+
+        details['htmlAttendees'] = htmlAttendees
+
+        # TODO: htmlOrganizer is also some HTML that requires additional
+        # template stuff, and once again, it's just a 'mailto:'.
+        # tags.a(href="mailto:"+email)[cn]
+        if orgEmail:
+            details['htmlOrganizer'] = tags.a(href="mailto:%s" % (orgEmail,))(
+                orgCN)
+        else:
+            details['htmlOrganizer'] = orgCN
+
+        templateDir = config.Scheduling.iMIP.MailTemplatesDirectory.rstrip("/")
+        templateName = "cancel.html" if canceled else "invite.html"
+        templatePath = os.path.join(templateDir, templateName)
+
+        if not os.path.exists(templatePath):
+            # Fall back to built-in simple templates:
+            if canceled:
+                htmlTemplate = htmlCancelTemplate
+            else:
+                htmlTemplate = htmlInviteTemplate
+        else: # HTML template file exists
+
+            with open(templatePath) as templateFile:
+                htmlTemplate = templateFile.read()
+
+        class EmailElement(Element):
+            loader = StringFormatTemplateLoader(lambda : StringIO(htmlTemplate),
+                                                "email")
+
+            @renderer
+            def email(self, request, tag):
+                return tag.fillSlots(**details)
+
+        textCollector = []
+        flattenString(None, EmailElement()).addCallback(textCollector.append)
+        htmlText = textCollector[0]
+
+        # If the template refers to an icon in a cid: link, it needs to be added
+        # in the MIME.
+        addIcon = (htmlTemplate.find("cid:%(iconName)s") != -1)
+        return (addIcon, htmlText)
+
+
     def getEventDetails(self, calendar, language='en'):
+        """
+        Create a dictionary mapping slot names - specifically: summary,
+        description, location, dateInfo, timeInfo, durationInfo, recurrenceInfo
+        - with localized string values that should be placed into the HTML and
+        plain-text templates.
 
+        @param calendar: a L{Component} upon which to base the language.
+
+        @type calendar: L{Component}
+
+        @param language: a 2-letter language code.
+
+        @type language: C{str}
+
+        @return: a mapping from template slot name to localized text.
+
+        @rtype: a C{dict} mapping C{bytes} to C{unicode}.
+        """
+
         # Get the most appropriate component
         component = calendar.masterComponent()
         if component is None:
             component = calendar.mainComponent(True)
 
-        results = { }
+        results = {}
 
         dtStart = component.propertyValue("DTSTART")
         results['month'] = dtStart.getMonth()
         results['day'] = dtStart.getDay()
 
-        summary = component.propertyValue("SUMMARY").decode("utf-8")
-        if summary is None:
-            summary = ""
-        results['summary'] = summary
+        for propertyToResult in ['summary', 'description', 'location']:
+            result = component.propertyValue(propertyToResult.upper())
+            if result is None:
+                result = u""
+            else:
+                result = result.decode('utf-8')
+            results[propertyToResult] = result
 
-        description = component.propertyValue("DESCRIPTION")
-        if description is None:
-            description = ""
-        results['description'] = description
-
-        location = component.propertyValue("LOCATION")
-        if location is None:
-            location = ""
-        results['location'] = location
-
         with translationTo(language) as trans:
             results['dateInfo'] = trans.date(component)
             results['timeInfo'], duration = trans.time(component)
             results['durationInfo'] = "(%s)" % (duration,) if duration else ""
 
             for propertyName in ("RRULE", "RDATE", "EXRULE", "EXDATE",
-                "RECURRENCE-ID"):
+                                 "RECURRENCE-ID"):
                 if component.hasProperty(propertyName):
                     results['recurrenceInfo'] = _("(Repeating)")
                     break
@@ -1301,10 +1547,6 @@
 
 
 
-
-
-
-
 #
 # POP3
 #
@@ -1499,7 +1741,8 @@
                 (self.messageCount - len(self.messageUIDs), self.messageCount,
                 nextUID))
             self.fetchMessage(messageListToFetch, True).addCallback(
-                self.cbGotMessage, messageListToFetch).addErrback(self.ebLogError)
+                self.cbGotMessage, messageListToFetch).addErrback(
+                    self.ebLogError)
         else:
             self.log_debug("Seeing if anything new has arrived")
             # Go back and see if any more messages have come in
@@ -1566,9 +1809,10 @@
 
     def buildProtocol(self, addr):
         p = protocol.ClientFactory.buildProtocol(self, addr)
-        p.registerAuthenticator(imap4.CramMD5ClientAuthenticator(self.settings["Username"]))
-        p.registerAuthenticator(imap4.LOGINAuthenticator(self.settings["Username"]))
-        p.registerAuthenticator(imap4.PLAINAuthenticator(self.settings["Username"]))
+        username = self.settings["Username"]
+        p.registerAuthenticator(imap4.CramMD5ClientAuthenticator(username))
+        p.registerAuthenticator(imap4.LOGINAuthenticator(username))
+        p.registerAuthenticator(imap4.PLAINAuthenticator(username))
         return p
 
     def handleMessage(self, message):

Modified: CalendarServer/trunk/twistedcaldav/test/test_mail.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/test/test_mail.py	2011-08-12 04:56:02 UTC (rev 7984)
+++ CalendarServer/trunk/twistedcaldav/test/test_mail.py	2011-08-12 05:15:28 UTC (rev 7985)
@@ -14,33 +14,73 @@
 # limitations under the License.
 ##
 
+
+from cStringIO import StringIO
+import datetime
+import email
+
+from twisted.internet.defer import inlineCallbacks
+
+from twisted.web.template import Element, renderer, flattenString
+from twisted.python.modules import getModule
+from twisted.python.filepath import FilePath
+
 from twistedcaldav.test.util import TestCase
+
 from twistedcaldav.ical import Component
 from twistedcaldav.config import config
 from twistedcaldav.scheduling.itip import iTIPRequestStatus
 
-
-from twisted.internet.defer import inlineCallbacks
-import email
 from twistedcaldav.mail import MailHandler
+from twistedcaldav.mail import StringFormatTemplateLoader
 from twistedcaldav.mail import MailGatewayTokensDatabase
+
 from twistedcaldav.directory.directory import DirectoryRecord
-import os
-import datetime
 
 
 def echo(*args):
     return args
 
+initialInviteText = u"""BEGIN:VCALENDAR
+VERSION:2.0
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:CFDD5E46-4F74-478A-9311-B3FF905449C3
+DTSTART:20100325T154500Z
+DTEND:20100325T164500Z
+ATTENDEE;CN=Th\xe9 Attendee;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;RSVP=TRU
+ E:mailto:attendee at example.com
+ATTENDEE;CN=Th\xe9 Organizer;CUTYPE=INDIVIDUAL;EMAIL=organizer at example.com;P
+ ARTSTAT=ACCEPTED:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
+ATTENDEE;CN=An Attendee without CUTYPE;EMAIL=nocutype at example.com;PARTSTAT=A
+ CCEPTED:urn:uuid:4DB528DC-3E60-44FA-9546-2A00FCDCFFAB
+ATTENDEE;EMAIL=nocn at example.com;PARTSTAT=ACCEPTED:urn:uuid:A592CF8B-4FC8-4E4
+ F-B543-B2F29A7EEB0B
+ORGANIZER;CN=Th\xe9 Organizer;EMAIL=organizer at example.com:urn:uuid:C3B38B00-
+ 4166-11DD-B22C-A07C87E02F6A
+SUMMARY:t\xe9sting outbound( )
+DESCRIPTION:awesome description with "<" and "&"
+END:VEVENT
+END:VCALENDAR
+"""
 
 class MailHandlerTests(TestCase):
 
     def setUp(self):
         TestCase.setUp(self)
         self.handler = MailHandler(dataRoot=":memory:")
-        self.dataDir = os.path.join(os.path.dirname(__file__), "data", "mail")
+        module = getModule(__name__)
+        self.dataPath = module.filePath.sibling("data").child("mail")
 
 
+    def dataFile(self, name):
+        """
+        Get the contents of a given data file from the 'data/mail' test
+        fixtures directory.
+        """
+        return self.dataPath.child(name).getContent()
+
+
     def test_purge(self):
         """
         Ensure that purge( ) cleans out old tokens
@@ -67,27 +107,69 @@
 
 
     def test_iconPath(self):
-        iconPath = self.handler.getIconPath({'day':'1', 'month':'1'}, False, language='en')
-        iconDir = "/usr/share/caldavd/share/date_icons"
-        if os.path.exists(iconDir):
-            if os.path.exists("%s/JAN/01.png" % (iconDir,)):
-                self.assertEquals(iconPath, "%s/JAN/01.png" % (iconDir,))
+        iconPath = self.handler.getIconPath({'day':'1', 'month':'1'}, False,
+                                            language='en')
+        iconDir = FilePath("/usr/share/caldavd/share/date_icons")
+
+        if iconDir.exists():
+            if iconDir.child("JAN").child("01.png"):
+                monthName = "JAN"
             else:
-                self.assertEquals(iconPath, "%s/01/01.png" % (iconDir,))
+                monthName = "01"
+            monthPath = iconDir.child(monthName)
+            self.assertEquals(iconPath, monthPath.child("01.png").path)
 
+
     def test_checkDSNFailure(self):
 
         data = {
             'good_reply' : (False, None, None),
             'dsn_failure_no_original' : (True, 'failed', None),
             'dsn_failure_no_ics' : (True, 'failed', None),
-            'dsn_failure_with_ics' : (True, 'failed', 'BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\nMETHOD:REQUEST\nPRODID:-//example Inc.//iCal 3.0//EN\nBEGIN:VTIMEZONE\nTZID:US/Pacific\nBEGIN:STANDARD\nDTSTART:20071104T020000\nRRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU\nTZNAME:PST\nTZOFFSETFROM:-0700\nTZOFFSETTO:-0800\nEND:STANDARD\nBEGIN:DAYLIGHT\nDTSTART:20070311T020000\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU\nTZNAME:PDT\nTZOFFSETFROM:-0800\nTZOFFSETTO:-0700\nEND:DAYLIGHT\nEND:VTIMEZONE\nBEGIN:VEVENT\nUID:1E71F9C8-AEDA-48EB-98D0-76E898F6BB5C\nDTSTART;TZID=US/Pacific:20080812T094500\nDTEND;TZID=US/Pacific:20080812T104500\nATTENDEE;CUTYPE=INDIVIDUAL;CN=User 01;PARTSTAT=ACCEPTED:mailto:user01 at exam\n ple.com\nATTENDEE;CUTYPE=INDIVIDUAL;RSVP=TRUE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-A\n CTION;CN=nonexistant at example.com:mailto:nonexistant at example.com\nCREATED:20080812T191857Z\nDTSTAMP:20080812T191932Z\nORGANIZER;CN=User 01:mailto:xyzzy+8e16b897-d544-4217-88e9-a363d08\n 46f6c at example.com\nSEQUENCE:2\nSUMMARY:New Event\nTRANSP:OPAQUE\nEND:VEVENT\nEND:VCALENDAR\n'),
+            'dsn_failure_with_ics' : (True, 'failed', '''BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+PRODID:-//example Inc.//iCal 3.0//EN
+BEGIN:VTIMEZONE
+TZID:US/Pacific
+BEGIN:STANDARD
+DTSTART:20071104T020000
+RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
+TZNAME:PST
+TZOFFSETFROM:-0700
+TZOFFSETTO:-0800
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20070311T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
+TZNAME:PDT
+TZOFFSETFROM:-0800
+TZOFFSETTO:-0700
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+UID:1E71F9C8-AEDA-48EB-98D0-76E898F6BB5C
+DTSTART;TZID=US/Pacific:20080812T094500
+DTEND;TZID=US/Pacific:20080812T104500
+ATTENDEE;CUTYPE=INDIVIDUAL;CN=User 01;PARTSTAT=ACCEPTED:mailto:user01 at exam
+ ple.com
+ATTENDEE;CUTYPE=INDIVIDUAL;RSVP=TRUE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-A
+ CTION;CN=nonexistant at example.com:mailto:nonexistant at example.com
+CREATED:20080812T191857Z
+DTSTAMP:20080812T191932Z
+ORGANIZER;CN=User 01:mailto:xyzzy+8e16b897-d544-4217-88e9-a363d08
+ 46f6c at example.com
+SEQUENCE:2
+SUMMARY:New Event
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
+'''),
         }
 
         for filename, expected in data.iteritems():
-            msg = email.message_from_string(
-                file(os.path.join(self.dataDir, filename)).read()
-            )
+            msg = email.message_from_string(self.dataFile(filename))
             self.assertEquals(self.handler.checkDSN(msg), expected)
 
 
@@ -185,43 +267,56 @@
         self.assertEquals(msgId, 'xyzzy')
 
 
-
     def test_processReply(self):
-        msg = email.message_from_string(
-            file(os.path.join(self.dataDir, 'good_reply')).read()
-        )
+        msg = email.message_from_string(self.dataFile('good_reply'))
 
         # Make sure an unknown token is not processed
         result = self.handler.processReply(msg, echo)
         self.assertEquals(result, None)
 
         # Make sure a known token *is* processed
-        self.handler.db.createToken("urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66", "mailto:xyzzy at example.com", icaluid="1E71F9C8-AEDA-48EB-98D0-76E898F6BB5C", token="d7cdf68d-8b73-4df1-ad3b-f08002fb285f")
+        self.handler.db.createToken(
+            "urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66",
+            "mailto:xyzzy at example.com",
+            icaluid="1E71F9C8-AEDA-48EB-98D0-76E898F6BB5C",
+            token="d7cdf68d-8b73-4df1-ad3b-f08002fb285f"
+        )
         organizer, attendee, calendar, msgId = self.handler.processReply(msg,
             echo)
-        self.assertEquals(organizer, 'urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66')
+        self.assertEquals(organizer,
+                          'urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66')
         self.assertEquals(attendee, 'mailto:xyzzy at example.com')
-        self.assertEquals(msgId, '<1983F777-BE86-4B98-881E-06D938E60920 at example.com>')
+        self.assertEquals(msgId,
+                          '<1983F777-BE86-4B98-881E-06D938E60920 at example.com>')
 
     def test_processReplyMissingOrganizer(self):
-        msg = email.message_from_string(
-            file(os.path.join(self.dataDir, 'reply_missing_organizer')).read()
+        msg = email.message_from_string(self.dataFile('reply_missing_organizer'))
+        # stick the token in the database first
+        self.handler.db.createToken(
+            "urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66",
+            "mailto:xyzzy at example.com",
+            icaluid="1E71F9C8-AEDA-48EB-98D0-76E898F6BB5C",
+            token="d7cdf68d-8b73-4df1-ad3b-f08002fb285f"
         )
-        # stick the token in the database first
-        self.handler.db.createToken("urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66", "mailto:xyzzy at example.com", icaluid="1E71F9C8-AEDA-48EB-98D0-76E898F6BB5C", token="d7cdf68d-8b73-4df1-ad3b-f08002fb285f")
 
         organizer, attendee, calendar, msgId = self.handler.processReply(msg,
             echo)
         organizerProp = calendar.mainComponent().getOrganizerProperty()
         self.assertTrue(organizerProp is not None)
-        self.assertEquals(organizer, "urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66")
+        self.assertEquals(organizer,
+                          "urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66")
 
+
     def test_processReplyMissingAttendee(self):
-        msg = email.message_from_string(
-            file(os.path.join(self.dataDir, 'reply_missing_attendee')).read()
+        msg = email.message_from_string(self.dataFile('reply_missing_attendee'))
+
+        # stick the token in the database first
+        self.handler.db.createToken(
+            "urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66",
+            "mailto:xyzzy at example.com",
+            icaluid="1E71F9C8-AEDA-48EB-98D0-76E898F6BB5C",
+            token="d7cdf68d-8b73-4df1-ad3b-f08002fb285f"
         )
-        # stick the token in the database first
-        self.handler.db.createToken("urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66", "mailto:xyzzy at example.com", icaluid="1E71F9C8-AEDA-48EB-98D0-76E898F6BB5C", token="d7cdf68d-8b73-4df1-ad3b-f08002fb285f")
 
         organizer, attendee, calendar, msgId = self.handler.processReply(msg,
             echo)
@@ -230,7 +325,8 @@
         # have added an attendee back in with a "5.1;Service unavailable"
         # schedule-status
         attendeeProp = calendar.mainComponent().getAttendeeProperty([attendee])
-        self.assertEquals(attendeeProp.parameterValue("SCHEDULE-STATUS"), iTIPRequestStatus.SERVICE_UNAVAILABLE)
+        self.assertEquals(attendeeProp.parameterValue("SCHEDULE-STATUS"),
+                          iTIPRequestStatus.SERVICE_UNAVAILABLE)
 
     def test_processReplyMissingAttachment(self):
 
@@ -239,13 +335,19 @@
             "9DC04A70-E6DD-11DF-9492-0800200C9A66", shortNames=("user01",),
             emailAddresses=("user01 at example.com",))
         record.enabled = True
-        self.handler.directory._tmpRecords["guids"]["9DC04A70-E6DD-11DF-9492-0800200C9A66"] = record
+        self.handler.directory._tmpRecords[
+            "guids"]["9DC04A70-E6DD-11DF-9492-0800200C9A66"] = record
 
         msg = email.message_from_string(
-            file(os.path.join(self.dataDir, 'reply_missing_attachment')).read()
+            self.dataFile('reply_missing_attachment')
         )
         # stick the token in the database first
-        self.handler.db.createToken("urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66", "mailto:xyzzy at example.com", icaluid="1E71F9C8-AEDA-48EB-98D0-76E898F6BB5C", token="d7cdf68d-8b73-4df1-ad3b-f08002fb285f")
+        self.handler.db.createToken(
+            "urn:uuid:9DC04A70-E6DD-11DF-9492-0800200C9A66",
+            "mailto:xyzzy at example.com",
+            icaluid="1E71F9C8-AEDA-48EB-98D0-76E898F6BB5C",
+            token="d7cdf68d-8b73-4df1-ad3b-f08002fb285f"
+        )
 
         self.assertEquals(
             self.handler.processReply(msg, echo, testMode=True),
@@ -264,22 +366,7 @@
         data = (
             # Initial invite
             (
-                u"""BEGIN:VCALENDAR
-VERSION:2.0
-METHOD:REQUEST
-BEGIN:VEVENT
-UID:CFDD5E46-4F74-478A-9311-B3FF905449C3
-DTSTART:20100325T154500Z
-DTEND:20100325T164500Z
-ATTENDEE;CN=Th\xe9 Attendee;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:attendee at example.com
-ATTENDEE;CN=Th\xe9 Organizer;CUTYPE=INDIVIDUAL;EMAIL=organizer at example.com;PARTSTAT=ACCEPTED:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
-ATTENDEE;CN=An Attendee without CUTYPE;EMAIL=nocutype at example.com;PARTSTAT=ACCEPTED:urn:uuid:4DB528DC-3E60-44FA-9546-2A00FCDCFFAB
-ATTENDEE;EMAIL=nocn at example.com;PARTSTAT=ACCEPTED:urn:uuid:A592CF8B-4FC8-4E4F-B543-B2F29A7EEB0B
-ORGANIZER;CN=Th\xe9 Organizer;EMAIL=organizer at example.com:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
-SUMMARY:t\xe9sting outbound( )
-END:VEVENT
-END:VCALENDAR
-""",
+                initialInviteText,
                 "CFDD5E46-4F74-478A-9311-B3FF905449C3",
                 "urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A",
                 "mailto:attendee at example.com",
@@ -305,9 +392,12 @@
 UID:CFDD5E46-4F74-478A-9311-B3FF905449C3
 DTSTART:20100325T154500Z
 DTEND:20100325T164500Z
-ATTENDEE;CN=The Attendee;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:attendee at example.com
-ATTENDEE;CN=The Organizer;CUTYPE=INDIVIDUAL;EMAIL=organizer at example.com;PARTSTAT=ACCEPTED:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
-ORGANIZER;CN=The Organizer;EMAIL=organizer at example.com:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
+ATTENDEE;CN=The Attendee;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:
+ mailto:attendee at example.com
+ATTENDEE;CN=The Organizer;CUTYPE=INDIVIDUAL;EMAIL=organizer at example.com;PAR
+ TSTAT=ACCEPTED:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
+ORGANIZER;CN=The Organizer;EMAIL=organizer at example.com:urn:uuid:C3B38B00-41
+ 66-11DD-B22C-A07C87E02F6A
 SUMMARY:testing outbound( ) *update*
 END:VEVENT
 END:VCALENDAR
@@ -335,8 +425,10 @@
 UID:DFDD5E46-4F74-478A-9311-B3FF905449C4
 DTSTART:20100325T154500Z
 DTEND:20100325T164500Z
-ATTENDEE;CN=The Attendee;CUTYPE=INDIVIDUAL;EMAIL=attendee at example.com;PARTSTAT=ACCEPTED:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
-ORGANIZER;CN=The Organizer;EMAIL=organizer at example.com:mailto:organizer at example.com
+ATTENDEE;CN=The Attendee;CUTYPE=INDIVIDUAL;EMAIL=attendee at example.com;PARTST
+ AT=ACCEPTED:urn:uuid:C3B38B00-4166-11DD-B22C-A07C87E02F6A
+ORGANIZER;CN=The Organizer;EMAIL=organizer at example.com:mailto:organizer at exam
+ ple.com
 SUMMARY:testing outbound( ) *reply*
 END:VEVENT
 END:VCALENDAR
@@ -399,18 +491,188 @@
                 self.assertEquals(actualReplyTo, actualFrom)
 
 
+    def generateSampleEmail(self):
+        """
+        Invoke L{MailHandler.generateEmail} and parse the result.
+        """
+        calendar = Component.fromString(initialInviteText)
+        msgID, msgTxt = self.handler.generateEmail(
+            inviteState='new',
+            calendar=calendar,
+            orgEmail=u"user01 at localhost",
+            orgCN=u"User Z\xe9ro One",
+            attendees=[(u"Us\xe9r One", "user01 at localhost"),
+                       (u"User 2", "user02 at localhost")],
+            fromAddress="user01 at localhost",
+            replyToAddress="imip-system at localhost",
+            toAddress="user03 at localhost",
+        )
+        message = email.message_from_string(msgTxt)
+        return msgID, message
+
+
+    def test_generateEmail(self):
+        """
+        L{MailHandler.generateEmail} generates a MIME-formatted email with a
+        text/plain part, a text/html part, and a text/calendar part.
+        """
+        msgID, message = self.generateSampleEmail()
+        self.assertEquals(message['Message-ID'], msgID)
+        expectedTypes = set(["text/plain", "text/html", "text/calendar"])
+        actualTypes = set([
+            part.get_content_type() for part in message.walk()
+            if not part.get_content_type().startswith("multipart/")
+        ])
+        self.assertEquals(actualTypes, expectedTypes)
+
+
+    def test_emailEncoding(self):
+        """
+        L{MailHandler.generateEmail} will preserve any non-ASCII characters
+        present in the fields that it formats in the message body.
+        """
+        msgID, message = self.generateSampleEmail()
+        textPart = partByType(message, "text/plain")
+        htmlPart = partByType(message, "text/html")
+
+        plainText = textPart.get_payload(decode=True).decode(
+            textPart.get_content_charset()
+        )
+        htmlText = htmlPart.get_payload(decode=True).decode(
+            htmlPart.get_content_charset()
+        )
+
+        self.assertIn(u"Us\u00e9r One", plainText)
+        self.assertIn(u'<a href="mailto:user01 at localhost">Us\u00e9r One</a>',
+                      htmlText)
+
+        # The same assertion, but with the organizer's form.
+        self.assertIn(
+            u'<a href="mailto:user01 at localhost">User Z\u00e9ro One</a>',
+            htmlText)
+
+
+    def test_emailQuoting(self):
+        """
+        L{MailHandler.generateEmail} will HTML-quote all relevant fields in the
+        HTML part, but not the text/plain part.
+        """
+        msgID, message = self.generateSampleEmail()
+        htmlPart = partByType(message, "text/html").get_payload(decode=True)
+        plainPart = partByType(message, "text/plain").get_payload(decode=True)
+        expectedPlain = 'awesome description with "<" and "&"'
+        expectedHTML = expectedPlain.replace("&", "&").replace("<", "<")
+
+        self.assertIn(expectedPlain, plainPart)
+        self.assertIn(expectedHTML, htmlPart)
+
+
+    def test_stringFormatTemplateLoader(self):
+        """
+        L{StringFormatTemplateLoader.load} will convert a template with
+        C{%(x)s}-format slots by converting it to a template with C{<t:slot
+        name="x" />} slots, and a renderer on the document element named
+        according to the constructor argument.
+        """
+        class StubElement(Element):
+            loader = StringFormatTemplateLoader(
+                lambda : StringIO(
+                    "<test><alpha>%(slot1)s</alpha>%(other)s</test>"
+                ),
+                "testRenderHere"
+            )
+
+            @renderer
+            def testRenderHere(self, request, tag):
+                return tag.fillSlots(slot1="hello",
+                                     other="world")
+        result = []
+        flattenString(None, StubElement()).addCallback(result.append)
+        self.assertEquals(result,
+                          ["<test><alpha>hello</alpha>world</test>"])
+
+
+    def test_templateLoaderWithAttributes(self):
+        """
+        L{StringFormatTemplateLoader.load} will convert a template with
+        C{%(x)s}-format slots inside attributes into t:attr elements containing
+        t:slot slots.
+        """
+        class StubElement(Element):
+            loader = StringFormatTemplateLoader(
+                lambda : StringIO(
+                    '<test><alpha beta="before %(slot1)s after">inner</alpha>'
+                    '%(other)s</test>'
+                ),
+                "testRenderHere"
+            )
+
+            @renderer
+            def testRenderHere(self, request, tag):
+                return tag.fillSlots(slot1="hello",
+                                     other="world")
+        result = []
+        flattenString(None, StubElement()).addCallback(result.append)
+        self.assertEquals(result,
+                          ['<test><alpha beta="before hello after">'
+                           'inner</alpha>world</test>'])
+
+
+    def test_templateLoaderTagSoup(self):
+        """
+        L{StringFormatTemplateLoader.load} will convert a template with
+        C{%(x)s}-format slots into t:slot slots, and render a well-formed output
+        document, even if the input is malformed (i.e. missing necessary closing
+        tags).
+        """
+        class StubElement(Element):
+            loader = StringFormatTemplateLoader(
+                lambda : StringIO(
+                    '<test><alpha beta="before %(slot1)s after">inner</alpha>'
+                    '%(other)s'
+                ),
+                "testRenderHere"
+            )
+
+            @renderer
+            def testRenderHere(self, request, tag):
+                return tag.fillSlots(slot1="hello",
+                                     other="world")
+        result = []
+        flattenString(None, StubElement()).addCallback(result.append)
+        self.assertEquals(result,
+                          ['<test><alpha beta="before hello after">'
+                           'inner</alpha>world</test>'])
+
+
+def partByType(message, contentType):
+    """
+    Retrieve a MIME part from an L{email.message.Message} based on a content
+    type.
+    """
+    for part in message.walk():
+        if part.get_content_type() == contentType:
+            return part
+    raise KeyError(contentType)
+
+
+
 class MailGatewayTokensDatabaseTests(TestCase):
 
     def setUp(self):
         TestCase.setUp(self)
         self.db = MailGatewayTokensDatabase(":memory:")
 
+
     def test_tokens(self):
         self.assertEquals(self.db.lookupByToken("xyzzy"), None)
 
         token = self.db.createToken("organizer", "attendee", "icaluid")
-        self.assertEquals(self.db.getToken("organizer", "attendee", "icaluid"), token)
+        self.assertEquals(self.db.getToken("organizer", "attendee", "icaluid"),
+                          token)
         self.assertEquals(self.db.lookupByToken(token),
             ("organizer", "attendee", "icaluid"))
         self.db.deleteToken(token)
         self.assertEquals(self.db.lookupByToken(token), None)
+
+
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20110811/027ab232/attachment-0001.html>


More information about the calendarserver-changes mailing list