[CalendarServer-changes] [9553] CalendarServer/trunk

source_changes at macosforge.org source_changes at macosforge.org
Fri Aug 10 14:24:37 PDT 2012


Revision: 9553
          http://trac.macosforge.org/projects/calendarserver/changeset/9553
Author:   sagen at apple.com
Date:     2012-08-10 14:24:34 -0700 (Fri, 10 Aug 2012)
Log Message:
-----------
New calendar-user-search REPORT

Modified Paths:
--------------
    CalendarServer/trunk/calendarserver/tools/principals.py
    CalendarServer/trunk/calendarserver/webadmin/resource.py
    CalendarServer/trunk/twistedcaldav/customxml.py
    CalendarServer/trunk/twistedcaldav/directory/aggregate.py
    CalendarServer/trunk/twistedcaldav/directory/appleopendirectory.py
    CalendarServer/trunk/twistedcaldav/directory/directory.py
    CalendarServer/trunk/twistedcaldav/directory/idirectory.py
    CalendarServer/trunk/twistedcaldav/directory/ldapdirectory.py
    CalendarServer/trunk/twistedcaldav/directory/principal.py
    CalendarServer/trunk/twistedcaldav/directory/test/test_buildquery.py
    CalendarServer/trunk/twistedcaldav/directory/test/test_directory.py
    CalendarServer/trunk/twistedcaldav/directory/test/test_ldapdirectory.py
    CalendarServer/trunk/twistedcaldav/extensions.py
    CalendarServer/trunk/twistedcaldav/test/test_extensions.py

Modified: CalendarServer/trunk/calendarserver/tools/principals.py
===================================================================
--- CalendarServer/trunk/calendarserver/tools/principals.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/calendarserver/tools/principals.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -390,7 +390,7 @@
         for fieldName in ("fullName", "firstName", "lastName", "emailAddresses"):
             fields.append((fieldName, searchTerm, True, "contains"))
 
-        records = list((yield config.directory.recordsMatchingFields(fields)))
+        records = list((yield config.directory.recordsMatchingTokens(searchTerm.strip().split())))
         if records:
             records.sort(key=operator.attrgetter('fullName'))
             print "%d matches found:" % (len(records),)

Modified: CalendarServer/trunk/calendarserver/webadmin/resource.py
===================================================================
--- CalendarServer/trunk/calendarserver/webadmin/resource.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/calendarserver/webadmin/resource.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -691,4 +691,5 @@
             fields.append((fieldName, searchStr, True, "contains"))
 
         records = list((yield self.directory.recordsMatchingFields(fields)))
+        # records = list((yield self.directory.recordsMatchingTokens(searchStr.strip().split())))
         returnValue(records)

Modified: CalendarServer/trunk/twistedcaldav/customxml.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/customxml.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/customxml.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -1349,6 +1349,32 @@
     namespace = "http://apple.com/ns/ical/"
     name = "calendar-color"
 
+#
+# Calendar-user-search REPORT
+#
+
+ at registerElement
+class CalendarUserSearchToken (WebDAVTextElement):
+    """
+    Contains a search token.
+    """
+    namespace = calendarserver_namespace
+    name = "search-token"
+
+ at registerElement
+class CalendarUserSearch (WebDAVElement):
+
+    namespace = calendarserver_namespace
+    name = "calendar-user-search"
+
+    allowed_children = {
+        (calendarserver_namespace, "search-token"          ): (1, None),
+        (calendarserver_namespace, "limit"                 ): (0, 1),
+        (dav_namespace, "prop"                             ): (1, 1),
+        (dav_namespace, "apply-to-principal-collection-set"): (0, 1),
+    }
+    allowed_attributes = { "context" : False }
+
 ##
 # Extensions to ResourceType
 ##

Modified: CalendarServer/trunk/twistedcaldav/directory/aggregate.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/directory/aggregate.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/directory/aggregate.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -164,6 +164,41 @@
 
         returnValue(itertools.chain(*generators))
 
