Browse Source

Because pip ldapdb version in too old and doesn't work with Django 1.6,
this commit add the last version of lib directly into our repo.
Remove requirement.txt line

Fabs 11 years ago
parent
commit
72eb06088c

+ 57 - 0
ldapdb/__init__.py

@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+#
+# django-ldapdb
+# Copyright (c) 2009-2011, Bolloré telecom
+# Copyright (c) 2013, Jeremy Lainé
+# All rights reserved.
+#
+# See AUTHORS file for a full list of contributors.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     1. Redistributions of source code must retain the above copyright notice,
+#        this list of conditions and the following disclaimer.
+#
+#     2. Redistributions in binary form must reproduce the above copyright
+#        notice, this list of conditions and the following disclaimer in the
+#        documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+from django.conf import settings
+
+
+def escape_ldap_filter(value):
+    value = unicode(value)
+    return value.replace('\\', '\\5c') \
+                .replace('*', '\\2a') \
+                .replace('(', '\\28') \
+                .replace(')', '\\29') \
+                .replace('\0', '\\00')
+
+# Legacy single database support
+if hasattr(settings, 'LDAPDB_SERVER_URI'):
+    from django import db
+    from ldapdb.router import Router
+
+    # Add the LDAP backend
+    settings.DATABASES['ldap'] = {
+        'ENGINE': 'ldapdb.backends.ldap',
+        'NAME': settings.LDAPDB_SERVER_URI,
+        'USER': settings.LDAPDB_BIND_DN,
+        'PASSWORD': settings.LDAPDB_BIND_PASSWORD}
+
+    # Add the LDAP router
+    db.router.routers.append(Router())

+ 0 - 0
ldapdb/backends/__init__.py


+ 0 - 0
ldapdb/backends/ldap/__init__.py


+ 147 - 0
ldapdb/backends/ldap/base.py

@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+#
+# django-ldapdb
+# Copyright (c) 2009-2011, Bolloré telecom
+# Copyright (c) 2013, Jeremy Lainé
+# All rights reserved.
+#
+# See AUTHORS file for a full list of contributors.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     1. Redistributions of source code must retain the above copyright notice,
+#        this list of conditions and the following disclaimer.
+#
+#     2. Redistributions in binary form must reproduce the above copyright
+#        notice, this list of conditions and the following disclaimer in the
+#        documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+import ldap
+import django
+
+from django.db.backends import (BaseDatabaseFeatures, BaseDatabaseOperations,
+                                BaseDatabaseWrapper)
+from django.db.backends.creation import BaseDatabaseCreation
+
+
+class DatabaseCreation(BaseDatabaseCreation):
+    def create_test_db(self, verbosity=1, autoclobber=False):
+        """
+        Creates a test database, prompting the user for confirmation if the
+        database already exists. Returns the name of the test database created.
+        """
+        pass
+
+    def destroy_test_db(self, old_database_name, verbosity=1):
+        """
+        Destroy a test database, prompting the user for confirmation if the
+        database already exists. Returns the name of the test database created.
+        """
+        pass
+
+
+class DatabaseCursor(object):
+    def __init__(self, ldap_connection):
+        self.connection = ldap_connection
+
+
+class DatabaseFeatures(BaseDatabaseFeatures):
+    def __init__(self, connection):
+        self.connection = connection
+        self.supports_transactions = False
+
+
+class DatabaseOperations(BaseDatabaseOperations):
+    compiler_module = "ldapdb.backends.ldap.compiler"
+
+    def quote_name(self, name):
+        return name
+
+
+class DatabaseWrapper(BaseDatabaseWrapper):
+    def __init__(self, *args, **kwargs):
+        super(DatabaseWrapper, self).__init__(*args, **kwargs)
+
+        self.charset = "utf-8"
+        self.creation = DatabaseCreation(self)
+        self.features = DatabaseFeatures(self)
+        if django.VERSION > (1, 4):
+            self.ops = DatabaseOperations(self)
+        else:
+            self.ops = DatabaseOperations()
+        self.settings_dict['SUPPORTS_TRANSACTIONS'] = False
+
+    def close(self):
+        if hasattr(self, 'validate_thread_sharing'):
+            # django >= 1.4
+            self.validate_thread_sharing()
+        if self.connection is not None:
+            self.connection.unbind_s()
+            self.connection = None
+
+    def ensure_connection(self):
+        if self.connection is None:
+            self.connection = ldap.initialize(self.settings_dict['NAME'])
+
+            options = self.settings_dict.get('CONNECTION_OPTIONS', {})
+            for opt, value in options.items():
+                self.connection.set_option(opt, value)
+
+            if self.settings_dict.get('TLS', False):
+                self.connection.start_tls_s()
+
+            self.connection.simple_bind_s(
+                self.settings_dict['USER'],
+                self.settings_dict['PASSWORD'])
+
+    def _commit(self):
+        pass
+
+    def _cursor(self):
+        self.ensure_connection()
+        return DatabaseCursor(self.connection)
+
+    def _rollback(self):
+        pass
+
+    def add_s(self, dn, modlist):
+        cursor = self._cursor()
+        return cursor.connection.add_s(dn.encode(self.charset), modlist)
+
+    def delete_s(self, dn):
+        cursor = self._cursor()
+        return cursor.connection.delete_s(dn.encode(self.charset))
+
+    def modify_s(self, dn, modlist):
+        cursor = self._cursor()
+        return cursor.connection.modify_s(dn.encode(self.charset), modlist)
+
+    def rename_s(self, dn, newrdn):
+        cursor = self._cursor()
+        return cursor.connection.rename_s(dn.encode(self.charset),
+                                          newrdn.encode(self.charset))
+
+    def search_s(self, base, scope, filterstr='(objectClass=*)',
+                 attrlist=None):
+        cursor = self._cursor()
+        results = cursor.connection.search_s(base, scope,
+                                             filterstr.encode(self.charset),
+                                             attrlist)
+        output = []
+        for dn, attrs in results:
+            output.append((dn.decode(self.charset), attrs))
+        return output

