# -*- coding: utf-8 -*- # # EAV-Django is a reusable Django application which implements EAV data model # Copyright © 2009—2010 Andrey Mikhaylenko # # This file is part of EAV-Django. # # EAV-Django is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # EAV-Django is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with EAV-Django. If not, see . """ Models ~~~~~~ """ # django from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes import fields from django.db.models import (BooleanField, CharField, DateField, FloatField, ForeignKey, IntegerField, Model, NullBooleanField, TextField) from django.utils.translation import ugettext_lazy as _ # 3rd-party from autoslug.fields import AutoSlugField from autoslug.settings import slugify #from view_shortcuts.decorators import cached_property # this app from managers import BaseEntityManager __all__ = ['BaseAttribute', 'BaseChoice', 'BaseEntity', 'BaseSchema'] def slugify_attr_name(name): return slugify(name.replace('_', '-')).replace('-', '_') def get_entity_lookups(entity): ctype = ContentType.objects.get_for_model(entity) return {'entity_type': ctype, 'entity_id': entity.pk} class BaseSchema(Model): """ Metadata for an attribute. """ TYPE_TEXT = 'text' TYPE_FLOAT = 'float' TYPE_DATE = 'date' TYPE_BOOLEAN = 'bool' TYPE_ONE = 'one' TYPE_MANY = 'many' TYPE_RANGE = 'range' DATATYPE_CHOICES = ( (TYPE_TEXT, _('text')), (TYPE_FLOAT, _('number')), (TYPE_DATE, _('date')), (TYPE_BOOLEAN, _('boolean')), (TYPE_ONE, _('choice')), (TYPE_MANY, _('multiple choices')), (TYPE_RANGE, _('numeric range')), ) title = CharField(_('title'), max_length=250, help_text=_('user-friendly attribute name')) name = AutoSlugField(_('name'), max_length=250, populate_from='title', editable=True, blank=True, slugify=slugify_attr_name) help_text = CharField(_('help text'), max_length=250, blank=True, help_text=_('short description for administrator')) datatype = CharField(_('data type'), max_length=5, choices=DATATYPE_CHOICES) required = BooleanField(_('required'), default=False) searched = BooleanField(_('include in search'), default=False) # i.e. full-text search? mb for text only filtered = BooleanField(_('include in filters'), default=False) sortable = BooleanField(_('allow sorting'), default=False) class Meta: abstract = True verbose_name, verbose_name_plural = _('schema'), _('schemata') ordering = ['title'] def __unicode__(self): return u'%s (%s)%s' % (self.title, self.get_datatype_display(), u' %s'%_('required') if self.required else '') def get_choices(self): """ Returns a queryset of choice objects bound to this schema. """ return self.choices.all() def get_attrs(self, entity): """ Returns available attributes for given entity instance. Handles many-to-one relations transparently. """ return self.attrs.filter(**get_entity_lookups(entity)) def save_attr(self, entity, value): """ Saves given EAV attribute with given value for given entity. If schema is not a choice, the value is saved to the corresponding Attr instance (which is created or updated). If schema is an cvhoice (one-to-one or many-to-one), the value is processed thusly: * if value is iterable, all Attr instances for corresponding managed choice schemata are updated (those with names from the value list are set to True, others to False). If a list item is not in available choices, ValueError is raised; * if the value is None, all corresponding Attr instances are reset to False; * if the value is neither a list nor None, it is wrapped into a list and processed as above (i.e. "foo" --> ["foo"]). """ if self.datatype in (self.TYPE_ONE, self.TYPE_MANY): self._save_choice_attr(entity, value) else: self._save_single_attr(entity, value) def _save_single_attr(self, entity, value=None, schema=None, create_nulls=False, extra={}): """ Creates or updates an EAV attribute for given entity with given value. :param schema: schema for attribute. Default it current schema instance. :param create_nulls: boolean: if True, even attributes with value=None are created (by default they are skipped). :param extra: dict: additional data for Attr instance (e.g. title). """ # If schema is not many-to-one, the value is saved to the corresponding # Attr instance (which is created or updated). schema = schema or self lookups = dict(get_entity_lookups(entity), schema=schema, **extra) try: attr = self.attrs.get(**lookups) except self.attrs.model.DoesNotExist: attr = self.attrs.model(**lookups) if create_nulls or value != attr.value: attr.value = value for k,v in extra.items(): setattr(attr, k, v) attr.save() def _save_choice_attr(self, entity, value): """ Creates or updates BaseChoice(s) attribute(s) for given entity. """ # value can be None to reset choices from schema if value == None: value = [] if not hasattr(value, '__iter__'): value = [value] if self.datatype == self.TYPE_ONE and len(value) > 1: raise TypeError('Cannot assign multiple values "%s" to TYPE_ONE ' 'must be only one BaseChoice instance.' % value) if not all(isinstance(x, BaseChoice) for x in value): raise TypeError('Cannot assign "%s": "Attr.choice" ' 'must be a BaseChoice instance.' % value) # drop all attributes for this entity/schema pair self.get_attrs(entity).delete() # Attr instances for corresponding managed choice schemata are updated for choice in value: self._save_single_attr( entity, schema = self, create_nulls = True, extra = {'choice': choice} ) class BaseEntity(Model): """ Entity, the "E" in EAV. This model is abstract and must be subclassed. See tests for examples. """ objects = BaseEntityManager() class Meta: abstract = True def save(self, force_eav=False, **kwargs): """ Saves entity instance and creates/updates related attribute instances. :param eav: if True (default), EAV attributes are saved along with entity. """ # save entity super(BaseEntity, self).save(**kwargs) # TODO: think about use cases; are we doing it right? #if not self.check_eav_allowed(): # import warnings # warnings.warn('EAV attributes are going to be saved along with entity' # ' despite %s.check_eav_allowed() returned False.' # % type(self), RuntimeWarning) # create/update EAV attributes for schema in self.get_schemata(): value = getattr(self, schema.name, None) schema.save_attr(self, value) def __getattr__(self, name): if not name.startswith('_'): if name in self.get_schema_names(): schema = self.get_schema(name) attrs = schema.get_attrs(self) if schema.datatype == schema.TYPE_MANY: return [a.value for a in attrs if a.value] else: return attrs[0].value if attrs else None raise AttributeError('%s does not have attribute named "%s".' % (self._meta.object_name, name)) def __iter__(self): "Iterates over non-empty EAV attributes. Normal fields are not included." for attr in self.attrs.select_related(): if getattr(self, attr.schema.name, None): yield attr @classmethod def get_schemata_for_model(cls): return NotImplementedError('BaseEntity subclasses must define method ' '"get_schemata_for_model" which returns a ' 'QuerySet for a BaseSchema subclass.') def get_schemata_for_instance(self, qs): return qs def get_schemata(self): if hasattr(self, '_schemata_cache') and self._schemata_cache is not None: return self._schemata_cache all_schemata = self.get_schemata_for_model().select_related() self._schemata_cache = self.get_schemata_for_instance(all_schemata) self._schemata_cache_dict = dict((s.name, s) for s in self._schemata_cache) return self._schemata_cache def get_schema_names(self): if not hasattr(self, '_schemata_cache_dict'): self.get_schemata() return self._schemata_cache_dict.keys() def get_schema(self, name): if not hasattr(self, '_schemata_cache_dict'): self.get_schemata() return self._schemata_cache_dict[name] def get_schema_by_id(self, schema_id): for schema in self.get_schemata(): if schema.pk == schema_id: return schema def check_eav_allowed(self): """ Returns True if entity instance allows EAV attributes to be attached. Can be useful if some external data is required to determine available schemata and that data may be missing. In such cases this method should be overloaded to check whether the data is available. """ return True def is_valid(self): "Returns True if attributes and their values conform with schema." raise NotImplementedError() ''' schemata = self.rubric.schemata.all() return all(x.is_valid for x in self.attributes) # 1. check if all required attributes are present for schema in schemata: pass # 2. check if all attributes have appropriate values for schema in schemata: pass return True ''' class BaseChoice(Model): """ Base class for choices. Concrete choice class must overload the `schema` attribute. """ title = CharField(max_length=100) schema = NotImplemented class Meta: abstract = True ordering = ('title',) def __unicode__(self): return self.title #u'%s "%s"' % (self.schema.title, self.title) class BaseAttribute(Model): """ Base class for choices. Concrete choice class must overload the `schema` and `choice` attributes. """ entity_type = ForeignKey(ContentType) entity_id = IntegerField() entity = fields.GenericForeignKey(ct_field="entity_type", fk_field='entity_id') value_text = TextField(blank=True, null=True) value_float = FloatField(blank=True, null=True) value_date = DateField(blank=True, null=True) value_bool = NullBooleanField(blank=True) # TODO: ensure that form invalidates null booleans (??) value_range_min = FloatField(blank=True, null=True) value_range_max = FloatField(blank=True, null=True) schema = NotImplemented # must be FK choice = NotImplemented # must be nullable FK class Meta: abstract = True verbose_name, verbose_name_plural = _('attribute'), _('attributes') ordering = ['entity_type', 'entity_id', 'schema'] unique_together = ('entity_type', 'entity_id', 'schema', 'choice') def __unicode__(self): return u'%s: %s "%s"' % (self.entity, self.schema.title, self.value) def _get_value(self): if self.schema.datatype in (self.schema.TYPE_ONE, self.schema.TYPE_MANY): return self.choice if self.schema.datatype == self.schema.TYPE_RANGE: names = ('value_range_%s' % x for x in ('min', 'max')) value = tuple(getattr(self, x, None) for x in names) return None if value == (None, None) else value return getattr(self, 'value_%s' % self.schema.datatype) def _set_value(self, new_value): if self.schema.datatype == self.schema.TYPE_RANGE: new_value = new_value or (None, None) # validate range value -- expecting a tuple of two numbers try: validate_range_value(new_value) except (TypeError, ValueError): raise for k,v in zip('min max'.split(), new_value): v = v if v is None else float(v) setattr(self, 'value_range_%s' % k, v) else: setattr(self, 'value_%s' % self.schema.datatype, new_value) value = property(_get_value, _set_value) def validate_range_value(value): """ Validates given value against `Schema.TYPE_RANGE` data type. Raises TypeError or ValueError if something is wrong. Returns None if everything is OK. """ if value == (None, None): return if not hasattr(value, '__iter__'): raise TypeError('Range value must be an iterable, got "%s".' % value) if not 2 == len(value): raise ValueError('Range value must consist of two elements, got %d.' % len(value)) if not all(isinstance(x, (int,float)) for x in value): raise TypeError('Range value must consist of two numbers, got "%s" ' 'and "%s" instead.' % value) if not value[0] <= value[1]: raise ValueError('Range must consist of min and max values (min <= ' 'max) but got "%s" and "%s" instead.' % value) return # xxx catch signal Attr.post_save() --> update attr.item.attribute_cache (JSONField or such)