+
+    @inlineCallbacks
+    def recordsMatchingTokens(self, tokens, context=None):
+        """
+        @param tokens: The tokens to search on
+        @type tokens: C{list} of C{str} (utf-8 bytes)
+        @param context: An indication of what the end user is searching
+            for, e.g. "attendee", "location"
+        @type context: C{str}
+        @return: a deferred sequence of L{IDirectoryRecord}s which
+            match the given tokens and optional context.
+
+        Each token is searched for within each record's full name and
+        email address; if each token is found within a record that
+        record is returned in the results.
+
+        If context is None, all record types are considered.  If
+        context is "location", only locations are considered.  If
+        context is "attendee", only users, groups, and resources
+        are considered.
+
+        Combine the results from the sub-services.
+        """
+
+        services = set(self._recordTypes.values())
+
+        generators = []
+        for service in services:
+            generator = (yield service.recordsMatchingTokens(tokens,
+                context=context))
+            generators.append(generator)
+
+        returnValue(itertools.chain(*generators))
+
+
     def getGroups(self, guids):
         """
         Returns a set of group records for the list of guids passed in.  For

Modified: CalendarServer/trunk/twistedcaldav/directory/appleopendirectory.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/directory/appleopendirectory.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/directory/appleopendirectory.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -530,6 +530,194 @@
         else:
             return ()
 
+    def recordsMatchingTokens(self, tokens, context=None, lookupMethod=None):
+        """
+        @param tokens: The tokens to search on
+        @type tokens: C{list} of C{str} (utf-8 bytes)
+        @param context: An indication of what the end user is searching
+            for, e.g. "attendee", "location"
+        @type context: C{str}
+        @return: a deferred sequence of L{IDirectoryRecord}s which
+            match the given tokens and optional context.
+
+        Each token is searched for within each record's full name and
+        email address; if each token is found within a record that
+        record is returned in the results.
+
+        If context is None, all record types are considered.  If
+        context is "location", only locations are considered.  If
+        context is "attendee", only users, groups, and resources
+        are considered.
+        """
+
+        if lookupMethod is None:
+            lookupMethod=self.odModule.queryRecordsWithAttributes_list
+
+        def collectResults(results):
+            self.log_debug("Got back %d records from OD" % (len(results),))
+            for key, value in results:
+                self.log_debug("OD result: %s %s" % (key, value))
+                try:
+                    recordNodeName = value.get(
+                        dsattributes.kDSNAttrMetaNodeLocation)
+                    recordShortNames = self._uniqueTupleFromAttribute(
+                        value.get(dsattributes.kDSNAttrRecordName))
+
+                    recordGUID = value.get(dsattributes.kDS1AttrGeneratedUID)
+
+                    recordType = value.get(dsattributes.kDSNAttrRecordType)
+                    if isinstance(recordType, list):
+                        recordType = recordType[0]
+                    if not recordType:
+                        continue
+                    recordType = self._fromODRecordTypes[recordType]
+
+                    # Skip if group restriction is in place and guid is not
+                    # a member (but don't skip any groups)
+                    if (recordType != self.recordType_groups and
+                        self.restrictedGUIDs is not None):
+                        if str(recordGUID) not in self.restrictedGUIDs:
+                            continue
+
+                    recordAuthIDs = self._setFromAttribute(
+                        value.get(dsattributes.kDSNAttrAltSecurityIdentities))
+                    recordFullName = value.get(
+                        dsattributes.kDS1AttrDistinguishedName)
+                    recordFirstName = value.get(dsattributes.kDS1AttrFirstName)
+                    recordLastName = value.get(dsattributes.kDS1AttrLastName)
+                    recordEmailAddresses = self._setFromAttribute(
+                        value.get(dsattributes.kDSNAttrEMailAddress),
+                        lower=True)
+
+                    # Special case for groups, which have members.
+                    if recordType == self.recordType_groups:
+                        memberGUIDs = value.get(dsattributes.kDSNAttrGroupMembers)
+                        if memberGUIDs is None:
+                            memberGUIDs = ()
+                        elif type(memberGUIDs) is str:
+                            memberGUIDs = (memberGUIDs,)
+                        nestedGUIDs = value.get(dsattributes.kDSNAttrNestedGroups)
+                        if nestedGUIDs:
+                            if type(nestedGUIDs) is str:
+                                nestedGUIDs = (nestedGUIDs,)
+                            memberGUIDs += tuple(nestedGUIDs)
+                        else:
+                            nestedGUIDs = ()
+                    else:
+                        nestedGUIDs = ()
+                        memberGUIDs = ()
+
+                    # Create records but don't store them in our index or
+                    # send them to memcached, because these are transient,
+                    # existing only so we can create principal resource
+                    # objects that are used to generate the REPORT result.
+
+                    record = OpenDirectoryRecord(
+                        service               = self,
+                        recordType            = recordType,
+                        guid                  = recordGUID,
+                        nodeName              = recordNodeName,
+                        shortNames            = recordShortNames,
+                        authIDs               = recordAuthIDs,
+                        fullName              = recordFullName,
+                        firstName             = recordFirstName,
+                        lastName              = recordLastName,
+                        emailAddresses        = recordEmailAddresses,
+                        memberGUIDs           = memberGUIDs,
+                        nestedGUIDs           = nestedGUIDs,
+                        extProxies            = (),
+                        extReadOnlyProxies    = (),
+                    )
+
+                    # (Copied from below)
+                    # Look up augment information
+                    # TODO: this needs to be deferred but for now we hard code
+                    # the deferred result because we know it is completing
+                    # immediately.
+                    if self.augmentService is not None:
+                        d = self.augmentService.getAugmentRecord(record.guid,
+                            recordType)
+                        d.addCallback(lambda x:record.addAugmentInformation(x))
+
+                    yield record
+
+                except KeyError:
+                    pass
+
+        def multiQuery(directory, queries, recordTypes, attrs):
+            byGUID = { }
+            sets = []
+
+            caseInsensitive = True
+            for compound in queries:
+                compound = compound.generate()
+
+                self.log_debug("Calling OD: Types %s, Query %s" %
+                    (recordTypes, compound))
+
+                try:
+                    queryResults = lookupMethod(
+                        directory,
+                        compound,
+                        caseInsensitive,
+                        recordTypes,
+                        attrs,
+                    )
+
+                    newSet = set()
+                    for recordName, data in queryResults:
+                        guid = data.get(dsattributes.kDS1AttrGeneratedUID, None)
+                        if guid:
+                            byGUID[guid] = (recordName, data)
+                            newSet.add(guid)
+
+                    sets.append(newSet)
+
+                except self.odModule.ODError, e:
+                    self.log_error("Ignoring OD Error: %d %s" %
+                        (e.message[1], e.message[0]))
+                    continue
+
+            results = []
+            for guid in set.intersection(*sets):
+                recordName, data = byGUID.get(guid, None)
+                if data is not None:
+                    results.append((data[dsattributes.kDSNAttrRecordName], data))
+            return results
+
+        queries = buildQueriesFromTokens(tokens, self._ODFields)
+
+        # Starting with the record types corresponding to the context...
+        recordTypes = self.recordTypesForSearchContext(context)
+        # ...limit to the types this service supports...
+        recordTypes = [r for r in recordTypes if r in self.recordTypes()]
+        # ...and map those to OD representations...
+        recordTypes = [self._toODRecordTypes[r] for r in recordTypes]
+
+        if recordTypes:
+            results = multiQuery(
+                self.directory,
+                queries,
+                recordTypes,
+                [
+                    dsattributes.kDS1AttrGeneratedUID,
+                    dsattributes.kDSNAttrRecordName,
+                    dsattributes.kDSNAttrAltSecurityIdentities,
+                    dsattributes.kDSNAttrRecordType,
+                    dsattributes.kDS1AttrDistinguishedName,
+                    dsattributes.kDS1AttrFirstName,
+                    dsattributes.kDS1AttrLastName,
+                    dsattributes.kDSNAttrEMailAddress,
+                    dsattributes.kDSNAttrMetaNodeLocation,
+                    dsattributes.kDSNAttrGroupMembers,
+                    dsattributes.kDSNAttrNestedGroups,
+                ]
+            )
+            return succeed(collectResults(results))
+        else:
+            return succeed([])
+
+
     def recordsMatchingFields(self, fields, operand="or", recordType=None,
         lookupMethod=None):
 
@@ -1101,7 +1289,36 @@
     return queries
 
 
+def buildQueriesFromTokens(tokens, mapping):
+    """
+    OD /Local doesn't support nested complex queries, so create a list of
+    complex queries that will be ANDed together in recordsMatchingTokens()
 