+ 261 - 0
ldapdb/backends/ldap/compiler.py

@@ -0,0 +1,261 @@
+# -*- coding: utf-8 -*-
+#
+# django-ldapdb
+# Copyright (c) 2009-2011, Bolloré telecom
+# Copyright (c) 2013, Jeremy Lainé
+# All rights reserved.
+#
+# See AUTHORS file for a full list of contributors.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     1. Redistributions of source code must retain the above copyright notice,
+#        this list of conditions and the following disclaimer.
+#
+#     2. Redistributions in binary form must reproduce the above copyright
+#        notice, this list of conditions and the following disclaimer in the
+#        documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+import ldap
+
+from django.db.models.sql import aggregates, compiler
+from django.db.models.sql.where import AND, OR
+
+
+def get_lookup_operator(lookup_type):
+    if lookup_type == 'gte':
+        return '>='
+    elif lookup_type == 'lte':
+        return '<='
+    else:
+        return '='
+
+
+def query_as_ldap(query):
+    # starting with django 1.6 we can receive empty querysets
+    if hasattr(query, 'is_empty') and query.is_empty():
+        return
+
+    filterstr = ''.join(['(objectClass=%s)' % cls for cls in
+                         query.model.object_classes])
+    sql, params = where_as_ldap(query.where)
+    filterstr += sql
+    return '(&%s)' % filterstr
+
+
+def where_as_ldap(self):
+    bits = []
+    for item in self.children:
+        if hasattr(item, 'as_sql'):
+            sql, params = where_as_ldap(item)
+            bits.append(sql)
+            continue
+
+        constraint, lookup_type, y, values = item
+        comp = get_lookup_operator(lookup_type)
+        if lookup_type == 'in':
+            equal_bits = ["(%s%s%s)" % (constraint.col, comp, value) for value
+                          in values]
+            clause = '(|%s)' % ''.join(equal_bits)
+        else:
+            clause = "(%s%s%s)" % (constraint.col, comp, values)
+
+        bits.append(clause)
+
+    if not len(bits):
+        return '', []
+
+    if len(bits) == 1:
+        sql_string = bits[0]
+    elif self.connector == AND:
+        sql_string = '(&%s)' % ''.join(bits)
+    elif self.connector == OR:
+        sql_string = '(|%s)' % ''.join(bits)
+    else:
+        raise Exception("Unhandled WHERE connector: %s" % self.connector)
+
+    if self.negated:
+        sql_string = ('(!%s)' % sql_string)
+
+    return sql_string, []
+
+
+class SQLCompiler(object):
+    def __init__(self, query, connection, using):
+        self.query = query
+        self.connection = connection
+        self.using = using
+
+    def execute_sql(self, result_type=compiler.MULTI):
+        if result_type != compiler.SINGLE:
+            raise Exception("LDAP does not support MULTI queries")
+
+        for key, aggregate in self.query.aggregate_select.items():
+            if not isinstance(aggregate, aggregates.Count):
+                raise Exception("Unsupported aggregate %s" % aggregate)
+
+        filterstr = query_as_ldap(self.query)
+        if not filterstr:
+            return
+
+        try:
+            vals = self.connection.search_s(
+                self.query.model.base_dn,
+                self.query.model.search_scope,
+                filterstr=filterstr,
+                attrlist=['dn'],
+            )
+        except ldap.NO_SUCH_OBJECT:
+            vals = []
+
+        if not vals:
+            return None
+
+        output = []
+        for alias, col in self.query.extra_select.iteritems():
+            output.append(col[0])
+        for key, aggregate in self.query.aggregate_select.items():
+            if isinstance(aggregate, aggregates.Count):
+                output.append(len(vals))
+            else:
+                output.append(None)
+        return output
+
+    def results_iter(self):
+        filterstr = query_as_ldap(self.query)
+        if not filterstr:
+            return
+
+        if hasattr(self.query, 'select_fields') and len(self.query.select_fields):
+            # django < 1.6
+            fields = self.query.select_fields
+        elif len(self.query.select):
+            # django >= 1.6
+            fields = [x.field for x in self.query.select]
+        else:
+            fields = self.query.model._meta.fields
+
+        attrlist = [x.db_column for x in fields if x.db_column]
+
+        try:
+            vals = self.connection.search_s(
+                self.query.model.base_dn,
+                self.query.model.search_scope,
+                filterstr=filterstr,
+                attrlist=attrlist,
+            )
+        except ldap.NO_SUCH_OBJECT:
+            return
+
+        # perform sorting
+        if self.query.extra_order_by:
+            ordering = self.query.extra_order_by
+        elif not self.query.default_ordering:
+            ordering = self.query.order_by
+        else:
+            ordering = self.query.order_by or self.query.model._meta.ordering
+
+        def cmpvals(x, y):
+            for fieldname in ordering:
+                if fieldname.startswith('-'):
+                    fieldname = fieldname[1:]
+                    negate = True
+                else:
+                    negate = False
+                if fieldname == 'pk':
+                    fieldname = self.query.model._meta.pk.name
+                field = self.query.model._meta.get_field(fieldname)
+                attr_x = field.from_ldap(x[1].get(field.db_column, []),
+                                         connection=self.connection)
+                attr_y = field.from_ldap(y[1].get(field.db_column, []),
+                                         connection=self.connection)
+                # perform case insensitive comparison
+                if hasattr(attr_x, 'lower'):
+                    attr_x = attr_x.lower()
+                if hasattr(attr_y, 'lower'):
+                    attr_y = attr_y.lower()
+                val = negate and cmp(attr_y, attr_x) or cmp(attr_x, attr_y)
+                if val:
+                    return val
+            return 0
+        vals = sorted(vals, cmp=cmpvals)
+
+        # process results
+        pos = 0
+        results = []
+        for dn, attrs in vals:
+            # FIXME : This is not optimal, we retrieve more results than we
+            # need but there is probably no other options as we can't perform
+            # ordering server side.
+            if (self.query.low_mark and pos < self.query.low_mark) or \
+               (self.query.high_mark is not None and
+                    pos >= self.query.high_mark):
+                pos += 1
+                continue
+            row = []
+            for field in iter(fields):
+                if field.attname == 'dn':
+                    row.append(dn)
+                elif hasattr(field, 'from_ldap'):
+                    row.append(field.from_ldap(attrs.get(field.db_column, []),
+                                               connection=self.connection))
+                else:
+                    row.append(None)
+            if self.query.distinct:
+                if row in results:
+                    continue
+                else:
+                    results.append(row)
+            yield row
+            pos += 1
+
+
+class SQLInsertCompiler(compiler.SQLInsertCompiler, SQLCompiler):
+    pass
+
+
+class SQLDeleteCompiler(compiler.SQLDeleteCompiler, SQLCompiler):
+    def execute_sql(self, result_type=compiler.MULTI):
+        filterstr = query_as_ldap(self.query)
+        if not filterstr:
+            return
+
+        try:
+            vals = self.connection.search_s(
+                self.query.model.base_dn,
+                self.query.model.search_scope,
+                filterstr=filterstr,
+                attrlist=['dn'],
+            )
+        except ldap.NO_SUCH_OBJECT:
+            return
+
+        # FIXME : there is probably a more efficient way to do this
+        for dn, attrs in vals:
+            self.connection.delete_s(dn)
+
+
+class SQLUpdateCompiler(compiler.SQLUpdateCompiler, SQLCompiler):
+    pass
+
+
+class SQLAggregateCompiler(compiler.SQLAggregateCompiler, SQLCompiler):
+    pass
+
+
+class SQLDateCompiler(compiler.SQLDateCompiler, SQLCompiler):
+    pass

