diff --git a/docs/source/conf.py b/docs/source/conf.py index 2da1384..2b63d00 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -18,16 +18,16 @@ sys.path.insert(0, os.path.abspath('../../')) # Pass settings into configure. settings.configure( - INSTALLED_APPS = [ + INSTALLED_APPS=[ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'eav' + 'eav', ], - SECRET_KEY=os.environ.get("DJANGO_SECRET_KEY", "this-is-not-s3cur3") + SECRET_KEY=os.environ.get("DJANGO_SECRET_KEY", "this-is-not-s3cur3"), ) # Call django.setup to load installed apps and other stuff. @@ -56,10 +56,7 @@ extensions = [ ] html_theme_options = dict( - show_powered_by = False, - show_related = True, - fixed_sidebar = True, - font_family = 'Roboto' + show_powered_by=False, show_related=True, fixed_sidebar=True, font_family='Roboto' ) templates_path = ['_templates'] @@ -111,17 +108,8 @@ def setup(app): html_sidebars = { - 'index': [ - 'sidebarintro.html', - 'localtoc.html' - ], - '**': [ - 'sidebarintro.html', - 'localtoc.html', - - 'relations.html', - 'searchbox.html' - ] + 'index': ['sidebarintro.html', 'localtoc.html'], + '**': ['sidebarintro.html', 'localtoc.html', 'relations.html', 'searchbox.html'], } @@ -137,15 +125,12 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -155,8 +140,7 @@ latex_elements = { # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'DjangoEAV2.tex', 'Django EAV 2 Documentation', - '-', 'manual'), + (master_doc, 'DjangoEAV2.tex', 'Django EAV 2 Documentation', '-', 'manual'), ] @@ -164,10 +148,7 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'djangoeav2', 'Django EAV 2 Documentation', - [author], 1) -] +man_pages = [(master_doc, 'djangoeav2', 'Django EAV 2 Documentation', [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -176,9 +157,15 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'DjangoEAV2', 'Django EAV 2 Documentation', - author, 'DjangoEAV2', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + 'DjangoEAV2', + 'Django EAV 2 Documentation', + author, + 'DjangoEAV2', + 'One line description of project.', + 'Miscellaneous', + ), ] # -- Extension configuration ------------------------------------------------- diff --git a/eav/__init__.py b/eav/__init__.py index 11afdef..3988f12 100644 --- a/eav/__init__.py +++ b/eav/__init__.py @@ -1,7 +1,10 @@ def register(model_cls, config_cls=None): from .registry import Registry + Registry.register(model_cls, config_cls) + def unregister(model_cls): from .registry import Registry + Registry.unregister(model_cls) diff --git a/eav/admin.py b/eav/admin.py index 8bd8db2..48a6710 100644 --- a/eav/admin.py +++ b/eav/admin.py @@ -36,6 +36,7 @@ class BaseEntityInlineFormSet(BaseInlineFormSet): """ An inline formset that correctly initializes EAV forms. """ + def add_fields(self, form, index): if self.instance: setattr(form.instance, self.fk.name, self.instance) @@ -59,6 +60,7 @@ class BaseEntityInline(InlineModelAdmin): with EAV-Django. You can copy or symlink the ``admin`` directory to your templates search path (see Django documentation). """ + formset = BaseEntityInlineFormSet def get_fieldsets(self, request, obj=None): diff --git a/eav/decorators.py b/eav/decorators.py index 5c8503d..f600b0d 100644 --- a/eav/decorators.py +++ b/eav/decorators.py @@ -3,6 +3,7 @@ This module contains pure wrapper functions used as decorators. Functions in this module should be simple and not involve complex logic. """ + def register_eav(**kwargs): """ Registers the given model(s) classes and wrapped ``Model`` class with diff --git a/eav/exceptions.py b/eav/exceptions.py index 924a8c0..c02f027 100644 --- a/eav/exceptions.py +++ b/eav/exceptions.py @@ -1,2 +1,2 @@ class IllegalAssignmentException(Exception): - pass + pass diff --git a/eav/fields.py b/eav/fields.py index 3131ca3..c5d4b9d 100644 --- a/eav/fields.py +++ b/eav/fields.py @@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _ from .forms import CSVFormField + class EavSlugField(models.SlugField): """ The slug field used by :class:`~eav.models.Attribute` @@ -22,10 +23,12 @@ class EavSlugField(models.SlugField): slug_regex = r'[a-z][a-z0-9_]*' if not re.match(slug_regex, value): - raise ValidationError(_( - 'Must be all lower case, start with a letter, and contain ' - 'only letters, numbers, or underscores.' - )) + raise ValidationError( + _( + 'Must be all lower case, start with a letter, and contain ' + 'only letters, numbers, or underscores.' + ) + ) @staticmethod def create_slug_from_name(name): @@ -59,12 +62,14 @@ class EavDatatypeField(models.CharField): return if instance.value_set.count(): - raise ValidationError(_( - 'You cannot change the datatype of an attribute that is already in use.' - )) + raise ValidationError( + _( + 'You cannot change the datatype of an attribute that is already in use.' + ) + ) -class CSVField(models.TextField): # (models.Field): +class CSVField(models.TextField): # (models.Field): description = _("A Comma-Separated-Value field.") default_separator = ";" diff --git a/eav/forms.py b/eav/forms.py index 1996a9a..51030a6 100644 --- a/eav/forms.py +++ b/eav/forms.py @@ -4,8 +4,15 @@ from copy import deepcopy from django import forms from django.contrib.admin.widgets import AdminSplitDateTime -from django.forms import (BooleanField, CharField, ChoiceField, DateTimeField, - FloatField, IntegerField, ModelForm) +from django.forms import ( + BooleanField, + CharField, + ChoiceField, + DateTimeField, + FloatField, + IntegerField, + ModelForm, +) from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -64,15 +71,16 @@ class BaseDynamicEntityForm(ModelForm): csv CSVField ===== ============= """ + FIELD_CLASSES = { - 'text': CharField, + 'text': CharField, 'float': FloatField, - 'int': IntegerField, - 'date': DateTimeField, - 'bool': BooleanField, - 'enum': ChoiceField, - 'json': JSONField, - 'csv': CSVFormField, + 'int': IntegerField, + 'date': DateTimeField, + 'bool': BooleanField, + 'enum': ChoiceField, + 'json': JSONField, + 'csv': CSVFormField, } def __init__(self, data=None, *args, **kwargs): @@ -123,10 +131,12 @@ class BaseDynamicEntityForm(ModelForm): ``self.instance`` and related EAV attributes. Returns ``instance``. """ if self.errors: - raise ValueError(_( - 'The %s could not be saved because the data' - 'didn\'t validate.' % self.instance._meta.object_name - )) + raise ValueError( + _( + 'The %s could not be saved because the data' + 'didn\'t validate.' % self.instance._meta.object_name + ) + ) # Create entity instance, don't save yet. instance = super(BaseDynamicEntityForm, self).save(commit=False) diff --git a/eav/managers.py b/eav/managers.py index b30acf9..6599225 100644 --- a/eav/managers.py +++ b/eav/managers.py @@ -11,6 +11,7 @@ class EntityManager(models.Manager): """ Our custom manager, overrides ``models.Manager``. """ + _queryset_class = EavQuerySet def create(self, **kwargs): @@ -29,7 +30,7 @@ class EntityManager(models.Manager): for key, value in kwargs.items(): if key.startswith(prefix): - eav_kwargs.update({key[len(prefix):]: value}) + eav_kwargs.update({key[len(prefix) :]: value}) else: new_kwargs.update({key: value}) diff --git a/eav/migrations/0001_initial.py b/eav/migrations/0001_initial.py index 48ce458..325566c 100644 --- a/eav/migrations/0001_initial.py +++ b/eav/migrations/0001_initial.py @@ -19,15 +19,79 @@ class Migration(migrations.Migration): 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', unique=True, verbose_name='Slug')), - ('description', models.CharField(blank=True, help_text='Short description', max_length=256, null=True, verbose_name='Description')), - ('datatype', eav.fields.EavDatatypeField(choices=[('text', 'Text'), ('date', 'Date'), ('float', 'Float'), ('int', 'Integer'), ('bool', 'True / False'), ('object', 'Django Object'), ('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')), - ('display_order', models.PositiveIntegerField(default=1, verbose_name='Display order')), + ( + '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', + unique=True, + verbose_name='Slug', + ), + ), + ( + 'description', + models.CharField( + blank=True, + help_text='Short description', + max_length=256, + null=True, + verbose_name='Description', + ), + ), + ( + 'datatype', + eav.fields.EavDatatypeField( + choices=[ + ('text', 'Text'), + ('date', 'Date'), + ('float', 'Float'), + ('int', 'Integer'), + ('bool', 'True / False'), + ('object', 'Django Object'), + ('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'), + ), + ( + 'display_order', + models.PositiveIntegerField( + default=1, verbose_name='Display order' + ), + ), ], options={ 'ordering': ['name'], @@ -36,21 +100,53 @@ class Migration(migrations.Migration): 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')), + ( + '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')), + ( + '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')), + ( + '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)), @@ -58,12 +154,52 @@ class Migration(migrations.Migration): ('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.PROTECT, to='eav.Attribute', verbose_name='Attribute')), - ('entity_ct', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='value_entities', to='contenttypes.ContentType')), - ('generic_value_ct', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='value_values', to='contenttypes.ContentType')), - ('value_enum', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='eav_values', to='eav.EnumValue')), + ( + '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.PROTECT, + to='eav.Attribute', + verbose_name='Attribute', + ), + ), + ( + 'entity_ct', + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name='value_entities', + to='contenttypes.ContentType', + ), + ), + ( + 'generic_value_ct', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='value_values', + to='contenttypes.ContentType', + ), + ), + ( + 'value_enum', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='eav_values', + to='eav.EnumValue', + ), + ), ], ), migrations.AddField( @@ -74,6 +210,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='attribute', name='enum_group', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='eav.EnumGroup', verbose_name='Choice Group'), + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to='eav.EnumGroup', + verbose_name='Choice Group', + ), ), ] diff --git a/eav/migrations/0003_auto_20210404_2209.py b/eav/migrations/0003_auto_20210404_2209.py index 1797b8e..d5a3e0b 100644 --- a/eav/migrations/0003_auto_20210404_2209.py +++ b/eav/migrations/0003_auto_20210404_2209.py @@ -3,6 +3,7 @@ from django.db import migrations import eav.fields import django.core.serializers.json + try: from django.db.models import JSONField except ImportError: @@ -19,11 +20,29 @@ class Migration(migrations.Migration): migrations.AddField( model_name='value', name='value_json', - field=JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + field=JSONField( + blank=True, + default=dict, + encoder=django.core.serializers.json.DjangoJSONEncoder, + null=True, + ), ), migrations.AlterField( model_name='attribute', name='datatype', - field=eav.fields.EavDatatypeField(choices=[('text', 'Text'), ('date', 'Date'), ('float', 'Float'), ('int', 'Integer'), ('bool', 'True / False'), ('object', 'Django Object'), ('enum', 'Multiple Choice'), ('json', 'JSON Object')], max_length=6, verbose_name='Data Type'), + field=eav.fields.EavDatatypeField( + choices=[ + ('text', 'Text'), + ('date', 'Date'), + ('float', 'Float'), + ('int', 'Integer'), + ('bool', 'True / False'), + ('object', 'Django Object'), + ('enum', 'Multiple Choice'), + ('json', 'JSON Object'), + ], + max_length=6, + verbose_name='Data Type', + ), ), ] diff --git a/eav/migrations/0005_auto_20210510_1305.py b/eav/migrations/0005_auto_20210510_1305.py index 7793395..90b8944 100644 --- a/eav/migrations/0005_auto_20210510_1305.py +++ b/eav/migrations/0005_auto_20210510_1305.py @@ -19,6 +19,20 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='attribute', name='datatype', - field=eav.fields.EavDatatypeField(choices=[('text', 'Text'), ('date', 'Date'), ('float', 'Float'), ('int', 'Integer'), ('bool', 'True / False'), ('object', 'Django Object'), ('enum', 'Multiple Choice'), ('json', 'JSON Object'), ('csv', 'Comma-Separated-Value')], max_length=6, verbose_name='Data Type'), + field=eav.fields.EavDatatypeField( + choices=[ + ('text', 'Text'), + ('date', 'Date'), + ('float', 'Float'), + ('int', 'Integer'), + ('bool', 'True / False'), + ('object', 'Django Object'), + ('enum', 'Multiple Choice'), + ('json', 'JSON Object'), + ('csv', 'Comma-Separated-Value'), + ], + max_length=6, + verbose_name='Data Type', + ), ), ] diff --git a/eav/models.py b/eav/models.py index ac4c541..30a06b6 100644 --- a/eav/models.py +++ b/eav/models.py @@ -67,6 +67,7 @@ class EnumValue(models.Model): only have a total of four *EnumValues* objects, as you should have used the same *Yes* and *No* *EnumValues* for both *EnumGroups*. """ + value = models.CharField(_('Value'), db_index=True, unique=True, max_length=50) def __str__(self): @@ -81,8 +82,9 @@ class EnumGroup(models.Model): See :class:`EnumValue` for an example. """ - name = models.CharField(_('Name'), unique = True, max_length = 100) - values = models.ManyToManyField(EnumValue, verbose_name = _('Enum group')) + + name = models.CharField(_('Name'), unique=True, max_length=100) + values = models.ManyToManyField(EnumValue, verbose_name=_('Enum group')) def __str__(self): return ''.format(self.name) @@ -139,43 +141,42 @@ class Attribute(models.Model): .. warning:: Once an Attribute has been used by an entity, you can not change it's datatype. """ + class Meta: ordering = ['name'] - TYPE_TEXT = 'text' - TYPE_FLOAT = 'float' - TYPE_INT = 'int' - TYPE_DATE = 'date' + TYPE_TEXT = 'text' + TYPE_FLOAT = 'float' + TYPE_INT = 'int' + TYPE_DATE = 'date' TYPE_BOOLEAN = 'bool' - TYPE_OBJECT = 'object' - TYPE_ENUM = 'enum' - TYPE_JSON = 'json' - TYPE_CSV = 'csv' + TYPE_OBJECT = 'object' + TYPE_ENUM = 'enum' + TYPE_JSON = 'json' + TYPE_CSV = 'csv' DATATYPE_CHOICES = ( - (TYPE_TEXT, _('Text')), - (TYPE_DATE, _('Date')), - (TYPE_FLOAT, _('Float')), - (TYPE_INT, _('Integer')), + (TYPE_TEXT, _('Text')), + (TYPE_DATE, _('Date')), + (TYPE_FLOAT, _('Float')), + (TYPE_INT, _('Integer')), (TYPE_BOOLEAN, _('True / False')), - (TYPE_OBJECT, _('Django Object')), - (TYPE_ENUM, _('Multiple Choice')), - (TYPE_JSON, _('JSON Object')), - (TYPE_CSV, _('Comma-Separated-Value')), + (TYPE_OBJECT, _('Django Object')), + (TYPE_ENUM, _('Multiple Choice')), + (TYPE_JSON, _('JSON Object')), + (TYPE_CSV, _('Comma-Separated-Value')), ) # Core attributes datatype = EavDatatypeField( - verbose_name = _('Data Type'), - choices = DATATYPE_CHOICES, - max_length = 6 + verbose_name=_('Data Type'), choices=DATATYPE_CHOICES, max_length=6 ) name = models.CharField( - verbose_name = _('Name'), - max_length = 100, - help_text = _('User-friendly attribute name') + verbose_name=_('Name'), + max_length=100, + help_text=_('User-friendly attribute name'), ) """ @@ -184,11 +185,11 @@ class Attribute(models.Model): (see :meth:`~eav.fields.EavSlugField.create_slug_from_name`). """ slug = EavSlugField( - verbose_name = _('Slug'), - max_length = 50, - db_index = True, - unique = True, - help_text = _('Short unique attribute label') + verbose_name=_('Slug'), + max_length=50, + db_index=True, + unique=True, + help_text=_('Short unique attribute label'), ) """ @@ -197,7 +198,7 @@ class Attribute(models.Model): means that *all* entities that *can* have this attribute will be required to have a value for it. """ - required = models.BooleanField(verbose_name = _('Required'), default = False) + required = models.BooleanField(verbose_name=_('Required'), default=False) entity_ct = models.ManyToManyField(ContentType, blank=True) """ @@ -209,36 +210,30 @@ class Attribute(models.Model): enum_group = models.ForeignKey( EnumGroup, - verbose_name = _('Choice Group'), - on_delete = models.PROTECT, - blank = True, - null = True + verbose_name=_('Choice Group'), + on_delete=models.PROTECT, + blank=True, + null=True, ) description = models.CharField( - verbose_name = _('Description'), - max_length = 256, - blank = True, - null = True, - help_text = _('Short description') + verbose_name=_('Description'), + max_length=256, + blank=True, + null=True, + help_text=_('Short description'), ) # Useful meta-information display_order = models.PositiveIntegerField( - verbose_name = _('Display order'), - default = 1 + verbose_name=_('Display order'), default=1 ) - modified = models.DateTimeField( - verbose_name = _('Modified'), - auto_now = True - ) + modified = models.DateTimeField(verbose_name=_('Modified'), auto_now=True) created = models.DateTimeField( - verbose_name = _('Created'), - default = timezone.now, - editable = False + verbose_name=_('Created'), default=timezone.now, editable=False ) @property @@ -256,15 +251,15 @@ class Attribute(models.Model): validators to return as well as the default, built-in one. """ DATATYPE_VALIDATORS = { - 'text': validate_text, - 'float': validate_float, - 'int': validate_int, - 'date': validate_date, - 'bool': validate_bool, + 'text': validate_text, + 'float': validate_float, + 'int': validate_int, + 'date': validate_date, + 'bool': validate_bool, 'object': validate_object, - 'enum': validate_enum, - 'json': validate_json, - 'csv': validate_csv, + 'enum': validate_enum, + 'json': validate_json, + 'csv': validate_csv, } return [DATATYPE_VALIDATORS[self.datatype]] @@ -283,7 +278,7 @@ class Attribute(models.Model): if not self.enum_group.values.filter(value=value).exists(): raise ValidationError( _('%(val)s is not a valid choice for %(attr)s') - % dict(val = value, attr = self) + % dict(val=value, attr=self) ) def save(self, *args, **kwargs): @@ -318,7 +313,11 @@ class Attribute(models.Model): Returns a query set of :class:`EnumValue` objects for this attribute. Returns None if the datatype of this attribute is not *TYPE_ENUM*. """ - return self.enum_group.values.all() if self.datatype == Attribute.TYPE_ENUM else None + return ( + self.enum_group.values.all() + if self.datatype == Attribute.TYPE_ENUM + else None + ) def save_value(self, entity, value): """ @@ -337,18 +336,14 @@ class Attribute(models.Model): try: value_obj = self.value_set.get( - entity_ct = ct, - entity_id = entity.pk, - attribute = self + entity_ct=ct, entity_id=entity.pk, attribute=self ) except Value.DoesNotExist: if value == None or value == '': return value_obj = Value.objects.create( - entity_ct = ct, - entity_id = entity.pk, - attribute = self + entity_ct=ct, entity_id=entity.pk, attribute=self ) if value == None or value == '': @@ -386,53 +381,49 @@ class Value(models.Model): """ entity_ct = models.ForeignKey( - ContentType, - on_delete = models.PROTECT, - related_name = 'value_entities' + ContentType, on_delete=models.PROTECT, related_name='value_entities' ) entity_id = models.IntegerField() - entity = generic.GenericForeignKey(ct_field = 'entity_ct', fk_field = 'entity_id') + entity = generic.GenericForeignKey(ct_field='entity_ct', fk_field='entity_id') - 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.BooleanField(blank = True, null = True) - value_json = JSONField(default=dict, encoder=DjangoJSONEncoder, blank = True, null = True) - value_csv = CSVField(blank = True, null = True) + 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.BooleanField(blank=True, null=True) + value_json = JSONField( + default=dict, encoder=DjangoJSONEncoder, blank=True, null=True + ) + value_csv = CSVField(blank=True, null=True) - value_enum = models.ForeignKey( + value_enum = models.ForeignKey( EnumValue, - blank = True, - null = True, - on_delete = models.PROTECT, - related_name = 'eav_values' + blank=True, + null=True, + on_delete=models.PROTECT, + related_name='eav_values', ) generic_value_id = models.IntegerField(blank=True, null=True) generic_value_ct = models.ForeignKey( ContentType, - blank = True, - null = True, - on_delete = models.PROTECT, - related_name ='value_values' + blank=True, + null=True, + on_delete=models.PROTECT, + related_name='value_values', ) value_object = generic.GenericForeignKey( - ct_field = 'generic_value_ct', - fk_field = 'generic_value_id' + ct_field='generic_value_ct', fk_field='generic_value_id' ) - created = models.DateTimeField(_('Created'), default = timezone.now) - modified = models.DateTimeField(_('Modified'), auto_now = True) + created = models.DateTimeField(_('Created'), default=timezone.now) + modified = models.DateTimeField(_('Modified'), auto_now=True) attribute = models.ForeignKey( - Attribute, - db_index = True, - on_delete = models.PROTECT, - verbose_name = _('Attribute') + Attribute, db_index=True, on_delete=models.PROTECT, verbose_name=_('Attribute') ) def save(self, *args, **kwargs): @@ -468,6 +459,7 @@ class Entity(object): The helper class that will be attached to any entity registered with eav. """ + @staticmethod def pre_save_handler(sender, *args, **kwargs): """ @@ -515,7 +507,7 @@ class Entity(object): except Attribute.DoesNotExist: raise AttributeError( _('%(obj)s has no EAV attribute named %(attr)s') - % dict(obj = self.instance, attr = name) + % dict(obj=self.instance, attr=name) ) try: @@ -557,7 +549,9 @@ class Entity(object): for attribute in self.get_all_attributes(): if self._hasattr(attribute.slug): attribute_value = self._getattr(attribute.slug) - if attribute.datatype == Attribute.TYPE_ENUM and not isinstance(attribute_value, EnumValue): + if attribute.datatype == Attribute.TYPE_ENUM and not isinstance( + attribute_value, EnumValue + ): if attribute_value is not None: attribute_value = EnumValue.objects.get(value=attribute_value) attribute.save_value(self.instance, attribute_value) @@ -592,16 +586,18 @@ class Entity(object): except ValidationError as e: raise ValidationError( _('%(attr)s EAV field %(err)s') - % dict(attr = attribute.slug, err = e) + % dict(attr=attribute.slug, err=e) ) illegal = values_dict or ( - self.get_object_attributes() - self.get_all_attribute_slugs()) + self.get_object_attributes() - self.get_all_attribute_slugs() + ) if illegal: raise IllegalAssignmentException( - 'Instance of the class {} cannot have values for attributes: {}.' - .format(self.instance.__class__, ', '.join(illegal)) + 'Instance of the class {} cannot have values for attributes: {}.'.format( + self.instance.__class__, ', '.join(illegal) + ) ) def get_values_dict(self): @@ -612,8 +608,7 @@ class Entity(object): Get all set :class:`Value` objects for self.instance """ return Value.objects.filter( - entity_ct = self.ct, - entity_id = self.instance.pk + entity_ct=self.ct, entity_id=self.instance.pk ).select_related() def get_all_attribute_slugs(self): diff --git a/eav/queryset.py b/eav/queryset.py index 243d75b..788c531 100644 --- a/eav/queryset.py +++ b/eav/queryset.py @@ -44,56 +44,56 @@ def is_eav_and_leaf(expr, gr_name): bool """ return ( - getattr(expr, 'connector', None) == 'AND' and - len(expr.children) == 1 and - expr.children[0][0] in ['pk__in', '{}__in'.format(gr_name)] + getattr(expr, 'connector', None) == 'AND' + and len(expr.children) == 1 + and expr.children[0][0] in ['pk__in', '{}__in'.format(gr_name)] ) def rewrite_q_expr(model_cls, expr): """ - Rewrites Q-expression to safe form, in order to ensure that - generated SQL is valid. + Rewrites Q-expression to safe form, in order to ensure that + generated SQL is valid. -IGNORE: - Suppose we have the following Q-expression: + IGNORE: + Suppose we have the following Q-expression: - └── OR - ├── AND - │ └── eav_values__in [1, 2, 3] - └── AND (1) - ├── AND - │ └── eav_values__in [4, 5] - └── AND - └── eav_values__in [6, 7, 8] -IGNORE + └── OR + ├── AND + │ └── eav_values__in [1, 2, 3] + └── AND (1) + ├── AND + │ └── eav_values__in [4, 5] + └── AND + └── eav_values__in [6, 7, 8] + IGNORE - All EAV values are stored in a single table. Therefore, INNER JOIN - generated for the AND-expression (1) will always fail, i.e. - single row in a eav_values table cannot be both in two disjoint sets at - the same time (and the whole point of using AND, usually, is two have - two different sets). Therefore, we must paritially rewrite the - expression so that the generated SQL is valid:: + All EAV values are stored in a single table. Therefore, INNER JOIN + generated for the AND-expression (1) will always fail, i.e. + single row in a eav_values table cannot be both in two disjoint sets at + the same time (and the whole point of using AND, usually, is two have + two different sets). Therefore, we must paritially rewrite the + expression so that the generated SQL is valid:: -IGNORE: - └── OR - ├── AND - │ └── eav_values__in [1, 2, 3] - └── AND - └── pk__in [1, 2] -IGNORE + IGNORE: + └── OR + ├── AND + │ └── eav_values__in [1, 2, 3] + └── AND + └── pk__in [1, 2] + IGNORE - This is done by merging dangerous AND's and substituting them with - explicit ``pk__in`` filter, where pks are taken from evaluted - Q-expr branch. + This is done by merging dangerous AND's and substituting them with + explicit ``pk__in`` filter, where pks are taken from evaluted + Q-expr branch. - Args: - model_cls (TypeVar): model class used to construct :meth:`QuerySet` - from leaf attribute-value expression. - expr: (Q | tuple): Q-expression (or attr-val leaf) to be rewritten. + Args: + model_cls (TypeVar): model class used to construct :meth:`QuerySet` + from leaf attribute-value expression. + expr: (Q | tuple): Q-expression (or attr-val leaf) to be rewritten. - Returns: - Union[Q, tuple] + Returns: + Union[Q, tuple] """ # Node in a Q-expr can be a Q or an attribute-value tuple (leaf). # We are only interested in Qs. @@ -162,6 +162,7 @@ def eav_filter(func): :func:`expand_q_filters` and kwargs through :func:`expand_eav_filter`. Returns the called function (filter or exclude). """ + @wraps(func) def wrapper(self, *args, **kwargs): nargs = [] @@ -244,10 +245,7 @@ def expand_eav_filter(model_cls, key, value): else: lookup = '__{}'.format(fields[2]) if len(fields) > 2 else '' value_key = 'value_{}{}'.format(datatype, lookup) - kwargs = { - value_key: value, - 'attribute__slug': slug - } + kwargs = {value_key: value, 'attribute__slug': slug} value = Value.objects.filter(**kwargs) return '%s__in' % gr_name, value @@ -317,20 +315,25 @@ class EavQuerySet(QuerySet): field_name = 'value_%s' % attr.datatype - pks_values = Value.objects.filter( - # Retrieve pk-values pairs of the related values - # (i.e. values for the specified attribute and - # belonging to entities in the queryset). - attribute__slug=attr.slug, - entity_id__in=self - ).order_by( - # Order values by their value-field of - # appriopriate attribute data-type. - field_name - ).values_list( - # Retrieve only primary-keys of the entities - # in the current queryset. - 'entity_id', field_name + pks_values = ( + Value.objects.filter( + # Retrieve pk-values pairs of the related values + # (i.e. values for the specified attribute and + # belonging to entities in the queryset). + attribute__slug=attr.slug, + entity_id__in=self, + ) + .order_by( + # Order values by their value-field of + # appriopriate attribute data-type. + field_name + ) + .values_list( + # Retrieve only primary-keys of the entities + # in the current queryset. + 'entity_id', + field_name, + ) ) # Retrive ordered values from pk-value list. @@ -353,29 +356,20 @@ class EavQuerySet(QuerySet): # WHEN id = 4 THEN 3 # END # - when_clauses = [ - When(id=pk, then=i) - for pk, i in entities_pk - ] + when_clauses = [When(id=pk, then=i) for pk, i in entities_pk] - order_clause = Case( - *when_clauses, - output_field=IntegerField() - ) + order_clause = Case(*when_clauses, output_field=IntegerField()) clause_name = '__'.join(term) # Use when-clause to construct # custom order-by clause. - query_clause = query_clause.annotate( - **{clause_name: order_clause} - ) + query_clause = query_clause.annotate(**{clause_name: order_clause}) order_clauses.append(clause_name) elif len(term) >= 2 and term[0] == config_cls.eav_attr: raise NotSupportedError( - 'EAV does not support ordering through ' - 'foreign-key chains' + 'EAV does not support ordering through ' 'foreign-key chains' ) else: diff --git a/eav/registry.py b/eav/registry.py index 91f3cfd..7c194e9 100644 --- a/eav/registry.py +++ b/eav/registry.py @@ -26,6 +26,7 @@ class EavConfig(object): GenericRelation from Entity to Value. None by default. Therefore, if not overridden, it is not possible to query Values by Entities. """ + manager_attr = 'objects' manager_only = False eav_attr = 'eav' @@ -60,8 +61,7 @@ class Registry(object): return if config_cls is EavConfig or config_cls is None: - config_cls = type("%sConfig" % model_cls.__name__, - (EavConfig,), {}) + config_cls = type("%sConfig" % model_cls.__name__, (EavConfig,), {}) # set _eav_config_cls on the model so we can access it there setattr(model_cls, '_eav_config_cls', config_cls) @@ -125,9 +125,9 @@ class Registry(object): delattr(self.model_cls, self.config_cls.manager_attr) if hasattr(self.config_cls, 'old_mgr'): - self.config_cls.old_mgr \ - .contribute_to_class(self.model_cls, - self.config_cls.manager_attr) + self.config_cls.old_mgr.contribute_to_class( + self.model_cls, self.config_cls.manager_attr + ) def _attach_signals(self): """ @@ -136,31 +136,33 @@ class Registry(object): able to prepare and clean-up before and after creation / update of the user's model class instance. """ - post_init.connect(Registry.attach_eav_attr, sender = self.model_cls) - pre_save.connect(Entity.pre_save_handler, sender = self.model_cls) - post_save.connect(Entity.post_save_handler, sender = self.model_cls) + post_init.connect(Registry.attach_eav_attr, sender=self.model_cls) + pre_save.connect(Entity.pre_save_handler, sender=self.model_cls) + post_save.connect(Entity.post_save_handler, sender=self.model_cls) def _detach_signals(self): """ Detach all signals for eav. """ - post_init.disconnect(Registry.attach_eav_attr, sender = self.model_cls) - pre_save.disconnect(Entity.pre_save_handler, sender = self.model_cls) - post_save.disconnect(Entity.post_save_handler, sender = self.model_cls) + post_init.disconnect(Registry.attach_eav_attr, sender=self.model_cls) + pre_save.disconnect(Entity.pre_save_handler, sender=self.model_cls) + post_save.disconnect(Entity.post_save_handler, sender=self.model_cls) def _attach_generic_relation(self): """ Set up the generic relation for the entity """ - rel_name = self.config_cls.generic_relation_related_name or \ - self.model_cls.__name__ + rel_name = ( + self.config_cls.generic_relation_related_name or self.model_cls.__name__ + ) gr_name = self.config_cls.generic_relation_attr.lower() - generic_relation = \ - generic.GenericRelation(Value, - object_id_field='entity_id', - content_type_field='entity_ct', - related_query_name=rel_name) + generic_relation = generic.GenericRelation( + Value, + object_id_field='entity_id', + content_type_field='entity_ct', + related_query_name=rel_name, + ) generic_relation.contribute_to_class(self.model_cls, gr_name) def _detach_generic_relation(self): diff --git a/eav/validators.py b/eav/validators.py index 7e2ab64..cc0dd9b 100644 --- a/eav/validators.py +++ b/eav/validators.py @@ -51,7 +51,9 @@ def validate_date(value): Raises ``ValidationError`` unless *value* is an instance of ``datetime`` or ``date`` """ - if not isinstance(value, datetime.datetime) and not isinstance(value, datetime.date): + if not isinstance(value, datetime.datetime) and not isinstance( + value, datetime.date + ): raise ValidationError(_(u"Must be a date or datetime")) diff --git a/eav/widgets.py b/eav/widgets.py index 3583851..d1acc39 100644 --- a/eav/widgets.py +++ b/eav/widgets.py @@ -2,13 +2,14 @@ from django.forms.widgets import Textarea from django.core.exceptions import ValidationError from django.core import validators -EMPTY_VALUES = validators.EMPTY_VALUES + ('[]', ) +EMPTY_VALUES = validators.EMPTY_VALUES + ('[]',) + class CSVWidget(Textarea): is_hidden = False def prep_value(self, value): - """ Prepare value before effectively render widget """ + """Prepare value before effectively render widget""" if value in EMPTY_VALUES: return "" elif isinstance(value, str): @@ -20,4 +21,3 @@ class CSVWidget(Textarea): def render(self, name, value, **kwargs): value = self.prep_value(value) return super().render(name, value, **kwargs) -