+    @param tokens: The tokens to search on
+    @type tokens: C{list} of C{str}
+    @param mapping: The mapping of DirectoryRecord attributes to OD attributes
+    @type mapping: C{dict}
+    @return: A list of expression objects
+    @type: C{list}
+    """
+
+    if len(tokens) == 0:
+        return None
+
+    fields = ["fullName", "emailAddresses"]
+
+    results = []
+    for token in tokens:
+        queries = []
+        for field in fields:
+            ODField = mapping[field]['odField']
+            query = dsquery.match(ODField, token, "contains")
+            queries.append(query)
+        results.append(dsquery.expression(dsquery.expression.OR, queries))
+    return results
+
+
+
 class OpenDirectoryRecord(CachingDirectoryRecord):
     """
     OpenDirectory implementation of L{IDirectoryRecord}.

Modified: CalendarServer/trunk/twistedcaldav/directory/directory.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/directory/directory.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/directory/directory.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -77,6 +77,9 @@
     recordType_groups = "groups"
     recordType_locations = "locations"
     recordType_resources = "resources"
+
+    searchContext_location = "location"
+    searchContext_attendee = "attendee"
     
     def _generatedGUID(self):
         if not hasattr(self, "_guid"):
@@ -225,7 +228,96 @@
         return self.recordsMatchingFields(fields, operand=operand,
             recordType=recordType)
 