+ 33 - 0
ldapdb/models/__init__.py

@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+#
+# django-ldapdb
+# Copyright (c) 2009-2011, Bolloré telecom
+# Copyright (c) 2013, Jeremy Lainé
+# All rights reserved.
+#
+# See AUTHORS file for a full list of contributors.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     1. Redistributions of source code must retain the above copyright notice,
+#        this list of conditions and the following disclaimer.
+#
+#     2. Redistributions in binary form must reproduce the above copyright
+#        notice, this list of conditions and the following disclaimer in the
+#        documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+from ldapdb.models.base import Model  # noqa

+ 176 - 0
ldapdb/models/base.py

@@ -0,0 +1,176 @@
+# -*- coding: utf-8 -*-
+#
+# django-ldapdb
+# Copyright (c) 2009-2011, Bolloré telecom
+# Copyright (c) 2013, Jeremy Lainé
+# All rights reserved.
+#
+# See AUTHORS file for a full list of contributors.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     1. Redistributions of source code must retain the above copyright notice,
+#        this list of conditions and the following disclaimer.
+#
+#     2. Redistributions in binary form must reproduce the above copyright
+#        notice, this list of conditions and the following disclaimer in the
+#        documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+import ldap
+import logging
+
+import django.db.models
+from django.db import connections, router
+from django.db.models import signals
+
+import ldapdb  # noqa
+
+
+logger = logging.getLogger('ldapdb')
+
+
+class Model(django.db.models.base.Model):
+    """
+    Base class for all LDAP models.
+    """
+    dn = django.db.models.fields.CharField(max_length=200)
+
+    # meta-data
+    base_dn = None
+    search_scope = ldap.SCOPE_SUBTREE
+    object_classes = ['top']
+
+    def __init__(self, *args, **kwargs):
+        super(Model, self).__init__(*args, **kwargs)
+        self.saved_pk = self.pk
+
+    def build_rdn(self):
+        """
+        Build the Relative Distinguished Name for this entry.
+        """
+        bits = []
+        for field in self._meta.fields:
+            if field.db_column and field.primary_key:
+                bits.append("%s=%s" % (field.db_column,
+                                       getattr(self, field.name)))
+        if not len(bits):
+            raise Exception("Could not build Distinguished Name")
+        return '+'.join(bits)
+
+    def build_dn(self):
+        """
+        Build the Distinguished Name for this entry.
+        """
+        return "%s,%s" % (self.build_rdn(), self.base_dn)
+        raise Exception("Could not build Distinguished Name")
+
+    def delete(self, using=None):
+        """
+        Delete this entry.
+        """
+        using = using or router.db_for_write(self.__class__, instance=self)
+        connection = connections[using]
+        logger.debug("Deleting LDAP entry %s" % self.dn)
+        connection.delete_s(self.dn)
+        signals.post_delete.send(sender=self.__class__, instance=self)
+
+    def save(self, using=None):
+        """
+        Saves the current instance.
+        """
+        signals.pre_save.send(sender=self.__class__, instance=self)
+        
+        using = using or router.db_for_write(self.__class__, instance=self)
+        connection = connections[using]
+        if not self.dn:
+            # create a new entry
+            record_exists = False
+            entry = [('objectClass', self.object_classes)]
+            new_dn = self.build_dn()
+
+            for field in self._meta.fields:
+                if not field.db_column:
+                    continue
+                value = getattr(self, field.name)
+                if value:
+                    entry.append((field.db_column,
+                                  field.get_db_prep_save(
+                                      value, connection=connection)))
+
+            logger.debug("Creating new LDAP entry %s" % new_dn)
+            connection.add_s(new_dn, entry)
+
+            # update object
+            self.dn = new_dn
+
+        else:
+            # update an existing entry
+            record_exists = True
+            modlist = []
+            orig = self.__class__.objects.get(pk=self.saved_pk)
+            for field in self._meta.fields:
+                if not field.db_column:
+                    continue
+                old_value = getattr(orig, field.name, None)
+                new_value = getattr(self, field.name, None)
+                if old_value != new_value:
+                    if new_value:
+                        modlist.append(
+                            (ldap.MOD_REPLACE, field.db_column,
+                             field.get_db_prep_save(new_value,
+                                                    connection=connection)))
+                    elif old_value:
+                        modlist.append((ldap.MOD_DELETE, field.db_column,
+                                        None))
+
+            if len(modlist):
+                # handle renaming
+                new_dn = self.build_dn()
+                if new_dn != self.dn:
+                    logger.debug("Renaming LDAP entry %s to %s" % (self.dn,
+                                                                   new_dn))
+                    connection.rename_s(self.dn, self.build_rdn())
+                    self.dn = new_dn
+
+                logger.debug("Modifying existing LDAP entry %s" % self.dn)
+                connection.modify_s(self.dn, modlist)
+            else:
+                logger.debug("No changes to be saved to LDAP entry %s" %
+                             self.dn)
+
+        # done
+        self.saved_pk = self.pk
+        signals.post_save.send(sender=self.__class__, instance=self,
+                               created=(not record_exists))
+
+    @classmethod
+    def scoped(base_class, base_dn):
+        """
+        Returns a copy of the current class with a different base_dn.
+        """
+        class Meta:
+            proxy = True
+        import re
+        suffix = re.sub('[=,]', '_', base_dn)
+        name = "%s_%s" % (base_class.__name__, str(suffix))
+        new_class = type(name, (base_class,), {
+            'base_dn': base_dn, '__module__': base_class.__module__,
+            'Meta': Meta})
+        return new_class
+
+    class Meta:
+        abstract = True

