commit e7f7041dea4599ffc1b21f4b52668419f7ea2dd1 Author: Benjamin Dauvergne Date: Mon Mar 26 08:57:37 2012 +0200 first commit diff --git a/django_directory/__init__.py b/django_directory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_directory/admin.py b/django_directory/admin.py new file mode 100644 index 0000000..6612c34 --- /dev/null +++ b/django_directory/admin.py @@ -0,0 +1,93 @@ +from django.contrib.admin import AdminSite, ModelAdmin, site, TabularInline +from django.db import models + +from .schema import get_schemas, schemas_update_subscribe + +from models import (Schema, View, ViewMember, Attribute, Principal, + Predicate, Relation, AttributeValue, PrincipalUser) + +class DirectoryAdmin(AdminSite): + def __init__(self, *args, **kwargs): + super(DirectoryAdmin, self).__init__(*args, **kwargs) + self.directory_proxy_models = [] + self.regenerate_proxy_models() + schemas_update_subscribe(id(self), self.regenerate_proxy_models) + + def make_accessor(self, attribute): + def accessor(self, principal): + values = principal.attributes.filter(attribute=attribute) + if attribute.multivalued: + return ', '.join(values.values_list('text_value', flat=True)) + else: + if values: + return values[0].text_value + else: + return '' + accessor.__name__ = str(attribute.name) + accessor.short_description = unicode(attribute) + return accessor + + def make_proxy(self, schema, name, model): + class Meta: + proxy = True + verbose_name = unicode(schema) + return type(name, (model,), dict(Meta=Meta, __module__=__name__)) + + def make_model_admin(self, model_admin_name, schema): + model_admin = None + def queryset(self, request): + qs = super(model_admin, self).queryset(request) + qs = qs.filter(schema=schema) + qs.prefetch_related('attributes', 'child_relations', 'parent_relations') + return qs + model_admin = type(model_admin_name, (ModelAdmin,), dict(queryset=queryset)) + return model_admin + + def regenerate_proxy_models(self): + print 'regenerating proxy models' + for proxy_model, proxy_model_admin in self.directory_proxy_models: + self.unregister(proxy_model) + self.directory_proxy_models = [] + for schema in get_schemas(): + prefix = str(schema.name.capitalize()) + name = '%sPrincipalProxy' % prefix + model_admin_name = '%sPrincipalModelAdmin' % prefix + + proxy = self.make_proxy(schema, name, Principal) + model_admin = self.make_model_admin(model_admin_name, schema) + list_display = [] + if schema.views.all()[:1]: + view = schema.views.all()[0] + qs = view.viewmember_set.order_by('order')[:5] + print qs + for i, viewmember in enumerate(qs): + attribute = viewmember.attribute + name = str(attribute.name) + print 'setattre', model_admin, name + setattr(model_admin, name, + self.make_accessor(attribute)) + list_display.append(name) + print 'setting list display', list_display, 'for', proxy + self.register(proxy, model_admin, list_display=list_display) + self.directory_proxy_models.append((proxy, model_admin)) + +site = DirectoryAdmin() + +class AttributeValueInlineAdmin(TabularInline): + model = AttributeValue + +class PrincipalModelAdmin(ModelAdmin): + inlines = [ AttributeValueInlineAdmin ] + +class ViewMemberInlineAdmin(TabularInline): + model = ViewMember + +class ViewModelAdmin(ModelAdmin): + inlines = [ ViewMemberInlineAdmin] + +for model in (Schema, Attribute, Predicate, Relation, + AttributeValue, PrincipalUser): + site.register(model) + +site.register(Principal, PrincipalModelAdmin) +site.register(View, ViewModelAdmin) diff --git a/django_directory/fields.py b/django_directory/fields.py new file mode 100644 index 0000000..d9228aa --- /dev/null +++ b/django_directory/fields.py @@ -0,0 +1,29 @@ +import uuid + +from django.db import models + +class UUIDField(models.Field): + __metaclass__ = models.SubfieldBase + + def __init__(self, *args, **kwargs): + kwargs['max_length'] = 32 + kwargs['editable'] = False + kwargs['blank'] = False + kwargs['unique'] = True + super(UUIDField, self).__init__(*args, **kwargs) + + def pre_save(self, model_instance, add): + value = getattr(model_instance, self.attname, None) + if not value and add: + setattr(model_instance, self.attname, uuid.uuid4().hex) + return super(UUIDField, self).pre_save(model_instance, add) + + def db_type(self, connection=None): + return 'char(%s)' % (self.max_length,) + + def south_field_triple(self): + "Returns a suitable description of this field for South." + from south.modelsinspector import introspector + field_class = "%s.UUIDField" % __name__ + args, kwargs = introspector(self) + return (field_class, args, kwargs) diff --git a/django_directory/forms.py b/django_directory/forms.py new file mode 100644 index 0000000..139597f --- /dev/null +++ b/django_directory/forms.py @@ -0,0 +1,2 @@ + + diff --git a/django_directory/models.py b/django_directory/models.py new file mode 100644 index 0000000..595c645 --- /dev/null +++ b/django_directory/models.py @@ -0,0 +1,299 @@ +from decimal import Decimal +from datetime import time, datetime, date + +from django.db import models +from django.contrib.auth.models import User +from django.forms import (IntegerField, CharField, DateField, DateTimeField, + TimeField, DecimalField, TypedChoiceField, FloatField) +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from django.db.models.signals import post_save, post_delete + +from .fields import UUIDField + + +class decimal(Decimal): + pass + +def parse_iso8601_datetime(iso_time): + return datetime.strptime(iso_time.replace("-", ""), "%Y%m%dT%H:%M:%S") + +def parse_iso8601_date(iso_time): + return datetime.strptime(iso_time.replace("-", ""), "%Y%m%d").date() + +def parse_iso8601_time(iso_time): + return datetime.strptime(iso_time, "%Y%m%d").time() + +ATTRIBUTE_TYPES = { + str: dict(field=CharField), + int: dict(field=IntegerField), + float: dict(field=FloatField), + decimal: dict(field=DecimalField), + date: dict(field=DateField, coerce=parse_iso8601_date), + time: dict(field=TimeField, coerce=parse_iso8601_time), + datetime: dict(field=DateTimeField, coerce=parse_iso8601_datetime), +} +ATTRIBUTE_TYPES_MAP = dict(((t.__name__, t) for t in ATTRIBUTE_TYPES)) +ATTRIBUTE_TYPE_CHOICES = [(t.__name__, t.__name__) for t in ATTRIBUTE_TYPES] + +class Schema(models.Model): + name = models.CharField(max_length=128, unique=True) + display_name = models.CharField(max_length=256) + views = models.ManyToManyField('django_directory.View', blank=True) + object_of = models.ManyToManyField('django_directory.Predicate', + related_name='object_schemas', blank=True) + value_of = models.ManyToManyField('django_directory.Predicate', + related_name='value_schemas', blank=True) + + def __unicode__(self): + return self.display_name or self.name + +class Principal(models.Model): + uuid = UUIDField() + schema = models.ForeignKey(Schema) + + @property + def attributes(self): + return Attributes(self) + + @property + def user(self): + '''Return the user linked to this principal, if none exists, create + one. + ''' + try: + return self.principaluser.user + except PrincipalUser.DoesNotExist: + if not self.id: + raise ValueError('Principal must have been saved to have an user') + user = User.get_or_create(username='principal#%u' % self.uuid) + principal_user = PrincipalUser(user=user, principal_ptr_id=self.pk) + principal_user.__dict__.update(self.__dict__) + principal_user.save() + return user + + def __unicode__(self): + try: + user = self.principaluser.user + return self.uuid + " " + user + except PrincipalUser.DoesNotExist: + return self.uuid + +class PrincipalUser(Principal): + user = models.OneToOneField(User, related_name='principal') + +class Attribute(models.Model): + '''An attribute MUST have a globally unique name, + you can use URI for example. + ''' + name = models.CharField(max_length=128, unique=True) + display_name = models.CharField(max_length=256) + multivalued = models.BooleanField(blank=True) + type_name = models.CharField(max_length=10, choices=ATTRIBUTE_TYPE_CHOICES) + possible_choices = models.TextField(blank=True, null=True) + required = models.BooleanField(blank=True) + + @property + def type(self): + return ATTRIBUTE_TYPES_MAP.get(self.type_name) + + @property + def coerce(self): + t = ATTRIBUTE_TYPES_MAP.get(self.type_name) + return ATTRIBUTE_TYPES[t].get('coerce', t) + + @property + def choices(self): + result = [] + t = self.type() + if self.possible_choices: + for choice in self.possible_choices.strip(','): + result.append(t(choice.strip())) + return result + + + + @property + def field(self, *args, **kwargs): + t = self.type + if self.choices: + coerce = ATTRIBUTE_TYPES[t].get('coerce', t) + return TypedChoiceField( choices=self.choices, coerce=coerce) + else: + return ATTRIBUTE_TYPES[t]['field'](*args, required=self.required, + **kwargs) + + + + + def __unicode__(self): + return self.display_name or self.name + + class Meta: + ordering = ('display_name',) + +class ViewMember(models.Model): + '''Try to put some order upon all those attributes. + + Attribute should be displayed in UI with this ordering, attribute with + the same order can be displayed on the same line for example. + ''' + attribute = models.ForeignKey(Attribute) + view = models.ForeignKey('django_directory.View') + order = models.IntegerField(default=0) + + def __unicode__(self): + return u'%s.%s %s' % (self.view, self.attribute, self.order) + + class Meta: + ordering = ('order','attribute',) + +class View(models.Model): + '''Group attributes together. + + Attributes without a group should not be displayed or editable. + ''' + name = models.CharField(max_length=128, primary_key=True) + display_name = models.CharField(max_length=128) + order = models.IntegerField(default=0) + attributes = models.ManyToManyField(Attribute, + blank=True, null=True, related_name='views', + through=ViewMember) + + def __unicode__(self): + return self.display_name or self.name + + def natural_key(self): + return (self.name,) + + class Meta: + ordering = ('order', 'display_name', 'name') + +class AttributeValue(models.Model): + '''Value for an attribute of a principal''' + user = models.ForeignKey(Principal, related_name='attributes') + attribute = models.ForeignKey(Attribute, related_name='values') + text_value = models.TextField() + + @property + def value(self): + '''Coerce the stored text value into a Python value''' + return self.attribute.coerce(self.text_value) + + def natural_key(self): + return (self.value,) + self.attribute.natural_key() + self.principal.natural_key() + + def clean(self): + if self.attribute.multivalued and self.objects.filter(user=self.user, attribute=self.attribute) \ + .exclude(pk=self.pk): + raise ValidationError(_(u'Attribute %u is not multi-valued') % self.attribute) + + class Meta: + unique_together = (('user', 'attribute', 'text_value'),) + + +class AttributeValuesAsSet(object): + def __init__(self, attribute, **kwargs): + self.attribute = attribute + self.kwargs = kwargs + + def qs(self, value=None): + qs = AttributeValue.objects.filter(*self.kwargs) + if value: + qs = qs.filter(text_value=unicode(value)) + return qs + + def add(self, value): + value = unicode(value) + if value not in self: + Attribute.get_or_create(text_value=value, **self.kwargs) + + def update(self, values): + values = map(unicode, values) + values = set(values) - set(iter(self)) + # since Django 1.4 + if hasattr(AttributeValue.objects, 'bulk_create'): + AttributeValue.objects.bulk_create(( + AttributeValue(text_value=value, **self.kwargs) for value in values)) + else: + for value in values: + self.add(value) + + def remove(self, value): + if value not in self: + raise KeyError() + self.discard(value) + + def discard(self, value): + self.qs(value).delete() + + def clear(self): + self.qs().delete() + + def __contains__(self, value): + return bool(self.qs(value)) + + def __len__(self): + return self.qs().count() + + def __iter__(self): + return iter(self.qs().values_list('text_value', flat=True)) + + +class Attributes(object): + def __init__(self, principal): + self.principal = principal + + def keys(self): + return Attribute.objects.filter(values__principal=self.principal) + + def __getitem__(self, attribute): + if not hasattr(attribute, '_meta'): + attribute = Attribute.objects.get(id=attribute) + return AttributeValuesAsSet(attribute, principal=self.principal) + + def __setitem__(self, attribute, values): + s = self[attribute] + s.update(values) + + def __contains__(self, attribute): + if not hasattr(attribute, '_meta'): + attribute = Attribute.objects.get(id=attribute) + return bool(AttributeValue.filter(attribute=attribute)) + +class DirectoryUser(User): + @property + def principal(self): + return PrincipalUser.get_or_create(user=self) + + @property + def attributes(self): + self.principal.attributes + + class Meta: + proxy = True + +class Predicate(models.Model): + name = models.CharField(max_length=128, unique=True) + display_name = models.CharField(max_length=256) + + def __unicode__(self): + return self.display_name or self.name + +class Relation(models.Model): + '''State a relation between two principals''' + object = models.ForeignKey(Principal, related_name='child_relations') + predicate = models.ForeignKey(Predicate, related_name='relations') + value = models.ForeignKey(Principal, related_name='parent_relations') + +def update_schema(sender, **kwargs): + from . import schema + print 'update schema', kwargs + schema.get_schemas(True) + +for model in (Schema, View, ViewMember, Attribute, Predicate, Relation): + post_save.connect(update_schema, sender=model, + dispatch_uid='schema-rebuild') + post_delete.connect(update_schema, sender=model, + dispatch_uid='schema-rebuild') + diff --git a/django_directory/schema.py b/django_directory/schema.py new file mode 100644 index 0000000..87d2f03 --- /dev/null +++ b/django_directory/schema.py @@ -0,0 +1,19 @@ +from . import models + +__cached_schema = None +__notify = {} + +def schemas_update_subscribe(who, what): + '''Register a function to be notified of schema changes''' + __notify[who] = what + +def get_schemas(force=False): + '''Get and cache schema objects''' + global __cached_schema + + if __cached_schema is None or force: + __cached_schema = list(models.Schema.objects.prefetch_related('views', 'object_of', 'value_of', 'views__attributes')) + if force: + for what in __notify.itervalues(): + what() + return __cached_schema diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..8b38c2a --- /dev/null +++ b/setup.py @@ -0,0 +1,54 @@ +#! /usr/bin/env python +# +from distutils.core import setup, Command +from unittest import TextTestRunner, TestLoader +from glob import glob +from os.path import splitext, basename, join as pjoin +import os + +class TestCommand(Command): + user_options = [ ] + + def initialize_options(self): + self._dir = os.getcwd() + + def finalize_options(self): + pass + + def run(self): + ''' + Finds all the tests modules in tests/, and runs them. + ''' + testfiles = [ ] + for t in glob(pjoin(self._dir, 'tests', '*.py')): + if not t.endswith('__init__.py'): + testfiles.append('.'.join( + ['tests', splitext(basename(t))[0]]) + ) + + tests = TestLoader().loadTestsFromNames(testfiles) + t = TextTestRunner(verbosity = 4) + t.run(tests) + +def ls_r(directory, target): + '''Recursively list files in @directory''' + path = os.path.join(os.path.dirname(__file__), directory) + to_remove = os.path.dirname(path) + for root, _, files in os.walk(path): + root = root.replace(to_remove + '/', '') + file_list = [ os.path.join(root, filename) for filename in files] + yield (os.path.join(target, root), file_list) + +setup(name="django-directory", + version="0.1", + license="AGPLv3 or later", + description="A generic directory data model for Django", + url="http://dev.entrouvert.org/projects/django-directory/", + author="Entr'ouvert", + maintainer="Benjamin Dauvergne", + maintainer_email="bdauvergne@entrouvert.com", + packages=[ 'django_directory' ], + package_data={ '': ['fixtures/*.json', + 'templates/*.html','templates/*/*.html','js/*.js'] }, + data_files=list(ls_r('static', 'share/authentic2/')), +)