+    def recordTypesForSearchContext(self, context):
+        """
+        Map calendar-user-search REPORT context value to applicable record types
 
+        @param context: The context value to map (either "location" or "attendee")
+        @type context: C{str}
+        @returns: The list of record types the context maps to
+        @rtype: C{list} of C{str}
+        """
+        if context == self.searchContext_location:
+            recordTypes = [self.recordType_locations]
+        elif context == self.searchContext_attendee:
+            recordTypes = [self.recordType_users, self.recordType_groups,
+                self.recordType_resources]
+        else:
+            recordTypes = list(self.recordTypes())
+        return recordTypes
+
+
+    def recordsMatchingTokens(self, tokens, context=None):
+        """
+        @param tokens: The tokens to search on
+        @type tokens: C{list} of C{str} (utf-8 bytes)
+        @param context: An indication of what the end user is searching
+            for, e.g. "attendee", "location"
+        @type context: C{str}
+        @return: a deferred sequence of L{IDirectoryRecord}s which
+            match the given tokens and optional context.
+
+        Each token is searched for within each record's full name and
+        email address; if each token is found within a record that
+        record is returned in the results.
+
+        If context is None, all record types are considered.  If
+        context is "location", only locations are considered.  If
+        context is "attendee", only users, groups, and resources
+        are considered.
+        """
+
+        # Default, bruteforce method; override with one optimized for each
+        # service
+
+        def fieldMatches(fieldValue, value):
+            if fieldValue is None:
+                return False
+            elif type(fieldValue) in types.StringTypes:
+                fieldValue = (fieldValue,)
+
+            for testValue in fieldValue:
+                testValue = testValue.lower()
+                value = value.lower()
+
+                try:
+                    testValue.index(value)
+                    return True
+                except ValueError:
+                    pass
+
+            return False
+
+        def recordMatches(record):
+            for token in tokens:
+                for fieldName in ["fullName", "emailAddresses"]:
+                    try:
+                        fieldValue = getattr(record, fieldName)
+                        if fieldMatches(fieldValue, token):
+                            break
+                    except AttributeError:
+                        # No value
+                        pass
+                else:
+                    return False
+            return True
+
+
+        def yieldMatches(recordTypes):
+            try:
+                for recordType in [r for r in recordTypes if r in self.recordTypes()]:
+                    for record in self.listRecords(recordType):
+                        if recordMatches(record):
+                            yield record
+
+            except UnknownRecordTypeError:
+                # Skip this service since it doesn't understand this record type
+                pass
+
+        recordTypes = self.recordTypesForSearchContext(context)
+        return succeed(yieldMatches(recordTypes))
+
+
     def recordsMatchingFields(self, fields, operand="or", recordType=None):
         # Default, bruteforce method; override with one optimized for each
         # service

Modified: CalendarServer/trunk/twistedcaldav/directory/idirectory.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/directory/idirectory.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/directory/idirectory.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -87,6 +87,27 @@
             match the given fields.
         """
 
+    def recordsMatchingTokens(tokens, context=None):
+        """
+        @param tokens: The tokens to search on
+        @type tokens: C{list} of C{str} (utf-8 bytes)
+        @param context: An indication of what the end user is searching
+            for, e.g. "attendee", "location"
+        @type context: C{str}
+        @return: a deferred sequence of L{IDirectoryRecord}s which
+            match the given tokens and optional context.
+
+        Each token is searched for within each record's full name and
+        email address; if each token is found within a record that
+        record is returned in the results.
+
+        If context is None, all record types are considered.  If
+        context is "location", only locations are considered.  If
+        context is "attendee", only users, groups, and resources
+        are considered.
+        """
+
+
     def setRealm(realmName):
         """
         Set a new realm name for this (and nested services if any)

Modified: CalendarServer/trunk/twistedcaldav/directory/ldapdirectory.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/directory/ldapdirectory.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/directory/ldapdirectory.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -974,6 +974,89 @@
                         % (recordTypes, indexType, indexKey))
 
 