+ 218 - 0
ldapdb/models/fields.py

@@ -0,0 +1,218 @@
+# -*- coding: utf-8 -*-
+#
+# django-ldapdb
+# Copyright (c) 2009-2011, Bolloré telecom
+# Copyright (c) 2013, Jeremy Lainé
+# All rights reserved.
+#
+# See AUTHORS file for a full list of contributors.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     1. Redistributions of source code must retain the above copyright notice,
+#        this list of conditions and the following disclaimer.
+#
+#     2. Redistributions in binary form must reproduce the above copyright
+#        notice, this list of conditions and the following disclaimer in the
+#        documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+from django.db.models import fields, SubfieldBase
+
+from ldapdb import escape_ldap_filter
+
+import datetime
+
+
+class CharField(fields.CharField):
+    def __init__(self, *args, **kwargs):
+        kwargs['max_length'] = 200
+        super(CharField, self).__init__(*args, **kwargs)
+
+    def from_ldap(self, value, connection):
+        if len(value) == 0:
+            return ''
+        else:
+            return value[0].decode(connection.charset)
+
+    def get_db_prep_lookup(self, lookup_type, value, connection,
+                           prepared=False):
+        "Returns field's value prepared for database lookup."
+        if lookup_type == 'endswith':
+            return ["*%s" % escape_ldap_filter(value)]
+        elif lookup_type == 'startswith':
+            return ["%s*" % escape_ldap_filter(value)]
+        elif lookup_type in ['contains', 'icontains']:
+            return ["*%s*" % escape_ldap_filter(value)]
+        elif lookup_type == 'exact':
+            return [escape_ldap_filter(value)]
+        elif lookup_type == 'in':
+            return [escape_ldap_filter(v) for v in value]
+
+        raise TypeError("CharField has invalid lookup: %s" % lookup_type)
+
+    def get_db_prep_save(self, value, connection):
+        return [value.encode(connection.charset)]
+
+    def get_prep_lookup(self, lookup_type, value):
+        "Perform preliminary non-db specific lookup checks and conversions"
+        if lookup_type == 'endswith':
+            return "*%s" % escape_ldap_filter(value)
+        elif lookup_type == 'startswith':
+            return "%s*" % escape_ldap_filter(value)
+        elif lookup_type in ['contains', 'icontains']:
+            return "*%s*" % escape_ldap_filter(value)
+        elif lookup_type == 'exact':
+            return escape_ldap_filter(value)
+        elif lookup_type == 'in':
+            return [escape_ldap_filter(v) for v in value]
+
+        raise TypeError("CharField has invalid lookup: %s" % lookup_type)
+
+
+class ImageField(fields.Field):
+    def from_ldap(self, value, connection):
+        if len(value) == 0:
+            return ''
+        else:
+            return value[0]
+
+    def get_db_prep_lookup(self, lookup_type, value, connection,
+                           prepared=False):
+        "Returns field's value prepared for database lookup."
+        return [self.get_prep_lookup(lookup_type, value)]
+
+    def get_db_prep_save(self, value, connection):
+        return [value]
+
+    def get_prep_lookup(self, lookup_type, value):
+        "Perform preliminary non-db specific lookup checks and conversions"
+        raise TypeError("ImageField has invalid lookup: %s" % lookup_type)
+
+
+class IntegerField(fields.IntegerField):
+    def from_ldap(self, value, connection):
+        if len(value) == 0:
+            return 0
+        else:
+            return int(value[0])
+
+    def get_db_prep_lookup(self, lookup_type, value, connection,
+                           prepared=False):
+        "Returns field's value prepared for database lookup."
+        return [self.get_prep_lookup(lookup_type, value)]
+
+    def get_db_prep_save(self, value, connection):
+        return [str(value)]
+
+    def get_prep_lookup(self, lookup_type, value):
+        "Perform preliminary non-db specific lookup checks and conversions"
+        if lookup_type in ('exact', 'gte', 'lte'):
+            return value
+        raise TypeError("IntegerField has invalid lookup: %s" % lookup_type)
+
+
+class FloatField(fields.FloatField):
+    def from_ldap(self, value, connection):
+        if len(value) == 0:
+            return 0.0
+        else:
+            return float(value[0])
+
+    def get_db_prep_lookup(self, lookup_type, value, connection,
+                           prepared=False):
+        "Returns field's value prepared for database lookup."
+        return [self.get_prep_lookup(lookup_type, value)]
+
+    def get_db_prep_save(self, value, connection):
+        return [str(value)]
+
+    def get_prep_lookup(self, lookup_type, value):
+        "Perform preliminary non-db specific lookup checks and conversions"
+        if lookup_type in ('exact', 'gte', 'lte'):
+            return value
+        raise TypeError("FloatField has invalid lookup: %s" % lookup_type)
+
+
+class ListField(fields.Field):
+    __metaclass__ = SubfieldBase
+
+    def from_ldap(self, value, connection):
+        return value
+
+    def get_db_prep_lookup(self, lookup_type, value, connection,
+                           prepared=False):
+        "Returns field's value prepared for database lookup."
+        return [self.get_prep_lookup(lookup_type, value)]
+
+    def get_db_prep_save(self, value, connection):
+        return [x.encode(connection.charset) for x in value]
+
+    def get_prep_lookup(self, lookup_type, value):
+        "Perform preliminary non-db specific lookup checks and conversions"
+        if lookup_type == 'contains':
+            return escape_ldap_filter(value)
+        raise TypeError("ListField has invalid lookup: %s" % lookup_type)
+
+    def to_python(self, value):
+        if not value:
+            return []
+        return value
+
+
+class DateField(fields.DateField):
+    """
+    A text field containing date, in specified format.
+    The format can be specified as 'format' argument, as strptime()
+    format string. It defaults to ISO8601 (%Y-%m-%d).
+
+    Note: 'lte' and 'gte' lookups are done string-wise. Therefore,
+    they will onlywork correctly on Y-m-d dates with constant
+    component widths.
+    """
+
+    def __init__(self, *args, **kwargs):
+        if 'format' in kwargs:
+            self._date_format = kwargs.pop('format')
+        else:
+            self._date_format = '%Y-%m-%d'
+        super(DateField, self).__init__(*args, **kwargs)
+
+    def from_ldap(self, value, connection):
+        if len(value) == 0:
+            return None
+        else:
+            return datetime.datetime.strptime(value[0],
+                                              self._date_format).date()
+
+    def get_db_prep_lookup(self, lookup_type, value, connection,
+                           prepared=False):
+        "Returns field's value prepared for database lookup."
+        return [self.get_prep_lookup(lookup_type, value)]
+
+    def get_db_prep_save(self, value, connection):
+        if not isinstance(value, datetime.date) \
+                and not isinstance(value, datetime.datetime):
+            raise ValueError(
+                'DateField can be only set to a datetime.date instance')
+
+        return [value.strftime(self._date_format)]
+
+    def get_prep_lookup(self, lookup_type, value):
+        "Perform preliminary non-db specific lookup checks and conversions"
+        if lookup_type in ('exact',):
+            return value
+        raise TypeError("DateField has invalid lookup: %s" % lookup_type)

