diff --git a/.gitignore b/.gitignore index e4f7286..5450e24 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ *.sqlite* _build build +django_eav.egg-info/* +*.DS_Store +env/ +.idea/ diff --git a/README.rst b/README.rst index c0aac2d..5ab2f1b 100644 --- a/README.rst +++ b/README.rst @@ -5,6 +5,7 @@ django-eav Introduction ------------ + django-eav provides an Entity-Attribute-Value storage model for django apps. For a decent explanation of what an Entity-Attribute-Value storage model is, diff --git a/docs/index.rst b/docs/index.rst index 47474f9..2567831 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -139,6 +139,9 @@ For example:: eav.register(MyModel, MyEavConfigClass) +To override ``entity_id`` to use ``entity_uuid`` for entity relationship with +model add ``EAV_ENTITY_ID_TYPE='uuid'`` to use ``entity_uuid`` (defaults to +int for ``entity_id``) Using Attributes ================ @@ -151,7 +154,7 @@ First, let's create some attributes:: >>> Attribute.objects.create(name='Weight', datatype=Attribute.TYPE_FLOAT) >>> Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT) - >>> Attribute.objects.create(name='Is pregant?', datatype=Attribute.TYPE_BOOLEAN) + >>> Attribute.objects.create(name='Is pregnant?', datatype=Attribute.TYPE_BOOLEAN) Now let's create a patient, and set some of these attributes:: diff --git a/eav/__init__.py b/eav/__init__.py index 73a21be..e4cc249 100644 --- a/eav/__init__.py +++ b/eav/__init__.py @@ -27,9 +27,9 @@ def get_version(): __version__ = get_version() def register(model_cls, config_cls=None): - from registry import Registry + from .registry import Registry Registry.register(model_cls, config_cls) def unregister(model_cls): - from registry import Registry + from .registry import Registry Registry.unregister(model_cls) diff --git a/eav/admin.py b/eav/admin.py index c7ab77d..83a5e97 100644 --- a/eav/admin.py +++ b/eav/admin.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # vim: ai ts=4 sts=4 et sw=4 coding=utf-8 # -# This software is derived from EAV-Django originally written and +# This software is derived from EAV-Django originally written and # copyrighted by Andrey Mikhaylenko # # This is free software: you can redistribute it and/or modify @@ -27,8 +27,9 @@ from .models import Attribute, Value, EnumValue, EnumGroup + class BaseEntityAdmin(ModelAdmin): - + def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): """ Wrapper for ModelAdmin.render_change_form. Replaces standard static @@ -93,8 +94,9 @@ def get_fieldsets(self, request, obj=None): return [(None, {'fields': form.fields.keys()})] + class AttributeAdmin(ModelAdmin): - list_display = ('name', 'slug', 'datatype', 'description', 'site') + list_display = ('name', 'content_type', 'slug', 'datatype', 'description', 'site') list_filter = ['site'] prepopulated_fields = {'slug': ('name',)} diff --git a/eav/fields.py b/eav/fields.py index 94e9740..8f7c9c7 100644 --- a/eav/fields.py +++ b/eav/fields.py @@ -82,9 +82,10 @@ def validate(self, value, instance): :class:`~eav.models.Value` objects. ''' super(EavDatatypeField, self).validate(value, instance) - from .models import Attribute if not instance.pk: return + if type(instance).objects.get(pk=instance.pk).datatype == instance.datatype: + return if instance.value_set.count(): raise ValidationError(_(u"You cannot change the datatype of an " u"attribute that is already in use.")) diff --git a/eav/managers.py b/eav/managers.py index 315c28c..8471cef 100644 --- a/eav/managers.py +++ b/eav/managers.py @@ -110,11 +110,11 @@ def expand_eav_filter(model_cls, key, value): return '%s__in' % gr_name, value try: - field, m, direct, m2m = model_cls._meta.get_field_by_name(fields[0]) + field = model_cls._meta.get_field(fields[0]) except models.FieldDoesNotExist: return key, value - if direct: + if not field.auto_created or field.concrete: return key, value else: sub_key = '__'.join(fields[1:]) diff --git a/eav/migrations/0001_initial.py b/eav/migrations/0001_initial.py new file mode 100644 index 0000000..89d6ba3 --- /dev/null +++ b/eav/migrations/0001_initial.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.10 on 2016-10-13 05:56 +from __future__ import unicode_literals + +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.manager +import django.utils.timezone +import eav.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('sites', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Attribute', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='User-friendly attribute name', max_length=100, verbose_name='name')), + ('slug', eav.fields.EavSlugField(help_text='Short unique attribute label', verbose_name='slug')), + ('description', models.CharField(blank=True, help_text='Short description', max_length=256, null=True, verbose_name='description')), + ('type', models.CharField(blank=True, max_length=20, null=True, verbose_name='type')), + ('datatype', eav.fields.EavDatatypeField(choices=[(b'text', 'Text'), (b'float', 'Float'), (b'int', 'Integer'), (b'date', 'Date'), (b'bool', 'True / False'), (b'object', 'Django Object'), (b'enum', 'Multiple Choice')], max_length=6, verbose_name='data type')), + ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('required', models.BooleanField(default=False, verbose_name='required')), + ], + options={ + 'ordering': ['name'], + }, + managers=[ + ('objects', django.db.models.manager.Manager()), + ('on_site', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + migrations.CreateModel( + name='EnumGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='name')), + ], + ), + migrations.CreateModel( + name='EnumValue', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='value')), + ], + ), + migrations.CreateModel( + name='Value', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('entity_id', models.IntegerField()), + ('value_text', models.TextField(blank=True, null=True)), + ('value_float', models.FloatField(blank=True, null=True)), + ('value_int', models.IntegerField(blank=True, null=True)), + ('value_date', models.DateTimeField(blank=True, null=True)), + ('value_bool', models.NullBooleanField()), + ('generic_value_id', models.IntegerField(blank=True, null=True)), + ('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eav.Attribute', verbose_name='attribute')), + ('entity_ct', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='value_entities', to='contenttypes.ContentType')), + ('generic_value_ct', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='value_values', to='contenttypes.ContentType')), + ('value_enum', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='eav_values', to='eav.EnumValue')), + ], + ), + migrations.AddField( + model_name='enumgroup', + name='enums', + field=models.ManyToManyField(to='eav.EnumValue', verbose_name='enum group'), + ), + migrations.AddField( + model_name='attribute', + name='enum_group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='eav.EnumGroup', verbose_name='choice group'), + ), + migrations.AddField( + model_name='attribute', + name='site', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='sites.Site', verbose_name='site'), + ), + migrations.AlterUniqueTogether( + name='attribute', + unique_together=set([('site', 'slug')]), + ), + ] diff --git a/eav/migrations/0002_auto_20161014_0157.py b/eav/migrations/0002_auto_20161014_0157.py new file mode 100644 index 0000000..70c3bd9 --- /dev/null +++ b/eav/migrations/0002_auto_20161014_0157.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.10 on 2016-10-13 16:57 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('eav', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='attribute', + options={'ordering': ['content_type', 'name']}, + ), + migrations.AddField( + model_name='attribute', + name='content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType', verbose_name='content type'), + ), + migrations.AddField( + model_name='attribute', + name='display_order', + field=models.PositiveIntegerField(default=1, verbose_name='display order'), + ), + migrations.AlterUniqueTogether( + name='attribute', + unique_together=set([('site', 'content_type', 'slug')]), + ), + ] diff --git a/eav/migrations/0003_auto_20161104_0922.py b/eav/migrations/0003_auto_20161104_0922.py new file mode 100644 index 0000000..9da9f11 --- /dev/null +++ b/eav/migrations/0003_auto_20161104_0922.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2016-11-04 09:22 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import eav.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('eav', '0002_auto_20161014_0157'), + ] + + operations = [ + migrations.CreateModel( + name='Encounter', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('num', models.PositiveSmallIntegerField()), + ], + ), + migrations.CreateModel( + name='Patient', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=12)), + ], + ), + migrations.AlterField( + model_name='attribute', + name='datatype', + field=eav.fields.EavDatatypeField(choices=[('text', 'Text'), ('float', 'Float'), ('int', 'Integer'), ('date', 'Date'), ('bool', 'True / False'), ('object', 'Django Object'), ('enum', 'Multiple Choice')], max_length=6, verbose_name='data type'), + ), + migrations.AddField( + model_name='encounter', + name='patient', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eav.Patient'), + ), + migrations.AddField( + model_name='value', + name='entity_uuid', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AlterField( + model_name='value', + name='entity_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/eav/migrations/__init__.py b/eav/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eav/models.py b/eav/models.py index 1544567..97647eb 100644 --- a/eav/models.py +++ b/eav/models.py @@ -33,19 +33,20 @@ ------- ''' -from datetime import datetime +from django.utils import timezone from django.db import models from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _ from django.contrib.contenttypes.models import ContentType -from django.contrib.contenttypes import generic +from django.contrib.contenttypes import fields as generic from django.contrib.sites.models import Site from django.contrib.sites.managers import CurrentSiteManager from django.conf import settings from .validators import * from .fields import EavSlugField, EavDatatypeField +from .utils.utilities import Utils class EnumValue(models.Model): @@ -152,8 +153,8 @@ class Attribute(models.Model): ''' class Meta: - ordering = ['name'] - unique_together = ('site', 'slug') + ordering = ['content_type', 'name'] + unique_together = ('site', 'content_type', 'slug') TYPE_TEXT = 'text' TYPE_FLOAT = 'float' @@ -176,8 +177,12 @@ class Meta: name = models.CharField(_(u"name"), max_length=100, help_text=_(u"User-friendly attribute name")) + content_type = models.ForeignKey(ContentType, + blank=True, null=True, + verbose_name=_(u"content type")) + site = models.ForeignKey(Site, verbose_name=_(u"site"), - default=Site.objects.get_current) + default=settings.SITE_ID) slug = EavSlugField(_(u"slug"), max_length=50, db_index=True, help_text=_(u"Short unique attribute label")) @@ -198,13 +203,15 @@ def help_text(self): datatype = EavDatatypeField(_(u"data type"), max_length=6, choices=DATATYPE_CHOICES) - created = models.DateTimeField(_(u"created"), default=datetime.now, + created = models.DateTimeField(_(u"created"), default=timezone.now, editable=False) modified = models.DateTimeField(_(u"modified"), auto_now=True) required = models.BooleanField(_(u"required"), default=False) + display_order = models.PositiveIntegerField(_(u"display order"), default=1) + objects = models.Manager() on_site = CurrentSiteManager() @@ -293,16 +300,15 @@ def save_value(self, entity, value): Attribute and *entity*, it will delete that :class:`Value` object. ''' ct = ContentType.objects.get_for_model(entity) + entity_dict = dict(entity_ct=ct, + attribute=self) + entity_dict[Value.entity_id_type] = entity.pk try: - value_obj = self.value_set.get(entity_ct=ct, - entity_id=entity.pk, - attribute=self) + value_obj = self.value_set.get(**entity_dict) except Value.DoesNotExist: if value == None or value == '': return - value_obj = Value.objects.create(entity_ct=ct, - entity_id=entity.pk, - attribute=self) + value_obj = Value.objects.create(**entity_dict) if value == None or value == '': value_obj.delete() return @@ -312,7 +318,7 @@ def save_value(self, entity, value): value_obj.save() def __unicode__(self): - return u"%s (%s)" % (self.name, self.get_datatype_display()) + return u"%s.%s (%s)" % (self.content_type, self.name, self.get_datatype_display()) class Value(models.Model): @@ -335,10 +341,13 @@ class Value(models.Model): ''' + entity_id_type = Utils().get_eav_entity_id_type() + entity_ct = models.ForeignKey(ContentType, related_name='value_entities') - entity_id = models.IntegerField() + entity_id = models.IntegerField(blank=True, null=True) + entity_uuid = models.UUIDField(blank=True, null=True) entity = generic.GenericForeignKey(ct_field='entity_ct', - fk_field='entity_id') + fk_field=entity_id_type) value_text = models.TextField(blank=True, null=True) value_float = models.FloatField(blank=True, null=True) @@ -354,7 +363,7 @@ class Value(models.Model): value_object = generic.GenericForeignKey(ct_field='generic_value_ct', fk_field='generic_value_id') - created = models.DateTimeField(_(u"created"), default=datetime.now) + created = models.DateTimeField(_(u"created"), default=timezone.now) modified = models.DateTimeField(_(u"modified"), auto_now=True) attribute = models.ForeignKey(Attribute, db_index=True, @@ -442,18 +451,19 @@ def get_all_attributes(self): Return a query set of all :class:`Attribute` objects that can be set for this entity. ''' - return self.model._eav_config_cls.get_attributes() + return self.model._eav_config_cls.get_attributes().filter( + models.Q(content_type__isnull=True) | models.Q(content_type=self.ct)).order_by('display_order') def _hasattr(self, attribute_slug): ''' - Since we override __getattr__ with a backdown to the database, this exists as a way of + Since we override __getattr__ with a backdown to the database, this exists as a way of checking whether a user has set a real attribute on ourselves, without going to the db if not ''' return attribute_slug in self.__dict__ def _getattr(self, attribute_slug): ''' - Since we override __getattr__ with a backdown to the database, this exists as a way of + Since we override __getattr__ with a backdown to the database, this exists as a way of getting the value a user set for one of our attributes, without going to the db to check ''' return self.__dict__[attribute_slug] @@ -482,7 +492,7 @@ def validate_attributes(self): value = self._getattr(attribute.slug) else: value = values_dict.get(attribute.slug, None) - + if value is None: if attribute.required: raise ValidationError(_(u"%(attr)s EAV field cannot " \ @@ -495,7 +505,7 @@ def validate_attributes(self): raise ValidationError(_(u"%(attr)s EAV field %(err)s") % \ {'attr': attribute.slug, 'err': e}) - + def get_values_dict(self): values_dict = dict() for value in self.get_values(): @@ -507,8 +517,9 @@ def get_values(self): ''' Get all set :class:`Value` objects for self.model ''' - return Value.objects.filter(entity_ct=self.ct, - entity_id=self.model.pk).select_related() + entiity_filter = dict(entity_ct=self.ct) + entiity_filter[Value.entity_id_type] = self.model.pk + return Value.objects.filter(**entiity_filter).select_related() def get_all_attribute_slugs(self): ''' diff --git a/eav/registry.py b/eav/registry.py index a5265af..40429c7 100644 --- a/eav/registry.py +++ b/eav/registry.py @@ -29,7 +29,7 @@ from django.db.utils import DatabaseError from django.db.models.signals import pre_init, post_init, pre_save, post_save -from django.contrib.contenttypes import generic +from django.contrib.contenttypes import fields as generic from .managers import EntityManager from .models import Entity, Attribute, Value @@ -164,9 +164,9 @@ def _attach_generic_relation(self): gr_name = self.config_cls.generic_relation_attr.lower() generic_relation = \ generic.GenericRelation(Value, - object_id_field='entity_id', + object_id_field=Value.entity_id_type, content_type_field='entity_ct', - related_name=rel_name) + related_query_name=rel_name) generic_relation.contribute_to_class(self.model_cls, gr_name) def _detach_generic_relation(self): diff --git a/eav/tests/data_validation.py b/eav/tests/data_validation.py index 93755c8..8a1e032 100644 --- a/eav/tests/data_validation.py +++ b/eav/tests/data_validation.py @@ -1,4 +1,4 @@ -from datetime import datetime, date +from django.utils import timezone from django.test import TestCase from django.core.exceptions import ValidationError @@ -91,13 +91,13 @@ def test_date_validation(self): self.assertRaises(ValidationError, p.save) p.eav.dob = 15 self.assertRaises(ValidationError, p.save) - now = datetime.now() - now = datetime(year=now.year, month=now.month, day=now.day, - hour=now.hour, minute=now.minute, second=now.second) + now = timezone.now() + now = timezone.datetime(year=now.year, month=now.month, day=now.day, + hour=now.hour, minute=now.minute, second=now.second) p.eav.dob = now p.save() self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob, now) - today = date.today() + today = timezone.today() p.eav.dob = today p.save() self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob.date(), today) diff --git a/eav/utils/__init__.py b/eav/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eav/utils/utilities.py b/eav/utils/utilities.py new file mode 100644 index 0000000..1878524 --- /dev/null +++ b/eav/utils/utilities.py @@ -0,0 +1,16 @@ +from django.conf import settings + + +class Utils(object): + ENTITY_ID_TYPES = { + 'uuid': 'entity_uuid', + 'int': 'entity_id' + } + + def get_eav_entity_id_type(self): + key = getattr(settings, 'EAV_ENTITY_ID_TYPE', 'int') + try: + return self.ENTITY_ID_TYPES[key] + except KeyError: + print('%s not supported, kindly try uuid or int, defaulting to int' % key) + return self.ENTITY_ID_TYPES['int'] diff --git a/eav/validators.py b/eav/validators.py index f5d6c5a..9e03f36 100644 --- a/eav/validators.py +++ b/eav/validators.py @@ -34,8 +34,7 @@ --------- ''' -from datetime import datetime, date - +from django.utils import timezone from django.db import models from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import ValidationError @@ -45,7 +44,7 @@ def validate_text(value): ''' Raises ``ValidationError`` unless *value* type is ``str`` or ``unicode`` ''' - if not (type(value) == unicode or type(value) == str): + if not (type(value) == str): raise ValidationError(_(u"Must be str or unicode")) @@ -74,7 +73,7 @@ def validate_date(value): Raises ``ValidationError`` unless *value* is an instance of ``datetime`` or ``date`` ''' - if not (isinstance(value, datetime) or isinstance(value, date)): + if not (isinstance(value, timezone.datetime) or isinstance(value, timezone.datetime.date)): raise ValidationError(_(u"Must be a date or datetime")) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3d21135 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Django==1.10.3 +eav-django==1.4.7