+    def recordsMatchingTokens(self, tokens, context=None):
+        """
+        @param tokens: The tokens to search on
+        @type tokens: C{list} of C{str} (utf-8 bytes)
+        @param context: An indication of what the end user is searching
+            for, e.g. "attendee", "location"
+        @type context: C{str}
+        @return: a deferred sequence of L{IDirectoryRecord}s which
+            match the given tokens and optional context.
+
+        Each token is searched for within each record's full name and
+        email address; if each token is found within a record that
+        record is returned in the results.
+
+        If context is None, all record types are considered.  If
+        context is "location", only locations are considered.  If
+        context is "attendee", only users, groups, and resources
+        are considered.
+        """
+        self.log_debug("Peforming calendar user search for %s (%s)" % (tokens, context))
+
+        records = []
+        recordTypes = self.recordTypesForSearchContext(context)
+        recordTypes = [r for r in recordTypes if r in self.recordTypes()]
+        guidAttr = self.rdnSchema["guidAttr"]
+
+        for recordType in recordTypes:
+            base = self.typeDNs[recordType]
+            scope = ldap.SCOPE_SUBTREE
+            filterstr = buildFilterFromTokens(self.rdnSchema[recordType]["mapping"],
+                tokens)
+
+            if filterstr is not None:
+                # Query the LDAP server
+                self.log_debug("LDAP search %s %s %s" %
+                    (ldap.dn.dn2str(base), scope, filterstr))
+                results = self.timedSearch(ldap.dn.dn2str(base), scope,
+                    filterstr=filterstr, attrlist=self.attrlist,
+                    timeoutSeconds=self.requestTimeoutSeconds,
+                    resultLimit=self.requestResultsLimit)
+                self.log_debug("LDAP search returned %d results" % (len(results),))
+                numMissingGuids = 0
+                numMissingRecordNames = 0
+                for dn, attrs in results:
+                    dn = normalizeDNstr(dn)
+                    # Skip if group restriction is in place and guid is not
+                    # a member
+                    if (recordType != self.recordType_groups and
+                        self.restrictedGUIDs is not None):
+                        if guidAttr:
+                            guid = self._getUniqueLdapAttribute(attrs, guidAttr)
+                            if guid not in self.restrictedGUIDs:
+                                continue
+
+                    try:
+                        record = self._ldapResultToRecord(dn, attrs, recordType)
+
+                        # For non-group records, if not enabled for calendaring do
+                        # not include in principal property search results
+                        if (recordType != self.recordType_groups):
+                            if not record.enabledForCalendaring:
+                                continue
+
+                        records.append(record)
+
+                    except MissingGuidException:
+                        numMissingGuids += 1
+
+                    except MissingRecordNameException:
+                        numMissingRecordNames += 1
+
+                if numMissingGuids:
+                    self.log_warn("%d %s records are missing %s" %
+                        (numMissingGuids, recordType, guidAttr))
+
+                if numMissingRecordNames:
+                    self.log_warn("%d %s records are missing record name" %
+                        (numMissingRecordNames, recordType))
+
+        self.log_debug("Calendar user search matched %d records" % (len(records),))
+        return succeed(records)
+
+
     def recordsMatchingFields(self, fields, operand="or", recordType=None):
         """
         Carries out the work of a principal-property-search against LDAP
@@ -1248,6 +1331,58 @@
     return filterstr
 
 
+def buildFilterFromTokens(mapping, tokens):
+    """
+    Create an LDAP filter string from a list of query tokens.  Each token is
+    searched for in each LDAP attribute corresponding to "fullName" and
+    "emailAddresses" (could be multiple LDAP fields for either).
+
+    @param mapping: A dict mapping internal directory attribute names to ldap names.
+    @type mapping: C{dict}
+    @param tokens: The list of tokens to search for
+    @type tokens: C{list}
+    @return: An LDAP filterstr
+    @rtype: C{str}
+    """
+
+    filterStr = None
+    tokens = [ldapEsc(t) for t in tokens if len(t) > 2]
+    if len(tokens) == 0:
+        return None
+
+    attributes = ["fullName", "emailAddresses"]
+
+    ldapFields = []
+    for attribute in attributes:
+        ldapField = mapping.get(attribute, None)
+        if ldapField:
+            if isinstance(ldapField, str):
+                ldapFields.append(ldapField)
+            else:
+                ldapFields.extend(ldapField)
+
+    if len(ldapFields) == 0:
+        return None
+
+    tokenFragments = []
+    for token in tokens:
+        fragments = []
+        for ldapField in ldapFields:
+            fragments.append("(%s=*%s*)" % (ldapField, token))
+        if len(fragments) == 1:
+            tokenFragment = fragments[0]
+        else:
+            tokenFragment = "(|%s)" % ("".join(fragments),)
+        tokenFragments.append(tokenFragment)
+
+    if len(tokenFragments) == 1:
+        filterStr = tokenFragments[0]
+    else:
+        filterStr = "(&%s)" % ("".join(tokenFragments),)
+
+    return filterStr
+
+
 class LdapDirectoryRecord(CachingDirectoryRecord):
     """
     LDAP implementation of L{IDirectoryRecord}.

Modified: CalendarServer/trunk/twistedcaldav/directory/principal.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/directory/principal.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/directory/principal.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -56,7 +56,7 @@
 from twistedcaldav import caldavxml, customxml
 from twistedcaldav.cache import DisabledCacheNotifier, PropfindCacheMixin
 from twistedcaldav.config import config
-from twistedcaldav.customxml import calendarserver_namespace
+from twistedcaldav.customxml import calendarserver_namespace, CalendarUserSearch
 from twistedcaldav.directory.augment import allowedAutoScheduleModes
 from twistedcaldav.directory.common import uidsResourceName
 from twistedcaldav.directory.directory import DirectoryService, DirectoryRecord
@@ -364,6 +364,15 @@
         return (self,)
 
 