+ 73 - 0
ldapdb/router.py

@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+#
+# django-ldapdb
+# Copyright (c) 2009-2011, Bolloré telecom
+# Copyright (c) 2013, Jeremy Lainé
+# All rights reserved.
+#
+# See AUTHORS file for a full list of contributors.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     1. Redistributions of source code must retain the above copyright notice,
+#        this list of conditions and the following disclaimer.
+#
+#     2. Redistributions in binary form must reproduce the above copyright
+#        notice, this list of conditions and the following disclaimer in the
+#        documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+
+def is_ldap_model(model):
+    # FIXME: there is probably a better check than testing 'base_dn'
+    return hasattr(model, 'base_dn')
+
+
+class Router(object):
+    """
+    A router to point database operations on LDAP models to the LDAP
+    database.
+
+    NOTE: if you have more than one LDAP database, you will need to
+    write your own router.
+    """
+
+    def __init__(self):
+        "Find the name of the LDAP database"
+        from django.conf import settings
+        self.ldap_alias = None
+        for alias, settings_dict in settings.DATABASES.items():
+            if settings_dict['ENGINE'] == 'ldapdb.backends.ldap':
+                self.ldap_alias = alias
+                break
+
+    def allow_syncdb(self, db, model):
+        "Do not create tables for LDAP models"
+        if is_ldap_model(model):
+            return db == self.ldap_alias
+        return None
+
+    def db_for_read(self, model, **hints):
+        "Point all operations on LDAP models to the LDAP database"
+        if is_ldap_model(model):
+            return self.ldap_alias
+        return None
+
+    def db_for_write(self, model, **hints):
+        "Point all operations on LDAP models to the LDAP database"
+        if is_ldap_model(model):
+            return self.ldap_alias
+        return None

