X-Git-Url: http://git.ithinksw.org/philo.git/blobdiff_plain/82b08f79564159d7acbcaf255ed1ac1fb4882e64..d19e216035b14d8f60b24dda0c0670e6997f16ce:/philo/models/fields/entities.py diff --git a/philo/models/fields/entities.py b/philo/models/fields/entities.py new file mode 100644 index 0000000..6c407d0 --- /dev/null +++ b/philo/models/fields/entities.py @@ -0,0 +1,261 @@ +""" +The EntityProxyFields defined in this file can be assigned as fields on +a subclass of philo.models.Entity. They act like any other model +fields, but instead of saving their data to the database, they save it +to attributes related to a model instance. Additionally, a new +attribute will be created for an instance if and only if the field's +value has been set. This is relevant i.e. for passthroughs, where the +value of the field may be defined by some other instance's attributes. + +Example:: + + class Thing(Entity): + numbers = models.PositiveIntegerField() + + class ThingProxy(Thing): + improvised = JSONAttribute(models.BooleanField) +""" +from itertools import tee +from django import forms +from django.core.exceptions import FieldError +from django.db import models +from django.db.models.fields import NOT_PROVIDED +from django.utils.text import capfirst +from philo.signals import entity_class_prepared +from philo.models import ManyToManyValue, JSONValue, ForeignKeyValue, Attribute, Entity +import datetime + + +__all__ = ('JSONAttribute', 'ForeignKeyAttribute', 'ManyToManyAttribute') + + +ATTRIBUTE_REGISTRY = '_attribute_registry' + + +class EntityProxyField(object): + def __init__(self, verbose_name=None, help_text=None, default=NOT_PROVIDED, editable=True, choices=None, *args, **kwargs): + self.verbose_name = verbose_name + self.help_text = help_text + self.default = default + self.editable = editable + self._choices = choices or [] + + def actually_contribute_to_class(self, sender, **kwargs): + sender._entity_meta.add_proxy_field(self) + + def contribute_to_class(self, cls, name): + if issubclass(cls, Entity): + self.name = self.attname = name + self.model = cls + if self.verbose_name is None and name: + self.verbose_name = name.replace('_', ' ') + entity_class_prepared.connect(self.actually_contribute_to_class, sender=cls) + else: + raise FieldError('%s instances can only be declared on Entity subclasses.' % self.__class__.__name__) + + def formfield(self, form_class=forms.CharField, **kwargs): + defaults = { + 'required': False, + 'label': capfirst(self.verbose_name), + 'help_text': self.help_text + } + if self.has_default(): + defaults['initial'] = self.default + defaults.update(kwargs) + return form_class(**defaults) + + def value_from_object(self, obj): + """The return value of this method will be used by the EntityForm as + this field's initial value.""" + return getattr(obj, self.name) + + def get_storage_value(self, value): + """Final conversion of `value` before it gets stored on an Entity instance. + This step is performed by the ProxyFieldForm.""" + return value + + def has_default(self): + return self.default is not NOT_PROVIDED + + def _get_choices(self): + if hasattr(self._choices, 'next'): + choices, self._choices = tee(self._choices) + return choices + else: + return self._choices + choices = property(_get_choices) + + +class AttributeFieldDescriptor(object): + def __init__(self, field): + self.field = field + + def get_registry(self, instance): + if ATTRIBUTE_REGISTRY not in instance.__dict__: + instance.__dict__[ATTRIBUTE_REGISTRY] = {'added': set(), 'removed': set()} + return instance.__dict__[ATTRIBUTE_REGISTRY] + + def __get__(self, instance, owner): + if instance is None: + return self + + if self.field.name not in instance.__dict__: + instance.__dict__[self.field.name] = instance.attributes.get(self.field.attribute_key, None) + + return instance.__dict__[self.field.name] + + def __set__(self, instance, value): + if instance is None: + raise AttributeError("%s must be accessed via instance" % self.field.name) + + self.field.validate_value(value) + instance.__dict__[self.field.name] = value + + registry = self.get_registry(instance) + registry['added'].add(self.field) + registry['removed'].discard(self.field) + + def __delete__(self, instance): + del instance.__dict__[self.field.name] + + registry = self.get_registry(instance) + registry['added'].discard(self.field) + registry['removed'].add(self.field) + + +def process_attribute_fields(sender, instance, created, **kwargs): + if ATTRIBUTE_REGISTRY in instance.__dict__: + registry = instance.__dict__[ATTRIBUTE_REGISTRY] + instance.attribute_set.filter(key__in=[field.attribute_key for field in registry['removed']]).delete() + + for field in registry['added']: + try: + attribute = instance.attribute_set.get(key=field.attribute_key) + except Attribute.DoesNotExist: + attribute = Attribute() + attribute.entity = instance + attribute.key = field.attribute_key + + value_class = field.value_class + if isinstance(attribute.value, value_class): + value = attribute.value + else: + if isinstance(attribute.value, models.Model): + attribute.value.delete() + value = value_class() + + value.set_value(getattr(instance, field.name, None)) + value.save() + + attribute.value = value + attribute.save() + del instance.__dict__[ATTRIBUTE_REGISTRY] + + +class AttributeField(EntityProxyField): + def __init__(self, attribute_key=None, **kwargs): + self.attribute_key = attribute_key + super(AttributeField, self).__init__(**kwargs) + + def actually_contribute_to_class(self, sender, **kwargs): + super(AttributeField, self).actually_contribute_to_class(sender, **kwargs) + setattr(sender, self.name, AttributeFieldDescriptor(self)) + opts = sender._entity_meta + if not hasattr(opts, '_has_attribute_fields'): + opts._has_attribute_fields = True + models.signals.post_save.connect(process_attribute_fields, sender=sender) + + def contribute_to_class(self, cls, name): + if self.attribute_key is None: + self.attribute_key = name + super(AttributeField, self).contribute_to_class(cls, name) + + def validate_value(self, value): + "Confirm that the value is valid or raise an appropriate error." + pass + + @property + def value_class(self): + raise AttributeError("value_class must be defined on AttributeField subclasses.") + + +class JSONAttribute(AttributeField): + value_class = JSONValue + + def __init__(self, field_template=None, **kwargs): + super(JSONAttribute, self).__init__(**kwargs) + if field_template is None: + field_template = models.CharField(max_length=255) + self.field_template = field_template + + def formfield(self, **kwargs): + defaults = { + 'required': False, + 'label': capfirst(self.verbose_name), + 'help_text': self.help_text + } + if self.has_default(): + defaults['initial'] = self.default + defaults.update(kwargs) + return self.field_template.formfield(**defaults) + + def value_from_object(self, obj): + value = super(JSONAttribute, self).value_from_object(obj) + if isinstance(self.field_template, (models.DateField, models.DateTimeField)): + value = self.field_template.to_python(value) + return value + + def get_storage_value(self, value): + if isinstance(value, datetime.datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(value, datetime.date): + return value.strftime("%Y-%m-%d") + return value + + +class ForeignKeyAttribute(AttributeField): + value_class = ForeignKeyValue + + def __init__(self, model, limit_choices_to=None, **kwargs): + super(ForeignKeyAttribute, self).__init__(**kwargs) + self.to = model + if limit_choices_to is None: + limit_choices_to = {} + self.limit_choices_to = limit_choices_to + + def validate_value(self, value): + if value is not None and not isinstance(value, self.to) : + raise TypeError("The '%s' attribute can only be set to an instance of %s or None." % (self.name, self.to.__name__)) + + def formfield(self, form_class=forms.ModelChoiceField, **kwargs): + defaults = { + 'queryset': self.to._default_manager.complex_filter(self.limit_choices_to) + } + defaults.update(kwargs) + return super(ForeignKeyAttribute, self).formfield(form_class=form_class, **defaults) + + def value_from_object(self, obj): + relobj = super(ForeignKeyAttribute, self).value_from_object(obj) + return getattr(relobj, 'pk', None) + + def get_related_field(self): + """Spoof being a rel from a ForeignKey.""" + return self.to._meta.pk + + +class ManyToManyAttribute(ForeignKeyAttribute): + value_class = ManyToManyValue + + def validate_value(self, value): + if not isinstance(value, models.query.QuerySet) or value.model != self.to: + raise TypeError("The '%s' attribute can only be set to a %s QuerySet." % (self.name, self.to.__name__)) + + def formfield(self, form_class=forms.ModelMultipleChoiceField, **kwargs): + return super(ManyToManyAttribute, self).formfield(form_class=form_class, **kwargs) + + def value_from_object(self, obj): + qs = super(ForeignKeyAttribute, self).value_from_object(obj) + try: + return qs.values_list('pk', flat=True) + except: + return [] \ No newline at end of file