+    def supportedReports(self):
+        """
+        Adds the CalendarUserSearch REPORT
+        """
+        result = super(DirectoryPrincipalProvisioningResource, self).supportedReports()
+        result.append(davxml.Report(CalendarUserSearch(),))
+        return result
+
+
 class DirectoryPrincipalTypeProvisioningResource (DirectoryProvisioningResource):
     """
     Collection resource which provisions directory principals of a

Modified: CalendarServer/trunk/twistedcaldav/directory/test/test_buildquery.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/directory/test/test_buildquery.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/directory/test/test_buildquery.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -15,7 +15,7 @@
 ##
 
 from twistedcaldav.test.util import TestCase
-from twistedcaldav.directory.appleopendirectory import buildQueries, OpenDirectoryService
+from twistedcaldav.directory.appleopendirectory import buildQueries, buildQueriesFromTokens, OpenDirectoryService
 from calendarserver.platform.darwin.od import dsattributes
 
 class BuildQueryTests(TestCase):
@@ -103,3 +103,23 @@
             {
             }
         )
+
+    def test_buildQueryFromTokens(self):
+        results = buildQueriesFromTokens([], OpenDirectoryService._ODFields)
+        self.assertEquals(results, None)
+
+        results = buildQueriesFromTokens(["foo"], OpenDirectoryService._ODFields)
+        self.assertEquals(
+            results[0].generate(),
+            "(|(dsAttrTypeStandard:RealName=*foo*)(dsAttrTypeStandard:EMailAddress=*foo*))"
+        )
+
+        results = buildQueriesFromTokens(["foo", "bar"], OpenDirectoryService._ODFields)
+        self.assertEquals(
+            results[0].generate(),
+            "(|(dsAttrTypeStandard:RealName=*foo*)(dsAttrTypeStandard:EMailAddress=*foo*))"
+        )
+        self.assertEquals(
+            results[1].generate(),
+            "(|(dsAttrTypeStandard:RealName=*bar*)(dsAttrTypeStandard:EMailAddress=*bar*))"
+        )

Modified: CalendarServer/trunk/twistedcaldav/directory/test/test_directory.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/directory/test/test_directory.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/directory/test/test_directory.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -583,7 +583,62 @@
             }
         )
 
+class RecordsMatchingTokensTests(TestCase):
 
+    @inlineCallbacks
+    def setUp(self):
+        super(RecordsMatchingTokensTests, self).setUp()
+
+        self.directoryService = XMLDirectoryService(
+            {
+                'xmlFile' : xmlFile,
+                'augmentService' :
+                    augment.AugmentXMLDB(xmlFiles=(augmentsFile.path,)),
+            }
+        )
+        calendaruserproxy.ProxyDBService = calendaruserproxy.ProxySqliteDB("proxies.sqlite")
+
+        # Set up a principals hierarchy for each service we're testing with
+        self.principalRootResources = {}
+        name = self.directoryService.__class__.__name__
+        url = "/" + name + "/"
+
+        provisioningResource = DirectoryPrincipalProvisioningResource(url, self.directoryService)
+
+        self.site.resource.putChild(name, provisioningResource)
+
+        self.principalRootResources[self.directoryService.__class__.__name__] = provisioningResource
+
+        yield XMLCalendarUserProxyLoader(proxiesFile.path).updateProxyDB()
+
+    def tearDown(self):
+        """ Empty the proxy db between tests """
+        return calendaruserproxy.ProxyDBService.clean()
+
+    @inlineCallbacks
+    def test_recordsMatchingTokens(self):
+        """
+        Exercise the default recordsMatchingTokens implementation
+        """
+        records = list((yield self.directoryService.recordsMatchingTokens(["Use", "01"])))
+        self.assertEquals(len(records), 1)
+        self.assertEquals(records[0].shortNames[0], "user01")
+
+        records = list((yield self.directoryService.recordsMatchingTokens(['"quotey"'],
+            context=self.directoryService.searchContext_attendee)))
+        self.assertEquals(len(records), 1)
+        self.assertEquals(records[0].shortNames[0], "doublequotes")
+
+        records = list((yield self.directoryService.recordsMatchingTokens(["coast"])))
+        self.assertEquals(len(records), 5)
+
+        records = list((yield self.directoryService.recordsMatchingTokens(["poll"],
+            context=self.directoryService.searchContext_location)))
+        self.assertEquals(len(records), 1)
+        self.assertEquals(records[0].shortNames[0], "apollo")
+
+
+ 
 class GUIDTests(TestCase):
 
     def setUp(self):

Modified: CalendarServer/trunk/twistedcaldav/directory/test/test_ldapdirectory.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/directory/test/test_ldapdirectory.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/directory/test/test_ldapdirectory.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -16,7 +16,7 @@
 
 try:
     from twistedcaldav.directory.ldapdirectory import (
-        buildFilter, LdapDirectoryService,
+        buildFilter, buildFilterFromTokens, LdapDirectoryService,
         MissingGuidException, MissingRecordNameException,
         normalizeDNstr, dnContainedIn
     )
@@ -193,6 +193,79 @@
                     entry["expected"]
                 )
 
+
+    class BuildFilterFromTokensTestCase(TestCase):
+
+        def test_buildFilterFromTokens(self):
+
+            entries = [
+                {
+                    "tokens" : ["foo"],
+                    "mapping" : {
+                        "fullName" : "cn",
+                        "emailAddresses" : "mail",
+                    },
+                    "expected" : "(|(cn=*foo*)(mail=*foo*))",
+                },
+                {
+                    "tokens" : ["foo"],
+                    "mapping" : {
+                        "fullName" : "cn",
+                        "emailAddresses" : ["mail", "mailAliases"],
+                    },
+                    "expected" : "(|(cn=*foo*)(mail=*foo*)(mailAliases=*foo*))",
+                },
+                {
+                    "tokens" : [],
+                    "mapping" : {
+                        "fullName" : "cn",
+                        "emailAddresses" : "mail",
+                    },
+                    "expected" : None,
+                },
+                {
+                    "tokens" : ["foo", "bar"],
+                    "mapping" : { },
+                    "expected" : None,
+                },
+                {
+                    "tokens" : ["foo", "bar"],
+                    "mapping" : {
+                        "emailAddresses" : "mail",
+                    },
+                    "expected" : "(&(mail=*foo*)(mail=*bar*))",
+                },
+                {
+                    "tokens" : ["foo", "bar"],
+                    "mapping" : {
+                        "fullName" : "cn",
+                        "emailAddresses" : "mail",
+                    },
+                    "expected" : "(&(|(cn=*foo*)(mail=*foo*))(|(cn=*bar*)(mail=*bar*)))",
+                },
+                {
+                    "tokens" : ["foo", "bar"],
+                    "mapping" : {
+                        "fullName" : "cn",
+                        "emailAddresses" : ["mail", "mailAliases"],
+                    },
+                    "expected" : "(&(|(cn=*foo*)(mail=*foo*)(mailAliases=*foo*))(|(cn=*bar*)(mail=*bar*)(mailAliases=*bar*)))",
+                },
+                {
+                    "tokens" : ["foo", "bar", "baz("],
+                    "mapping" : {
+                        "fullName" : "cn",
+                        "emailAddresses" : "mail",
+                    },
+                    "expected" : "(&(|(cn=*foo*)(mail=*foo*))(|(cn=*bar*)(mail=*bar*))(|(cn=*baz\\28*)(mail=*baz\\28*)))",
+                },
+            ]
+            for entry in entries:
+                self.assertEquals(
+                    buildFilterFromTokens(entry["mapping"], entry["tokens"]),
+                    entry["expected"]
+                )
+
     class StubList(object):
         def __init__(self, wrapper):
             self.ldap = wrapper

Modified: CalendarServer/trunk/twistedcaldav/extensions.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/extensions.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/extensions.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -249,7 +249,95 @@
         returnValue(MultiStatusResponse(responses))
 
 
+    @inlineCallbacks
+    def report_http___calendarserver_org_ns__calendar_user_search(self, request,
+        calendar_user_search):
+        """
+        Generate a calendar-user-search REPORT.
 