+ 165 - 0
ldapdb/tests.py

@@ -0,0 +1,165 @@
+# -*- coding: utf-8 -*-
+#
+# django-ldapdb
+# Copyright (c) 2009-2011, Bolloré telecom
+# Copyright (c) 2013, Jeremy Lainé
+# All rights reserved.
+#
+# See AUTHORS file for a full list of contributors.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     1. Redistributions of source code must retain the above copyright notice,
+#        this list of conditions and the following disclaimer.
+#
+#     2. Redistributions in binary form must reproduce the above copyright
+#        notice, this list of conditions and the following disclaimer in the
+#        documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+from django.test import TestCase
+from django.db.models.sql.where import Constraint, AND, OR, WhereNode
+
+from ldapdb import escape_ldap_filter
+from ldapdb.backends.ldap.compiler import where_as_ldap
+from ldapdb.models.fields import (CharField, IntegerField, FloatField,
+                                  ListField, DateField)
+
+
+class WhereTestCase(TestCase):
+    def test_escape(self):
+        self.assertEquals(escape_ldap_filter(u'fôöbàr'), u'fôöbàr')
+        self.assertEquals(escape_ldap_filter('foo*bar'), 'foo\\2abar')
+        self.assertEquals(escape_ldap_filter('foo(bar'), 'foo\\28bar')
+        self.assertEquals(escape_ldap_filter('foo)bar'), 'foo\\29bar')
+        self.assertEquals(escape_ldap_filter('foo\\bar'), 'foo\\5cbar')
+        self.assertEquals(escape_ldap_filter('foo\\bar*wiz'),
+                          'foo\\5cbar\\2awiz')
+
+    def test_char_field_exact(self):
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'exact', "test"), AND)
+        self.assertEquals(where_as_ldap(where), ("(cn=test)", []))
+
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'exact', "(test)"),
+                  AND)
+        self.assertEquals(where_as_ldap(where), ("(cn=\\28test\\29)", []))
+
+    def test_char_field_in(self):
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'in', ["foo", "bar"]),
+                  AND)
+        self.assertEquals(where_as_ldap(where), ("(|(cn=foo)(cn=bar))", []))
+
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'in',
+                   ["(foo)", "(bar)"]), AND)
+        self.assertEquals(where_as_ldap(where),
+                          ("(|(cn=\\28foo\\29)(cn=\\28bar\\29))", []))
+
+    def test_char_field_startswith(self):
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'startswith', "test"),
+                  AND)
+        self.assertEquals(where_as_ldap(where), ("(cn=test*)", []))
+
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'startswith', "te*st"),
+                  AND)
+        self.assertEquals(where_as_ldap(where), ("(cn=te\\2ast*)", []))
+
+    def test_char_field_endswith(self):
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'endswith', "test"),
+                  AND)
+        self.assertEquals(where_as_ldap(where), ("(cn=*test)", []))
+
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'endswith', "te*st"),
+                  AND)
+        self.assertEquals(where_as_ldap(where), ("(cn=*te\\2ast)", []))
+
+    def test_char_field_contains(self):
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'contains', "test"),
+                  AND)
+        self.assertEquals(where_as_ldap(where), ("(cn=*test*)", []))
+
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'contains', "te*st"),
+                  AND)
+        self.assertEquals(where_as_ldap(where), ("(cn=*te\\2ast*)", []))
+
+    def test_integer_field(self):
+        where = WhereNode()
+        where.add((Constraint("uid", "uid", IntegerField()), 'exact', 1), AND)
+        self.assertEquals(where_as_ldap(where), ("(uid=1)", []))
+
+        where = WhereNode()
+        where.add((Constraint("uid", "uid", IntegerField()), 'gte', 1), AND)
+        self.assertEquals(where_as_ldap(where), ("(uid>=1)", []))
+
+        where = WhereNode()
+        where.add((Constraint("uid", "uid", IntegerField()), 'lte', 1), AND)
+        self.assertEquals(where_as_ldap(where), ("(uid<=1)", []))
+
+    def test_float_field(self):
+        where = WhereNode()
+        where.add((Constraint("uid", "uid", FloatField()), 'exact', 1.2), AND)
+        self.assertEquals(where_as_ldap(where), ("(uid=1.2)", []))
+
+        where = WhereNode()
+        where.add((Constraint("uid", "uid", FloatField()), 'gte', 1.2), AND)
+        self.assertEquals(where_as_ldap(where), ("(uid>=1.2)", []))
+
+        where = WhereNode()
+        where.add((Constraint("uid", "uid", FloatField()), 'lte', 1.2), AND)
+        self.assertEquals(where_as_ldap(where), ("(uid<=1.2)", []))
+
+    def test_list_field_contains(self):
+        where = WhereNode()
+        where.add((Constraint("memberUid", "memberUid", ListField()),
+                   'contains', 'foouser'), AND)
+        self.assertEquals(where_as_ldap(where), ("(memberUid=foouser)", []))
+
+        where = WhereNode()
+        where.add((Constraint("memberUid", "memberUid", ListField()),
+                   'contains', '(foouser)'), AND)
+        self.assertEquals(where_as_ldap(where), ("(memberUid=\\28foouser\\29)",
+                                                 []))
+
+    def test_date_field(self):
+        where = WhereNode()
+        where.add((Constraint("birthday", "birthday", DateField()), 'exact',
+                   '2013-09-03'), AND)
+        self.assertEquals(where_as_ldap(where), ("(birthday=2013-09-03)", []))
+
+    def test_and(self):
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'exact', "foo"), AND)
+        where.add((Constraint("givenName", "givenName", CharField()), 'exact',
+                   "bar"), AND)
+        self.assertEquals(where_as_ldap(where), ("(&(cn=foo)(givenName=bar))",
+                                                 []))
+
+    def test_or(self):
+        where = WhereNode()
+        where.add((Constraint("cn", "cn", CharField()), 'exact', "foo"), AND)
+        where.add((Constraint("givenName", "givenName", CharField()), 'exact',
+                   "bar"), OR)
+        self.assertEquals(where_as_ldap(where), ("(|(cn=foo)(givenName=bar))",
+                                                 []))

+ 0 - 1
requirements.txt

@@ -4,4 +4,3 @@ django-auth-ldap==1.1.4
 psycopg2==2.5.1
 python-ldap==2.4.13
 wsgiref==0.1.2
-django-ldapdb==0.1.0