[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