+        @param request: Request object
+        @param calendar_user_search: CalendarUserSearch object
+        """
+
+        # Verify root element
+        if not isinstance(calendar_user_search, customxml.CalendarUserSearch):
+            msg = "%s expected as root element, not %s." % (customxml.CalendarUserSearch.sname(), calendar_user_search.sname())
+            log.warn(msg)
+            raise HTTPError(StatusResponse(responsecode.BAD_REQUEST, msg))
+
+        # Only handle Depth: 0
+        depth = request.headers.getHeader("depth", "0")
+        if depth != "0":
+            log.err("Error in calendar-user-search REPORT, Depth set to %s" % (depth,))
+            raise HTTPError(StatusResponse(responsecode.BAD_REQUEST, "Depth %s not allowed" % (depth,)))
+
+        tokens, context, applyTo, clientLimit, propElement = extractCalendarUserSearchData(calendar_user_search)
+
+        # Run report
+        resultsWereLimited = None
+        resources = []
+        if applyTo or not hasattr(self, "directory"):
+            for principalCollection in self.principalCollections():
+                uri = principalCollection.principalCollectionURL()
+                resource = (yield request.locateResource(uri))
+                if resource:
+                    resources.append((resource, uri))
+        else:
+            resources.append((self, request.uri))
+
+        # We need to access a directory service
+        principalCollection = resources[0][0]
+        dir = principalCollection.directory
+
+        matchingResources = []
+        matchcount = 0
+
+        records = (yield dir.recordsMatchingTokens(tokens, context=context))
+
+        for record in records:
+            resource = principalCollection.principalForRecord(record)
+            if resource:
+                matchingResources.append(resource)
+
+                # We've determined this is a matching resource
+                matchcount += 1
+                if clientLimit is not None and matchcount >= clientLimit:
+                    resultsWereLimited = ("client", matchcount)
+                    break
+                if matchcount >= config.MaxPrincipalSearchReportResults:
+                    resultsWereLimited = ("server", matchcount)
+                    break
+
+        # Generate the response
+        responses = []
+        for resource in matchingResources:
+            url = resource.url()
+            yield prop_common.responseForHref(
+                request,
+                responses,
+                element.HRef.fromString(url),
+                resource,
+                prop_common.propertyListForResource,
+                propElement
+            )
+
+        if resultsWereLimited is not None:
+            if resultsWereLimited[0] == "server":
+                log.err("Too many matching resources in "
+                        "calendar-user-search report")
+            responses.append(element.StatusResponse(
+                element.HRef.fromString(request.uri),
+                element.Status.fromResponseCode(
+                    responsecode.INSUFFICIENT_STORAGE_SPACE
+                ),
+                element.Error(element.NumberOfMatchesWithinLimits()),
+                element.ResponseDescription("Results limited by %s at %d"
+                                           % resultsWereLimited),
+            ))
+        returnValue(MultiStatusResponse(responses))
+
+
+
 class DirectoryElement(Element):
     """
     A L{DirectoryElement} is an L{Element} for rendering the contents of a
