diff --git a/admin.py b/admin.py index b06756e..95cf511 100644 --- a/admin.py +++ b/admin.py @@ -53,9 +53,6 @@ class BaseEntityAdmin(ModelAdmin): return super_meth(request, context, **kwargs) - - - class BaseEntityInlineFormSet(BaseInlineFormSet): """ An inline formset that correctly initializes EAV forms. @@ -99,7 +96,7 @@ class BaseEntityInline(InlineModelAdmin): class AttributeAdmin(ModelAdmin): list_display = ('name', 'slug', 'datatype', 'description') - prepopulated_fields = {'name': ('slug',)} + prepopulated_fields = {'slug': ('name',)} admin.site.register(Attribute, AttributeAdmin) admin.site.register(Value) diff --git a/forms.py b/forms.py index 2fc58a2..baab9a0 100644 --- a/forms.py +++ b/forms.py @@ -28,22 +28,6 @@ from django.utils.translation import ugettext_lazy as _ from .utils import EavRegistry -__all__ = ['BaseSchemaForm', 'BaseDynamicEntityForm'] - - -class BaseSchemaForm(ModelForm): - - ''' - def clean_name(self): - "Avoid name clashes between static and dynamic attributes." - name = self.cleaned_data['name'] - reserved_names = self._meta.model._meta.get_all_field_names() - if name not in reserved_names: - return name - raise ValidationError(_('Attribute name must not clash with reserved names' - ' ("%s")') % '", "'.join(reserved_names)) - ''' - class BaseDynamicEntityForm(ModelForm): """ @@ -69,15 +53,15 @@ class BaseDynamicEntityForm(ModelForm): def __init__(self, data=None, *args, **kwargs): super(BaseDynamicEntityForm, self).__init__(data, *args, **kwargs) config_cls = EavRegistry.get_config_cls_for_model(self.instance.__class__) - self.proxy = getattr(self.instance, config_cls.eav_attr) + self.entity = getattr(self.instance, config_cls.eav_attr) self._build_dynamic_fields() def _build_dynamic_fields(self): # reset form fields self.fields = deepcopy(self.base_fields) - for attribute in self.proxy.get_eav_attributes(): - value = getattr(self.proxy, attribute.slug) + for attribute in self.entity.get_all_attributes(): + value = getattr(self.entity, attribute.slug) defaults = { 'label': attribute.name.capitalize(), @@ -120,7 +104,7 @@ class BaseDynamicEntityForm(ModelForm): instance = super(BaseDynamicEntityForm, self).save(commit=False) # assign attributes - for attribute in self.proxy.get_eav_attributes(): + for attribute in self.entity.get_all_attributes(): value = self.cleaned_data.get(attribute.slug) if attribute.datatype == attribute.TYPE_ENUM: if value: @@ -128,7 +112,7 @@ class BaseDynamicEntityForm(ModelForm): else: value = None - setattr(self.proxy, attribute.slug, value) + setattr(self.entity, attribute.slug, value) # save entity and its attributes if commit: diff --git a/models.py b/models.py index ab00ff3..0b6da44 100644 --- a/models.py +++ b/models.py @@ -31,9 +31,9 @@ from .fields import EavSlugField, EavDatatypeField def get_unique_class_identifier(cls): - """ - Return a unique identifier for a class - """ + ''' + Return a unique identifier for a class + ''' return '.'.join((inspect.getfile(cls), cls.__name__)) @@ -86,13 +86,13 @@ class Attribute(models.Model): (TYPE_ENUM, _(u"Multiple Choice")), ) + name = models.CharField(_(u"name"), max_length=100, + help_text=_(u"User-friendly attribute name")) + slug = EavSlugField(_(u"slug"), max_length=50, db_index=True, help_text=_(u"Short unique attribute label"), unique=True) - name = models.CharField(_(u"name"), max_length=100, - help_text=_(u"User-friendly attribute name")) - description = models.CharField(_(u"description"), max_length=256, blank=True, null=True, help_text=_(u"Short description")) @@ -133,59 +133,33 @@ class Attribute(models.Model): return None return self.enum_group.enums.all() + ''' + @classmethod + def get_for_entity_class(cls, entity_cls): + from .utils import EavRegistry + config_cls = EavRegistry.get_config_cls_for_model(entity_cls) + return config_cls.get_attributes() + ''' - def get_value_for_entity(self, entity): - ''' - Passed any object that may be used as an 'entity' object (is linked - to through the generic relation from some EaveValue object. Returns - an Value object that has a foreignkey to self (attribute) and - to the entity. Returns nothing if a matching Value object - doesn't exist. - ''' - ct = ContentType.objects.get_for_model(entity) - qs = self.value_set.filter(entity_ct=ct, entity_id=entity.pk) - count = qs.count() - if count > 1: - raise AttributeError(u"You should have one and only one value "\ - u"for the attribute %s and the entity %s. Found "\ - u"%s" % (self, entity, qs.count())) - if count: - return qs[0] - - return None - - def save_value(self, entity, value): - """ - Save any value for an entity, calling the appropriate method - according to the type of the value. - Value should not be an Value but a normal value - """ - self._save_single_value(entity, value) - - - def _save_single_value(self, entity, value=None, attribute=None): - """ - Save a a value of type that doesn't need special joining like - int, float, text, date, etc. - - Value should not be an Value object but a normal value. - Use attribute if you want to use something else than the current - one - """ ct = ContentType.objects.get_for_model(entity) - attribute = attribute or self try: - eavvalue = self.value_set.get(entity_ct=ct, - entity_id=entity.pk, - attribute=attribute) + value_obj = self.value_set.get(entity_ct=ct, + entity_id=entity.pk, + attribute=self) except Value.DoesNotExist: - eavvalue = self.value_set.model(entity_ct=ct, - entity_id=entity.pk, - attribute=attribute) - if value != eavvalue.value: - eavvalue.value = value - eavvalue.save() + if value == None: + return + value_obj = Value.objects.create(entity_ct=ct, + entity_id=entity.pk, + attribute=self) + if value == None: + value_obj.delete() + return + + if value != value_obj.value: + value_obj.value = value + value_obj.save() def __unicode__(self): @@ -209,6 +183,10 @@ class Value(models.Model): ''' + class Meta: + unique_together = ('entity_ct', 'entity_id', 'attribute') + + entity_ct = models.ForeignKey(ContentType, related_name='value_entities') entity_id = models.IntegerField() entity = generic.GenericForeignKey(ct_field='entity_ct', fk_field='entity_id') @@ -233,7 +211,6 @@ class Value(models.Model): attribute = models.ForeignKey(Attribute, db_index=True, verbose_name=_(u"attribute")) - def save(self, *args, **kwargs): self.full_clean() @@ -276,174 +253,64 @@ class Value(models.Model): class Entity(object): - _cache = {} - def __init__(self, instance): self.model = instance self.ct = ContentType.objects.get_for_model(instance) - # TODO: memoize def __getattr__(self, name): - if not name.startswith('_'): - attribute = self.get_attribute_by_slug(name) - if attribute: - value_obj = attribute.get_value_for_entity(self.model) - if value_obj: - return value_obj.value - return None - + try: + attribute = self.get_attribute_by_slug(name) + except Attribute.DoesNotExist: + raise AttributeError(_(u"%(obj)s has no EAV attribute named " \ + u"'%(attr)s'") % \ + {'obj':self.model, 'attr':name}) + + try: + return self.get_value_by_attribute(attribute).value + except Value.DoesNotExist: + return None return object.__getattr__(self, name) - - @classmethod - def get_eav_attributes_for_model(cls, model_cls): - """ - Return the attributes for this model - """ + def get_all_attributes(self): + ''' + Return all the attributes that are applicable to self.model. + ''' from .utils import EavRegistry - config_cls = EavRegistry.get_config_cls_for_model(model_cls) - return config_cls.get_eav_attributes() - - - @classmethod - def get_attr_cache_for_model(cls, model_cls): - """ - Return the attribute cache for this model - """ - return cls._cache.setdefault(get_unique_class_identifier(model_cls), {}) - - - @classmethod - def update_attr_cache_for_model(cls, model_cls): - """ - Create or update the attributes cache for this entity class. - """ - cache = cls.get_attr_cache_for_model(model_cls) - cache['attributes'] = cls.get_eav_attributes_for_model(model_cls)\ - .select_related() - cache['slug_mapping'] = dict((s.slug, s) for s in cache['attributes']) - return cache - - - @classmethod - def flush_attr_cache_for_model(cls, model_cls): - """ - Flush the attributes cache for this entity class - """ - cache = cls.get_attr_cache_for_model(model_cls) - cache.clear() - return cache - - - def get_eav_attributes(self): - """ - Return the attributes for this model - """ - return self.__class__.get_eav_attributes_for_model(self.model.__class__) - - - def update_attr_cache(self): - """ - Create or update the attributes cache for the entity class linked - to the current instance. - """ - return self.__class__.update_attr_cache_for_model(self.model.__class__) - - - def flush_attr_cache(self): - """ - Flush the attributes cache for the entity class linked - to the current instance. - """ - return self.__class__.flush_attr_cache_for_model(self.model.__class__) - - - def get_attr_cache(self): - """ - Return the attribute cache for the entity class linked - to the current instance. - """ - return self.__class__.get_attr_cache_for_model(self.model.__class__) - + config_cls = EavRegistry.get_config_cls_for_model(self.model.__class__) + return config_cls.get_attributes() def save(self): - for attribute in self.get_eav_attributes(): + for attribute in self.get_all_attributes(): if hasattr(self, attribute.slug): attribute_value = getattr(self, attribute.slug) attribute.save_value(self.model, attribute_value) - - @classmethod - def get_all_attributes_for_model(cls, model_cls): - """ - Return the current cache or if it doesn't exists, update it - and returns it. - """ - cache = cls.get_attr_cache_for_model(model_cls) - if not cache: - cache = Entity.update_attr_cache_for_model(model_cls) - - return cache['attributes'] - - def get_values(self): + ''' + Get all set EAV Value objects for self.model + ''' return Value.objects.filter(entity_ct=self.ct, - entity_id=self.model.pk).select_related() - - @classmethod - def get_all_attribute_slugs_for_model(cls, model_cls): - """ - Returns all attributes slugs for the entity - class linked to the passed model. - """ - cache = cls.get_attr_cache_for_model(model_cls) - if not cache: - cache = Entity.update_attr_cache_for_model(model_cls) - - return cache['slug_mapping'] + entity_id=self.model.pk).select_related() def get_all_attribute_slugs(self): - """ - Returns all attributes slugs for the entity - class linked to the current instance. - """ - m_cls = self.model.__class__ - return self.__class__.get_all_attribute_slugs_for_model(m_cls) - - - @classmethod - def get_attribute_by_slug_for_model(cls, model_cls, slug): - """ - Returns all attributes slugs for the entity - class linked to the passed model. - """ - cache = cls.get_attr_cache_for_model(model_cls) - if not cache: - cache = Entity.update_attr_cache_for_model(model_cls) - return cache['slug_mapping'].get(slug, None) + return self.get_all_attributes().values_list('slug', Flat=True) def get_attribute_by_slug(self, slug): - m_cls = self.model.__class__ - return self.__class__.get_attribute_by_slug_for_model(m_cls, slug) + return self.get_all_attributes().get(slug=slug) + def get_value_by_attribute(self, attribute): + return self.get_values().get(attribute=attribute) def __iter__(self): - """ - Iterates over non-empty EAV attributes. Normal fields are not included. - """ return iter(self.get_values()) - - # todo: cache all changed value in EAV and check against existing attribtue @staticmethod def save_handler(sender, *args, **kwargs): from .utils import EavRegistry config_cls = EavRegistry.get_config_cls_for_model(sender) - instance_eav = getattr(kwargs['instance'], config_cls.eav_attr) - instance_eav.save() - - + entity = getattr(kwargs['instance'], config_cls.eav_attr) + entity.save() diff --git a/tests/basics.py b/tests/basics.py index ad2a6de..466f8a4 100644 --- a/tests/basics.py +++ b/tests/basics.py @@ -25,8 +25,8 @@ class EavBasicTests(TestCase): self.entity = Patient.objects.create(name="Doe") self.value = Value.objects.create(entity=self.entity, - attribute=self.attribute, - value_text='Denver') + attribute=self.attribute, + value_text='Denver') def tearDown(self): EavRegistry.unregister(Patient) @@ -43,15 +43,11 @@ class EavBasicTests(TestCase): def test_can_eaventity_children_give_you_all_attributes_by_default(self): - qs = Patient.eav.get_eav_attributes() + p = Patient.objects.create(name='bob') + qs = p.eav.get_all_attributes() self.assertEqual(list(qs), list(Attribute.objects.all())) - def test_value_creation(self): - Value.objects.create(entity=self.entity, - attribute=self.attribute, - value_float=1.2) - def test_value_unicode(self): self.assertEqual(unicode(self.value), "Doe - City: \"Denver\"") diff --git a/tests/filters.py b/tests/filters.py index a29057a..b8f3982 100644 --- a/tests/filters.py +++ b/tests/filters.py @@ -217,9 +217,11 @@ class EavFilterTests(TestCase): EavRegistry.register(User) Attribute.objects.create(datatype=Attribute.TYPE_INT, - name='Height') + name='Height') + Attribute.objects.create(datatype=Attribute.TYPE_FLOAT, - name='Weight') + name='Weight') + u = User.objects.create(username='Bob') u.eav.height = 10 u.eav.weight = 20 diff --git a/tests/set_and_get.py b/tests/set_and_get.py index 0bdc7a5..e27648d 100644 --- a/tests/set_and_get.py +++ b/tests/set_and_get.py @@ -16,7 +16,6 @@ class EavSetterAndGetterTests(TestCase): def setUp(self): - EavRegistry.unregister(Patient) EavRegistry.register(Patient) @@ -27,8 +26,9 @@ class EavSetterAndGetterTests(TestCase): self.patient = Patient.objects.create(name="Doe") self.value = Value.objects.create(entity=self.patient, - attribute=self.attribute, - value_text='Denver') + attribute=self.attribute, + value_text='Denver') + def tearDown(self): EavRegistry.unregister(Patient) @@ -38,48 +38,36 @@ class EavSetterAndGetterTests(TestCase): def additional_attribute_setup(self): self.country_attr = Attribute.objects\ - .create(datatype=Attribute.TYPE_TEXT, - name='Country', slug='country') + .create(datatype=Attribute.TYPE_TEXT, + name='Country', slug='country') class PatientEav(EavConfig): @classmethod - def get_eav_attributes(cls): + def get_attributes(cls): return Attribute.objects.filter(slug='country') - + self.PatientEav = PatientEav - + class UserEav(EavConfig): @classmethod - def get_eav_attributes(cls): + def get_attributes(cls): return Attribute.objects.filter(slug='city') - - self.UserEav = UserEav - - def test_get_value_to_entity(self): - self.assertEqual(self.attribute.get_value_for_entity(self.patient), - self.value) + self.UserEav = UserEav def test_save_single_value(self): patient = Patient.objects.create(name="x") attr = Attribute.objects.create(datatype=Attribute.TYPE_TEXT, name='a', slug='a') - # does nothing - attr._save_single_value(patient) - + # save value - attr._save_single_value(patient, 'b') + attr.save_value(patient, 'b') patient = Patient.objects.get(name="x") self.assertEqual(patient.eav.a, 'b') - - # save value on another attribute - attr._save_single_value(patient, 'Paris', self.attribute) - patient = Patient.objects.get(name="x") - self.assertEqual(patient.eav.city, 'Paris') - + def test_save_value(self): # TODO: update test_save_value when multiple values are out @@ -169,15 +157,7 @@ class EavSetterAndGetterTests(TestCase): def test_get_a_value_that_does_not_exists(self): - # return None for non '_' values - self.assertEqual(self.patient.eav.impossible_value, None) - - # normal behavior for '_' values - try: - self.patient.eav._impossible_value - self.fail() - except AttributeError: - pass + self.assertFalse(hasattr(self.patient.eav, 'impossible_value')) def test_attributes_are_filtered_according_to_config_class(self): @@ -188,15 +168,18 @@ class EavSetterAndGetterTests(TestCase): EavRegistry.register(Patient, self.PatientEav) EavRegistry.register(User, self.UserEav) - self.assertEqual(list(Patient.eav.get_eav_attributes()), + p = Patient.objects.create(name='Bob') + + u = User.objects.create(username='hope') + + self.assertEqual(list(p.eav.get_all_attributes()), list(Attribute.objects.filter(slug='country'))) - self.assertEqual(list(User.eav.get_eav_attributes()), + self.assertEqual(list(u.eav.get_all_attributes()), list(Attribute.objects.filter(slug='city'))) def test_can_filter_attribute_availability_for_entity(self): - self.additional_attribute_setup() self.patient.eav.city = 'Tunis' @@ -213,7 +196,7 @@ class EavSetterAndGetterTests(TestCase): p.save() p = Patient.objects.get(pk=p.pk) - self.assertFalse(p.eav.city, 'Paris') + self.assertFalse(hasattr(p.eav, 'city')) self.assertEqual(p.eav.country, 'USA') @@ -238,10 +221,10 @@ class EavSetterAndGetterTests(TestCase): p = Patient.objects.get(pk=p.pk) u = User.objects.get(pk=u.pk) - self.assertFalse(p.eav.city) + self.assertFalse(hasattr(p.eav, 'city')) self.assertEqual(p.eav.country, 'USA') - self.assertFalse(u.eav.country) + self.assertFalse(hasattr(u.eav, 'country')) self.assertEqual(u.eav.city, 'Paris') @@ -254,7 +237,7 @@ class EavSetterAndGetterTests(TestCase): class SubPatientEav(self.PatientEav): @classmethod - def get_eav_attributes(cls): + def get_attributes(cls): return Attribute.objects.filter(slug='country') EavRegistry.register(Patient, SubPatientEav) @@ -265,7 +248,7 @@ class EavSetterAndGetterTests(TestCase): p = Patient.objects.get(pk=self.patient.pk) - self.assertFalse(p.eav.city) + self.assertFalse(hasattr(p.eav, 'city')) self.assertEqual(p.eav.country, 'USA') @@ -296,18 +279,7 @@ class EavSetterAndGetterTests(TestCase): self.assertEqual(self.value.value_object, None) - def test_get_eav_attributes(self): - - self.additional_attribute_setup() - EavRegistry.unregister(Patient) - EavRegistry.register(Patient, self.PatientEav) - - assert list(self.PatientEav.get_eav_attributes_for_model(Patient))\ - == list(Entity.get_eav_attributes_for_model(Patient))\ - == list(self.patient.eav.get_eav_attributes())\ - == list(Patient.eav.get_eav_attributes_for_model(Patient))\ - == list(Attribute.objects.filter(slug='country')) - + def test_values(self): self.additional_attribute_setup() @@ -318,33 +290,7 @@ class EavSetterAndGetterTests(TestCase): self.assertEqual(list(self.patient.eav.get_values()), list(Value.objects.exclude(value_text='Denver'))) - - def test_get_all_attribute_slugs_for_model(self): - - self.country_attr = Attribute.objects\ - .create(datatype=Attribute.TYPE_TEXT, - name='Street', slug='street') - - self.additional_attribute_setup() - - class UserEav(EavConfig): - @classmethod - def get_eav_attributes(cls): - return Attribute.objects.exclude(slug='city') - - EavRegistry.register(User, UserEav) - - u = User.objects.create(username='John') - - slugs = dict((s.slug, s) for s in Attribute.objects\ - .exclude(slug='city')) - - assert slugs\ - == Entity.get_all_attribute_slugs_for_model(User)\ - == User.eav.get_all_attribute_slugs_for_model(User)\ - == u.eav.get_all_attribute_slugs() - def test_iteration(self): self.additional_attribute_setup() diff --git a/utils.py b/utils.py index bb13ba3..3057f0a 100644 --- a/utils.py +++ b/utils.py @@ -34,7 +34,7 @@ class EavConfig(Entity): generic_relation_related_name = None @classmethod - def get_eav_attributes(cls): + def get_attributes(cls): """ By default, all attributes apply to an entity, unless otherwise specified. @@ -70,18 +70,7 @@ class EavRegistry(object): config_cls = EavRegistry.get_config_cls_for_model(sender) setattr(instance, config_cls.eav_attr, Entity(instance)) - - - @staticmethod - def update_attribute_cache(sender, *args, **kwargs): - """ - Update the attribute cache for all the models every time we - create an attribute. - """ - for cache in EavRegistry.cache.itervalues(): - Entity.update_attr_cache_for_model(cache['model_cls']) - - + @staticmethod def wrap_config_class(model_cls, config_cls): """ @@ -132,19 +121,13 @@ class EavRegistry(object): # todo : not useful anymore ? setattr(getattr(model_cls, config_cls.eav_attr), - 'get_eav_attributes', config_cls.get_eav_attributes) + 'get_attributes', config_cls.get_attributes) # attache the new manager to the model mgr = EntityManager() mgr.contribute_to_class(model_cls, config_cls.manager_attr) if not manager_only: - # todo: see with david how to change that - try: - Entity.update_attr_cache_for_model(model_cls) - except DatabaseError: - pass - # todo: make that overridable # attach the generic relation to the model if config_cls.generic_relation_related_name: @@ -200,15 +183,10 @@ class EavRegistry(object): delattr(model_cls, config_cls.eav_attr) except AttributeError: pass - - if not manager_only: - Entity.flush_attr_cache_for_model(model_cls) EavRegistry.cache.pop(cls_id) # todo : test cache # todo : tst unique identitfier # todo: test update attribute cache on attribute creation - -post_save.connect(EavRegistry.update_attribute_cache, sender=Attribute) -post_delete.connect(EavRegistry.update_attribute_cache, sender=Attribute) +