@@ -868,3 +956,41 @@
             )
         return self._data
 
+def extractCalendarUserSearchData(doc):
+    """
+    Extract relevant info from a CalendarUserSearch document
+
+    @param doc: CalendarUserSearch object to extract info from
+    @return: A tuple containing:
+        the list of tokens
+        the context string
+        the applyTo boolean
+        the clientLimit integer
+        the propElement containing the properties to return
+    """
+    context = doc.attributes.get("context", None)
+    applyTo = False
+    tokens = []
+    clientLimit = None
+    for child in doc.children:
+        if child.qname() == (dav_namespace, "prop"):
+            propElement = child
+
+        elif child.qname() == (dav_namespace,
+            "apply-to-principal-collection-set"):
+            applyTo = True
+
+        elif child.qname() == (calendarserver_namespace, "search-token"):
+            tokens.append(str(child))
+
+        elif child.qname() == (calendarserver_namespace, "limit"):
+            try:
+                nresults = child.childOfType(customxml.NResults)
+                clientLimit = int(str(nresults))
+            except (TypeError, ValueError,):
+                msg = "Bad XML: unknown value for <limit> element"
+                log.warn(msg)
+                raise HTTPError(StatusResponse(responsecode.BAD_REQUEST, msg))
+
+    return tokens, context, applyTo, clientLimit, propElement
+

Modified: CalendarServer/trunk/twistedcaldav/test/test_extensions.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/test/test_extensions.py	2012-08-10 18:28:21 UTC (rev 9552)
+++ CalendarServer/trunk/twistedcaldav/test/test_extensions.py	2012-08-10 21:24:34 UTC (rev 9553)
@@ -17,6 +17,7 @@
 
 from twext.python.filepath import CachingFilePath as FilePath
 from txdav.xml.element import WebDAVElement, ResourceType
+from txdav.xml.parser import WebDAVDocument
 from twext.web2.http_headers import MimeType
 from twext.web2.static import MetaDataMixin
 
@@ -24,7 +25,7 @@
 from twisted.trial.unittest import TestCase
 from twisted.web.microdom import parseString
 
-from twistedcaldav.extensions import DAVFile, DAVResourceWithChildrenMixin
+from twistedcaldav.extensions import DAVFile, DAVResourceWithChildrenMixin, extractCalendarUserSearchData
 
 from xml.etree.cElementTree import XML
 
@@ -219,3 +220,46 @@
         self.assertEquals(len(result), 2)
         self.assertEquals(result[0].name, 'cheese')
         self.assertEquals(result[1], ['burger'])
+
+
+class CalendarUserSearchTests(TestCase):
+    def test_extractCalendarUserSearchData(self):
+        """
+        Exercise the parser for calendar-user-search documents
+        """
+
+        data = """<B:calendar-user-search xmlns:A="DAV:" xmlns:B="http://calendarserver.org/ns/" context="attendee">
+  <B:search-token>morgen</B:search-token>
+  <A:prop>
+    <A:principal-URL/>
+    <A:displayname/>
+  </A:prop>
+</B:calendar-user-search>
+"""
+        doc = WebDAVDocument.fromString(data)
+        tokens, context, applyTo, clientLimit, propElement =  extractCalendarUserSearchData(doc.root_element)
+        self.assertEquals(tokens, ["morgen"])
+        self.assertEquals(context, "attendee")
+        self.assertFalse(applyTo)
+        self.assertEquals(clientLimit, None)
+
+
+        data = """<B:calendar-user-search xmlns:A="DAV:" xmlns:B="http://calendarserver.org/ns/">
+  <B:search-token>morgen</B:search-token>
+  <B:search-token>sagen</B:search-token>
+  <B:limit>
+      <B:nresults>42</B:nresults>
+  </B:limit>
+  <A:prop>
+    <A:principal-URL/>
+    <A:displayname/>
+  </A:prop>
+  <A:apply-to-principal-collection-set/>
+</B:calendar-user-search>
+"""
+        doc = WebDAVDocument.fromString(data)
+        tokens, context, applyTo, clientLimit, propElement =  extractCalendarUserSearchData(doc.root_element)
+        self.assertEquals(tokens, ["morgen", "sagen"])
+        self.assertEquals(context, None)
+        self.assertTrue(applyTo)
+        self.assertEquals(clientLimit, 42)
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20120810/386ca313/attachment-0001.html>


More information about the calendarserver-changes mailing list