From b5b576aca5d5852a7ce903e2e8514fe5b2224c96 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 1 Sep 2024 08:21:47 -0700 Subject: [PATCH] refactor: apply ruff linter rules and standardize code style Replace flake8 with ruff and apply consistent linting rules across the entire codebase. Update type annotations, quotation marks, and other style-related changes to comply with the new standards. --- docs/source/conf.py | 103 ++++----- eav/admin.py | 46 ++-- eav/decorators.py | 2 +- eav/exceptions.py | 2 +- eav/fields.py | 18 +- eav/forms.py | 73 ++++--- eav/logic/entity_pk.py | 6 +- eav/logic/object_pk.py | 3 +- eav/managers.py | 6 +- eav/migrations/0001_initial.py | 155 +++++++------- eav/models/attribute.py | 169 +++++++-------- eav/models/entity.py | 51 ++--- eav/models/enum_group.py | 26 +-- eav/models/enum_value.py | 20 +- eav/models/value.py | 107 +++++----- eav/queryset.py | 88 ++++---- eav/registry.py | 32 +-- eav/validators.py | 31 +-- eav/widgets.py | 20 +- manage.py | 14 +- test_project/models.py | 27 ++- test_project/settings.py | 71 +++---- tests/test_attributes.py | 45 ++-- tests/test_data_validation.py | 12 +- tests/test_forms.py | 118 +++++------ tests/test_logic.py | 4 +- tests/test_misc_models.py | 10 +- tests/test_natural_keys.py | 7 +- tests/test_primary_key_format.py | 5 +- tests/test_queries.py | 346 +++++++++++++++++++------------ tests/test_registry.py | 64 +++--- 31 files changed, 894 insertions(+), 787 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 811ce9d..d7e8db5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -2,30 +2,35 @@ # # More information on the configuration options is available at: # https://www.sphinx-doc.org/en/master/usage/configuration.html +from __future__ import annotations import os import sys -from typing import Dict +from pathlib import Path import django from django.conf import settings from sphinx.ext.autodoc import between -sys.path.insert(0, os.path.abspath('.')) -sys.path.insert(0, os.path.abspath('../../')) +# For discovery of Python modules +sys.path.insert(0, str(Path().cwd())) + +# For finding the django_settings.py file +sys.path.insert(0, str(Path("../../").resolve())) + # Pass settings into configure. settings.configure( INSTALLED_APPS=[ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'eav', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "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"), EAV2_PRIMARY_KEY_FIELD="django.db.models.BigAutoField", ) @@ -34,22 +39,22 @@ django.setup() # -- Project information ----------------------------------------------------- -project = 'Django EAV 2' -copyright = '2018, Iwo Herka and team at MAKIMO' -author = '-' +project = "Django EAV 2" +copyright = "2018, Iwo Herka and team at MAKIMO" # noqa: A001 +author = "-" # The short X.Y version -version = '' +version = "" # The full version, including alpha/beta/rc tags -release = '0.10.0' +release = "0.10.0" def setup(app): """Use the configuration file itself as an extension.""" app.connect( - 'autodoc-process-docstring', + "autodoc-process-docstring", between( - '^.*IGNORE.*$', + "^.*IGNORE.*$", exclude=True, ), ) @@ -59,57 +64,57 @@ def setup(app): # -- General configuration --------------------------------------------------- extensions = [ - 'sphinx.ext.napoleon', - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.coverage', - 'sphinx.ext.viewcode', - 'sphinx_rtd_theme', + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.coverage", + "sphinx.ext.viewcode", + "sphinx_rtd_theme", ] -templates_path = ['_templates'] +templates_path = ["_templates"] -source_suffix = '.rst' +source_suffix = ".rst" -master_doc = 'index' +master_doc = "index" -language = 'en' +language = "en" -exclude_patterns = ['build'] +exclude_patterns = ["build"] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # -- Options for HTML output ------------------------------------------------- -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" -html_static_path = ['_static'] +html_static_path = ["_static"] 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", ], } -htmlhelp_basename = 'DjangoEAV2doc' +htmlhelp_basename = "DjangoEAV2doc" # -- Options for LaTeX output ------------------------------------------------ -latex_elements: Dict[str, str] = {} +latex_elements: dict[str, str] = {} # Grouping the document tree into LaTeX files. List of tuples # (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"), ] @@ -120,8 +125,8 @@ latex_documents = [ man_pages = [ ( master_doc, - 'djangoeav2', - 'Django EAV 2 Documentation', + "djangoeav2", + "Django EAV 2 Documentation", [author], 1, ), @@ -136,12 +141,12 @@ man_pages = [ texinfo_documents = [ ( master_doc, - 'DjangoEAV2', - 'Django EAV 2 Documentation', + "DjangoEAV2", + "Django EAV 2 Documentation", author, - 'DjangoEAV2', - 'One line description of project.', - 'Miscellaneous', + "DjangoEAV2", + "One line description of project.", + "Miscellaneous", ), ] @@ -150,7 +155,7 @@ texinfo_documents = [ # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} +intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} # -- Autodoc configuration --------------------------------------------------- diff --git a/eav/admin.py b/eav/admin.py index 51e9789..cf6294d 100644 --- a/eav/admin.py +++ b/eav/admin.py @@ -1,6 +1,8 @@ """This module contains classes used for admin integration.""" -from typing import Any, Dict, List, Optional, Union +from __future__ import annotations + +from typing import Any, ClassVar, Dict, List, Sequence, Union from django.contrib import admin from django.contrib.admin.options import InlineModelAdmin, ModelAdmin @@ -11,6 +13,8 @@ from eav.models import Attribute, EnumGroup, EnumValue, Value _FIELDSET_TYPE = List[Union[str, Dict[str, Any]]] # type: ignore[misc] +some_attribute = ClassVar[dict[str, str]] + class BaseEntityAdmin(ModelAdmin): """Custom admin model to support dynamic EAV fieldsets. @@ -26,7 +30,7 @@ class BaseEntityAdmin(ModelAdmin): """ eav_fieldset_title: str = "EAV Attributes" - eav_fieldset_description: Optional[str] = None + eav_fieldset_description: str | None = None def render_change_form(self, request, context, *args, **kwargs): """Dynamically modifies the admin form to include EAV fields. @@ -45,7 +49,7 @@ class BaseEntityAdmin(ModelAdmin): Returns: HttpResponse object representing the rendered change form. """ - form = context['adminform'].form + form = context["adminform"].form # Identify EAV fields based on the form instance's configuration. eav_fields = self._get_eav_fields(form.instance) @@ -55,7 +59,7 @@ class BaseEntityAdmin(ModelAdmin): return super().render_change_form(request, context, *args, **kwargs) # Get the non-EAV fieldsets and then append our own - fieldsets = list(self.get_fieldsets(request, kwargs['obj'])) + fieldsets = list(self.get_fieldsets(request, kwargs["obj"])) fieldsets.append(self._get_eav_fieldset(eav_fields)) # Reconstruct the admin form with updated fieldsets. @@ -65,18 +69,18 @@ class BaseEntityAdmin(ModelAdmin): # Clear prepopulated fields on a view-only form to avoid a crash. ( self.prepopulated_fields - if self.has_change_permission(request, kwargs['obj']) + if self.has_change_permission(request, kwargs["obj"]) else {} ), readonly_fields=self.readonly_fields, model_admin=self, ) - media = mark_safe(context['media'] + adminform.media) + media = mark_safe(context["media"] + adminform.media) # noqa: S308 context.update(adminform=adminform, media=media) return super().render_change_form(request, context, *args, **kwargs) - def _get_eav_fields(self, instance) -> List[str]: + def _get_eav_fields(self, instance) -> list[str]: """Retrieves a list of EAV field slugs for the given instance. Args: @@ -85,24 +89,24 @@ class BaseEntityAdmin(ModelAdmin): Returns: A list of strings representing the slugs of EAV fields. """ - entity = getattr(instance, instance._eav_config_cls.eav_attr) - return list(entity.get_all_attributes().values_list('slug', flat=True)) + entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001 + return list(entity.get_all_attributes().values_list("slug", flat=True)) def _get_eav_fieldset(self, eav_fields) -> _FIELDSET_TYPE: """Constructs an EAV Attributes fieldset for inclusion in admin form fieldsets. - Generates a list representing a fieldset specifically for Entity-Attribute-Value (EAV) fields, - intended to be appended to the admin form's fieldsets configuration. This facilitates the - dynamic inclusion of EAV fields within the Django admin interface by creating a designated - section for these attributes. + Generates a list representing a fieldset specifically for Entity-Attribute-Value + (EAV) fields, intended to be appended to the admin form's fieldsets + configuration. This facilitates the dynamic inclusion of EAV fields within the + Django admin interface by creating a designated section for these attributes. Args: - eav_fields (List[str]): A list of slugs representing the EAV fields to be included - in the EAV Attributes fieldset. + eav_fields (List[str]): A list of slugs representing the EAV fields to be + included in the EAV Attributes fieldset. """ return [ self.eav_fieldset_title, - {'fields': eav_fields, 'description': self.eav_fieldset_description}, + {"fields": eav_fields, "description": self.eav_fieldset_description}, ] @@ -114,9 +118,9 @@ class BaseEntityInlineFormSet(BaseInlineFormSet): def add_fields(self, form, index): if self.instance: setattr(form.instance, self.fk.name, self.instance) - form._build_dynamic_fields() + form._build_dynamic_fields() # noqa: SLF001 - super(BaseEntityInlineFormSet, self).add_fields(form, index) + super().add_fields(form, index) class BaseEntityInline(InlineModelAdmin): @@ -147,12 +151,12 @@ class BaseEntityInline(InlineModelAdmin): instance = self.model(**kw) form = formset.form(request.POST, instance=instance) - return [(None, {'fields': form.fields.keys()})] + return [(None, {"fields": form.fields.keys()})] class AttributeAdmin(ModelAdmin): - list_display = ('name', 'slug', 'datatype', 'description') - prepopulated_fields = {'slug': ('name',)} + list_display = ("name", "slug", "datatype", "description") + prepopulated_fields: ClassVar[dict[str, Sequence[str]]] = {"slug": ("name",)} admin.site.register(Attribute, AttributeAdmin) diff --git a/eav/decorators.py b/eav/decorators.py index c67abdd..580af6c 100644 --- a/eav/decorators.py +++ b/eav/decorators.py @@ -19,7 +19,7 @@ def register_eav(**kwargs): def _model_eav_wrapper(model_class): if not issubclass(model_class, Model): - raise ValueError('Wrapped class must subclass Model.') + raise TypeError("Wrapped class must subclass Model.") register(model_class, **kwargs) return model_class diff --git a/eav/exceptions.py b/eav/exceptions.py index c02f027..f071031 100644 --- a/eav/exceptions.py +++ b/eav/exceptions.py @@ -1,2 +1,2 @@ -class IllegalAssignmentException(Exception): +class IllegalAssignmentException(Exception): # noqa: N818 pass diff --git a/eav/fields.py b/eav/fields.py index c9f2910..82b107c 100644 --- a/eav/fields.py +++ b/eav/fields.py @@ -16,7 +16,7 @@ class EavDatatypeField(models.CharField): :class:`~eav.models.Attribute` that is already used by :class:`~eav.models.Value` objects. """ - super(EavDatatypeField, self).validate(value, instance) + super().validate(value, instance) if not instance.pk: return @@ -31,8 +31,9 @@ class EavDatatypeField(models.CharField): if instance.value_set.count(): raise ValidationError( _( - 'You cannot change the datatype of an attribute that is already in use.' - ) + "You cannot change the datatype of an " + + "attribute that is already in use.", + ), ) @@ -42,21 +43,21 @@ class CSVField(models.TextField): # (models.Field): def __init__(self, separator=";", *args, **kwargs): self.separator = separator - kwargs.setdefault('default', "") + kwargs.setdefault("default", "") super().__init__(*args, **kwargs) def deconstruct(self): name, path, args, kwargs = super().deconstruct() if self.separator != self.default_separator: - kwargs['separator'] = self.separator + kwargs["separator"] = self.separator return name, path, args, kwargs def formfield(self, **kwargs): - defaults = {'form_class': CSVFormField} + defaults = {"form_class": CSVFormField} defaults.update(kwargs) return super().formfield(**defaults) - def from_db_value(self, value, expression, connection, context=None): + def from_db_value(self, value, expression, connection): if value is None: return [] return value.split(self.separator) @@ -73,8 +74,9 @@ class CSVField(models.TextField): # (models.Field): return "" if isinstance(value, str): return value - elif isinstance(value, list): + if isinstance(value, list): return self.separator.join(value) + return value def value_to_string(self, obj): value = self.value_from_object(obj) diff --git a/eav/forms.py b/eav/forms.py index d85d886..5ff60a5 100644 --- a/eav/forms.py +++ b/eav/forms.py @@ -1,6 +1,9 @@ """This module contains forms used for admin integration.""" +from __future__ import annotations + from copy import deepcopy +from typing import ClassVar from django.contrib.admin.widgets import AdminSplitDateTime from django.core.exceptions import ValidationError @@ -21,14 +24,14 @@ from eav.widgets import CSVWidget class CSVFormField(Field): - message = _('Enter comma-separated-values. eg: one;two;three.') - code = 'invalid' + message = _("Enter comma-separated-values. eg: one;two;three.") + code = "invalid" widget = CSVWidget default_separator = ";" def __init__(self, *args, **kwargs): - kwargs.pop('max_length', None) - self.separator = kwargs.pop('separator', self.default_separator) + kwargs.pop("max_length", None) + self.separator = kwargs.pop("separator", self.default_separator) super().__init__(*args, **kwargs) def to_python(self, value): @@ -38,9 +41,8 @@ class CSVFormField(Field): def validate(self, field_value): super().validate(field_value) - try: - isinstance(field_value, list) - except ValidationError: + + if not isinstance(field_value, list): raise ValidationError(self.message, code=self.code) @@ -70,20 +72,20 @@ class BaseDynamicEntityForm(ModelForm): ===== ============= """ - FIELD_CLASSES = { - 'text': CharField, - 'float': FloatField, - 'int': IntegerField, - 'date': SplitDateTimeField, - 'bool': BooleanField, - 'enum': ChoiceField, - 'json': JSONField, - 'csv': CSVFormField, + FIELD_CLASSES: ClassVar[dict[str, Field]] = { + "text": CharField, + "float": FloatField, + "int": IntegerField, + "date": SplitDateTimeField, + "bool": BooleanField, + "enum": ChoiceField, + "json": JSONField, + "csv": CSVFormField, } def __init__(self, data=None, *args, **kwargs): - super(BaseDynamicEntityForm, self).__init__(data, *args, **kwargs) - config_cls = self.instance._eav_config_cls + super().__init__(data, *args, **kwargs) + config_cls = self.instance._eav_config_cls # noqa: SLF001 self.entity = getattr(self.instance, config_cls.eav_attr) self._build_dynamic_fields() @@ -95,35 +97,35 @@ class BaseDynamicEntityForm(ModelForm): value = getattr(self.entity, attribute.slug) defaults = { - 'label': attribute.name.capitalize(), - 'required': attribute.required, - 'help_text': attribute.help_text, - 'validators': attribute.get_validators(), + "label": attribute.name.capitalize(), + "required": attribute.required, + "help_text": attribute.help_text, + "validators": attribute.get_validators(), } datatype = attribute.datatype if datatype == attribute.TYPE_ENUM: - values = attribute.get_choices().values_list('id', 'value') - choices = [('', '-----')] + list(values) - defaults.update({'choices': choices}) + values = attribute.get_choices().values_list("id", "value") + choices = ["", "-----", *list(values)] + defaults.update({"choices": choices}) if value: - defaults.update({'initial': value.pk}) + defaults.update({"initial": value.pk}) elif datatype == attribute.TYPE_DATE: - defaults.update({'widget': AdminSplitDateTime}) + defaults.update({"widget": AdminSplitDateTime}) elif datatype == attribute.TYPE_OBJECT: continue - MappedField = self.FIELD_CLASSES[datatype] + MappedField = self.FIELD_CLASSES[datatype] # noqa: N806 self.fields[attribute.slug] = MappedField(**defaults) # Fill initial data (if attribute was already defined). - if value and not datatype == attribute.TYPE_ENUM: + if value and datatype != attribute.TYPE_ENUM: self.initial[attribute.slug] = value - def save(self, commit=True): + def save(self, *, commit=True): """ Saves this ``form``'s cleaned_data into model instance ``self.instance`` and related EAV attributes. Returns ``instance``. @@ -131,23 +133,20 @@ class BaseDynamicEntityForm(ModelForm): if self.errors: raise ValueError( _( - 'The %s could not be saved because the data' - 'didn\'t validate.' % self.instance._meta.object_name + "The %s could not be saved because the data didn't validate.", ) + % self.instance._meta.object_name, # noqa: SLF001 ) # Create entity instance, don't save yet. - instance = super(BaseDynamicEntityForm, self).save(commit=False) + instance = super().save(commit=False) # Assign attributes. for attribute in self.entity.get_all_attributes(): value = self.cleaned_data.get(attribute.slug) if attribute.datatype == attribute.TYPE_ENUM: - if value: - value = attribute.enum_group.values.get(pk=value) - else: - value = None + value = attribute.enum_group.values.get(pk=value) if value else None setattr(self.entity, attribute.slug, value) diff --git a/eav/logic/entity_pk.py b/eav/logic/entity_pk.py index bdb0a02..97cc16c 100644 --- a/eav/logic/entity_pk.py +++ b/eav/logic/entity_pk.py @@ -7,6 +7,6 @@ def get_entity_pk_type(entity_cls) -> str: These values map to `models.Value` as potential fields to use to relate to the proper entity via the correct PK type. """ - if isinstance(entity_cls._meta.pk, UUIDField): - return 'entity_uuid' - return 'entity_id' + if isinstance(entity_cls._meta.pk, UUIDField): # noqa: SLF001 + return "entity_uuid" + return "entity_id" diff --git a/eav/logic/object_pk.py b/eav/logic/object_pk.py index 3deee32..8e0282c 100644 --- a/eav/logic/object_pk.py +++ b/eav/logic/object_pk.py @@ -1,6 +1,5 @@ import uuid from functools import partial -from typing import Type from django.conf import settings from django.db import models @@ -24,7 +23,7 @@ _FIELD_MAPPING = { } -def get_pk_format() -> Type[models.Field]: +def get_pk_format() -> models.Field: """ Get the primary key field format based on the Django settings. diff --git a/eav/managers.py b/eav/managers.py index d4f5883..4141c09 100644 --- a/eav/managers.py +++ b/eav/managers.py @@ -19,12 +19,12 @@ class EntityManager(models.Manager): Parse eav attributes out of *kwargs*, then try to create and save the object, then assign and save it's eav attributes. """ - config_cls = getattr(self.model, '_eav_config_cls', None) + config_cls = getattr(self.model, "_eav_config_cls", None) if not config_cls or config_cls.manager_only: - return super(EntityManager, self).create(**kwargs) + return super().create(**kwargs) - prefix = '%s__' % config_cls.eav_attr + prefix = f"{config_cls.eav_attr}__" new_kwargs = {} eav_kwargs = {} diff --git a/eav/migrations/0001_initial.py b/eav/migrations/0001_initial.py index d89bc46..648ffd9 100644 --- a/eav/migrations/0001_initial.py +++ b/eav/migrations/0001_initial.py @@ -8,215 +8,220 @@ import eav.fields class Migration(migrations.Migration): - """Initial migration that creates the Attribute, EnumGroup, EnumValue, and Value models.""" + """Initial migration for the Attribute, EnumGroup, EnumValue, and Value models.""" initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), + ("contenttypes", "0002_remove_content_type_name"), ] operations = [ migrations.CreateModel( - name='Attribute', + name="Attribute", fields=[ ( - 'id', + "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, - verbose_name='ID', + verbose_name="ID", ), ), ( - 'name', + "name", models.CharField( - help_text='User-friendly attribute name', + help_text="User-friendly attribute name", max_length=100, - verbose_name='Name', + verbose_name="Name", ), ), ( - 'slug', + "slug", models.SlugField( - help_text='Short unique attribute label', + help_text="Short unique attribute label", unique=True, - verbose_name='Slug', + verbose_name="Slug", ), ), ( - 'description', + "description", models.CharField( blank=True, - help_text='Short description', + help_text="Short description", max_length=256, null=True, - verbose_name='Description', + verbose_name="Description", ), ), ( - 'datatype', + "datatype", eav.fields.EavDatatypeField( choices=[ - ('text', 'Text'), - ('date', 'Date'), - ('float', 'Float'), - ('int', 'Integer'), - ('bool', 'True / False'), - ('object', 'Django Object'), - ('enum', 'Multiple Choice'), + ("text", "Text"), + ("date", "Date"), + ("float", "Float"), + ("int", "Integer"), + ("bool", "True / False"), + ("object", "Django Object"), + ("enum", "Multiple Choice"), ], max_length=6, - verbose_name='Data Type', + verbose_name="Data Type", ), ), ( - 'created', + "created", models.DateTimeField( default=django.utils.timezone.now, editable=False, - verbose_name='Created', + verbose_name="Created", ), ), ( - 'modified', - models.DateTimeField(auto_now=True, verbose_name='Modified'), + "modified", + models.DateTimeField(auto_now=True, verbose_name="Modified"), ), ( - 'required', - models.BooleanField(default=False, verbose_name='Required'), + "required", + models.BooleanField(default=False, verbose_name="Required"), ), ( - 'display_order', + "display_order", models.PositiveIntegerField( - default=1, verbose_name='Display order' + default=1, + verbose_name="Display order", ), ), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='EnumGroup', + name="EnumGroup", fields=[ ( - 'id', + "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, - verbose_name='ID', + verbose_name="ID", ), ), ( - 'name', - models.CharField(max_length=100, unique=True, verbose_name='Name'), + "name", + models.CharField(max_length=100, unique=True, verbose_name="Name"), ), ], ), migrations.CreateModel( - name='EnumValue', + name="EnumValue", fields=[ ( - 'id', + "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, - verbose_name='ID', + verbose_name="ID", ), ), ( - 'value', + "value", models.CharField( - db_index=True, max_length=50, unique=True, verbose_name='Value' + db_index=True, + max_length=50, + unique=True, + verbose_name="Value", ), ), ], ), migrations.CreateModel( - name='Value', + name="Value", fields=[ ( - 'id', + "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, - verbose_name='ID', + 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)), + ("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', + "created", models.DateTimeField( - default=django.utils.timezone.now, verbose_name='Created' + default=django.utils.timezone.now, + verbose_name="Created", ), ), ( - 'modified', - models.DateTimeField(auto_now=True, verbose_name='Modified'), + "modified", + models.DateTimeField(auto_now=True, verbose_name="Modified"), ), ( - 'attribute', + "attribute", models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, - to='eav.Attribute', - verbose_name='Attribute', + to="eav.Attribute", + verbose_name="Attribute", ), ), ( - 'entity_ct', + "entity_ct", models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, - related_name='value_entities', - to='contenttypes.ContentType', + related_name="value_entities", + to="contenttypes.ContentType", ), ), ( - 'generic_value_ct', + "generic_value_ct", models.ForeignKey( blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, - related_name='value_values', - to='contenttypes.ContentType', + related_name="value_values", + to="contenttypes.ContentType", ), ), ( - 'value_enum', + "value_enum", models.ForeignKey( blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, - related_name='eav_values', - to='eav.EnumValue', + related_name="eav_values", + to="eav.EnumValue", ), ), ], ), migrations.AddField( - model_name='enumgroup', - name='values', - field=models.ManyToManyField(to='eav.EnumValue', verbose_name='Enum group'), + model_name="enumgroup", + name="values", + field=models.ManyToManyField(to="eav.EnumValue", verbose_name="Enum group"), ), migrations.AddField( - model_name='attribute', - name='enum_group', + 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', + to="eav.EnumGroup", + verbose_name="Choice Group", ), ), ] diff --git a/eav/models/attribute.py b/eav/models/attribute.py index 1906b5c..f44311f 100644 --- a/eav/models/attribute.py +++ b/eav/models/attribute.py @@ -1,6 +1,8 @@ # ruff: noqa: UP007 -from typing import TYPE_CHECKING, Optional, Tuple # noqa: UP035 +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError @@ -79,40 +81,36 @@ class Attribute(models.Model): ynu = EnumGroup.objects.create(name='Yes / No / Unknown') ynu.values.add(yes, no, unknown) - Attribute.objects.create(name='has fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu) + Attribute.objects.create(name='has fever?', + datatype=Attribute.TYPE_ENUM, + enum_group=ynu + ) # = .. warning:: Once an Attribute has been used by an entity, you can not change it's datatype. """ - objects = AttributeManager() - - class Meta: - ordering = ['name'] - verbose_name = _('Attribute') - verbose_name_plural = _('Attributes') - - 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_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" DATATYPE_CHOICES = ( - (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_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")), ) # Core attributes @@ -121,13 +119,13 @@ class Attribute(models.Model): datatype = EavDatatypeField( choices=DATATYPE_CHOICES, max_length=6, - verbose_name=_('Data Type'), + verbose_name=_("Data Type"), ) name = models.CharField( max_length=CHARFIELD_LENGTH, - help_text=_('User-friendly attribute name'), - verbose_name=_('Name'), + help_text=_("User-friendly attribute name"), + verbose_name=_("Name"), ) """ @@ -139,8 +137,8 @@ class Attribute(models.Model): max_length=SLUGFIELD_MAX_LENGTH, db_index=True, unique=True, - help_text=_('Short unique attribute label'), - verbose_name=_('Slug'), + help_text=_("Short unique attribute label"), + verbose_name=_("Slug"), ) """ @@ -151,13 +149,13 @@ class Attribute(models.Model): """ required = models.BooleanField( default=False, - verbose_name=_('Required'), + verbose_name=_("Required"), ) entity_ct = models.ManyToManyField( ContentType, blank=True, - verbose_name=_('Entity content type'), + verbose_name=_("Entity content type"), ) """ This field allows you to specify a relationship with any number of content types. @@ -166,49 +164,67 @@ class Attribute(models.Model): :meth:`~eav.registry.EavConfig.get_attributes` method of that entity's config. """ - enum_group: "ForeignKey[Optional[EnumGroup]]" = ForeignKey( + enum_group: ForeignKey[Optional[EnumGroup]] = ForeignKey( "eav.EnumGroup", on_delete=models.PROTECT, blank=True, null=True, - verbose_name=_('Choice Group'), + verbose_name=_("Choice Group"), ) description = models.CharField( max_length=256, blank=True, - null=True, - help_text=_('Short description'), - verbose_name=_('Description'), + default="", + help_text=_("Short description"), + verbose_name=_("Description"), ) # Useful meta-information display_order = models.PositiveIntegerField( default=1, - verbose_name=_('Display order'), + verbose_name=_("Display order"), ) modified = models.DateTimeField( auto_now=True, - verbose_name=_('Modified'), + verbose_name=_("Modified"), ) created = models.DateTimeField( default=timezone.now, editable=False, - verbose_name=_('Created'), + verbose_name=_("Created"), ) - def __str__(self) -> str: - return f'{self.name} ({self.get_datatype_display()})' + objects = AttributeManager() - def natural_key(self) -> Tuple[str, str]: # noqa: UP006 + class Meta: + ordering = ("name",) + verbose_name = _("Attribute") + verbose_name_plural = _("Attributes") + + def __str__(self) -> str: + return f"{self.name} ({self.get_datatype_display()})" + + def save(self, *args, **kwargs): + """ + Saves the Attribute and auto-generates a slug field + if one wasn't provided. + """ + if not self.slug: + self.slug = generate_slug(self.name) + + self.full_clean() + super().save(*args, **kwargs) + + def natural_key(self) -> tuple[str, str]: """ Retrieve the natural key for the Attribute instance. - The natural key for an Attribute is defined by its `name` and `slug`. This method - returns a tuple containing these two attributes of the instance. + The natural key for an Attribute is defined by its `name` and `slug`. This + method returns a tuple containing these two attributes of the instance. Returns ------- @@ -233,19 +249,19 @@ class Attribute(models.Model): method to look elsewhere for additional attribute specific 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, - 'object': validate_object, - 'enum': validate_enum, - 'json': validate_json, - 'csv': validate_csv, + datatype_validators = { + "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, } - return [DATATYPE_VALIDATORS[self.datatype]] + return [datatype_validators[self.datatype]] def validate_value(self, value): """ @@ -260,21 +276,10 @@ class Attribute(models.Model): value = value.value if not self.enum_group.values.filter(value=value).exists(): raise ValidationError( - _('%(val)s is not a valid choice for %(attr)s') - % {'val': value, 'attr': self}, + _("%(val)s is not a valid choice for %(attr)s") + % {"val": value, "attr": self}, ) - def save(self, *args, **kwargs): - """ - Saves the Attribute and auto-generates a slug field - if one wasn't provided. - """ - if not self.slug: - self.slug = generate_slug(self.name) - - self.full_clean() - super().save(*args, **kwargs) - def clean(self): """ Validates the attribute. Will raise ``ValidationError`` if the @@ -283,12 +288,12 @@ class Attribute(models.Model): """ if self.datatype == self.TYPE_ENUM and not self.enum_group: raise ValidationError( - _('You must set the choice group for multiple choice attributes'), + _("You must set the choice group for multiple choice attributes"), ) if self.datatype != self.TYPE_ENUM and self.enum_group: raise ValidationError( - _('You can only assign a choice group to multiple choice attributes'), + _("You can only assign a choice group to multiple choice attributes"), ) def clean_fields(self, exclude=None): @@ -308,11 +313,11 @@ class Attribute(models.Model): if not self.slug.isidentifier(): raise ValidationError( { - 'slug': _( + "slug": _( "Slug must be a valid Python identifier (no spaces, " - "special characters, or leading digits)." - ) - } + + "special characters, or leading digits).", + ), + }, ) def get_choices(self): @@ -342,20 +347,20 @@ class Attribute(models.Model): ct = ContentType.objects.get_for_model(entity) entity_filter = { - 'entity_ct': ct, - 'attribute': self, - f'{get_entity_pk_type(entity)}': entity.pk, + "entity_ct": ct, + "attribute": self, + f"{get_entity_pk_type(entity)}": entity.pk, } try: value_obj = self.value_set.get(**entity_filter) except Value.DoesNotExist: - if value is None or value == '': + if value is None or value == "": return value_obj = Value.objects.create(**entity_filter) - if value is None or value == '': + if value is None or value == "": value_obj.delete() return diff --git a/eav/models/entity.py b/eav/models/entity.py index 7ee05d1..c57ac4a 100644 --- a/eav/models/entity.py +++ b/eav/models/entity.py @@ -24,8 +24,8 @@ class Entity: model instance we are attached to is saved. This allows us to call :meth:`validate_attributes` before the entity is saved. """ - instance = kwargs['instance'] - entity = getattr(kwargs['instance'], instance._eav_config_cls.eav_attr) + instance = kwargs["instance"] + entity = getattr(kwargs["instance"], instance._eav_config_cls.eav_attr) # noqa: SLF001 entity.validate_attributes() @staticmethod @@ -34,8 +34,8 @@ class Entity: Post save handler attached to self.instance. Calls :meth:`save` when the model instance we are attached to is saved. """ - instance = kwargs['instance'] - entity = getattr(instance, instance._eav_config_cls.eav_attr) + instance = kwargs["instance"] + entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001 entity.save() def __init__(self, instance) -> None: @@ -58,14 +58,14 @@ class Entity: class:`Value` object, otherwise it hasn't been set, so it returns None. """ - if not name.startswith('_'): + if not name.startswith("_"): try: attribute = self.get_attribute_by_slug(name) - except Attribute.DoesNotExist: + except Attribute.DoesNotExist as err: raise AttributeError( - _('%(obj)s has no EAV attribute named %(attr)s') - % {'obj': self.instance, 'attr': name}, - ) + _("%(obj)s has no EAV attribute named %(attr)s") + % {"obj": self.instance, "attr": name}, + ) from err try: return self.get_value_by_attribute(attribute).value @@ -79,9 +79,9 @@ class Entity: Return a query set of all :class:`Attribute` objects that can be set for this entity. """ - return self.instance._eav_config_cls.get_attributes( + return self.instance._eav_config_cls.get_attributes( # noqa: SLF001 instance=self.instance, - ).order_by('display_order') + ).order_by("display_order") def _hasattr(self, attribute_slug): """ @@ -137,28 +137,29 @@ class Entity: if value is None: if attribute.required: raise ValidationError( - _(f'{attribute.slug} EAV field cannot be blank'), + _("%s EAV field cannot be blank") % attribute.slug, ) else: try: attribute.validate_value(value) - except ValidationError as e: + except ValidationError as err: raise ValidationError( - _('%(attr)s EAV field %(err)s') - % {'attr': attribute.slug, 'err': e}, - ) + _("%(attr)s EAV field %(err)s") + % {"attr": attribute.slug, "err": err}, + ) from err illegal = values_dict or ( 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), - ), + message = ( + "Instance of the class {} cannot have values for attributes: {}." + ).format( + self.instance.__class__, + ", ".join(illegal), ) + raise IllegalAssignmentException(message) def get_values_dict(self): return {v.attribute.slug: v.value for v in self.get_values()} @@ -166,15 +167,15 @@ class Entity: def get_values(self): """Get all set :class:`Value` objects for self.instance.""" entity_filter = { - 'entity_ct': self.ct, - f'{get_entity_pk_type(self.instance)}': self.instance.pk, + "entity_ct": self.ct, + f"{get_entity_pk_type(self.instance)}": self.instance.pk, } return Value.objects.filter(**entity_filter).select_related() def get_all_attribute_slugs(self): """Returns a list of slugs for all attributes available to this entity.""" - return set(self.get_all_attributes().values_list('slug', flat=True)) + return set(self.get_all_attributes().values_list("slug", flat=True)) def get_attribute_by_slug(self, slug): """Returns a single :class:`Attribute` with *slug*.""" @@ -189,7 +190,7 @@ class Entity: Returns entity instance attributes, except for ``instance`` and ``ct`` which are used internally. """ - return set(copy(self.__dict__).keys()) - {'instance', 'ct'} + return set(copy(self.__dict__).keys()) - {"instance", "ct"} def __iter__(self): """ diff --git a/eav/models/enum_group.py b/eav/models/enum_group.py index 9b57705..7865cf8 100644 --- a/eav/models/enum_group.py +++ b/eav/models/enum_group.py @@ -1,4 +1,6 @@ -from typing import TYPE_CHECKING, Any, Tuple +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from django.db import models from django.db.models import ManyToManyField @@ -21,33 +23,33 @@ class EnumGroup(models.Model): See :class:`EnumValue` for an example. """ - objects = EnumGroupManager() - - class Meta: - verbose_name = _('EnumGroup') - verbose_name_plural = _('EnumGroups') - id = get_pk_format() name = models.CharField( unique=True, max_length=CHARFIELD_LENGTH, - verbose_name=_('Name'), + verbose_name=_("Name"), ) - values: "ManyToManyField[EnumValue, Any]" = ManyToManyField( + values: ManyToManyField[EnumValue, Any] = ManyToManyField( "eav.EnumValue", - verbose_name=_('Enum group'), + verbose_name=_("Enum group"), ) + objects = EnumGroupManager() + + class Meta: + verbose_name = _("EnumGroup") + verbose_name_plural = _("EnumGroups") + def __str__(self) -> str: """String representation of `EnumGroup` instance.""" return str(self.name) def __repr__(self) -> str: """String representation of `EnumGroup` object.""" - return f'' + return f"" - def natural_key(self) -> Tuple[str]: + def natural_key(self) -> tuple[str]: """ Retrieve the natural key for the EnumGroup instance. diff --git a/eav/models/enum_value.py b/eav/models/enum_value.py index 29b4ccc..b54fea5 100644 --- a/eav/models/enum_value.py +++ b/eav/models/enum_value.py @@ -1,4 +1,4 @@ -from typing import Tuple +from __future__ import annotations from django.db import models from django.utils.translation import gettext_lazy as _ @@ -35,21 +35,21 @@ class EnumValue(models.Model): the same *Yes* and *No* *EnumValues* for both *EnumGroups*. """ - objects = EnumValueManager() - - class Meta: - verbose_name = _('EnumValue') - verbose_name_plural = _('EnumValues') - id = get_pk_format() value = models.CharField( - _('Value'), + _("Value"), db_index=True, unique=True, max_length=SLUGFIELD_MAX_LENGTH, ) + objects = EnumValueManager() + + class Meta: + verbose_name = _("EnumValue") + verbose_name_plural = _("EnumValues") + def __str__(self) -> str: """String representation of `EnumValue` instance.""" return str( @@ -58,9 +58,9 @@ class EnumValue(models.Model): def __repr__(self) -> str: """String representation of `EnumValue` object.""" - return f'' + return f"" - def natural_key(self) -> Tuple[str]: + def natural_key(self) -> tuple[str]: """ Retrieve the natural key for the EnumValue instance. diff --git a/eav/models/value.py b/eav/models/value.py index c46f39f..d392ee1 100644 --- a/eav/models/value.py +++ b/eav/models/value.py @@ -1,6 +1,7 @@ # ruff: noqa: UP007 +from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Tuple +from typing import TYPE_CHECKING, Optional from django.contrib.contenttypes import fields as generic from django.contrib.contenttypes.models import ContentType @@ -43,20 +44,14 @@ class Value(models.Model): # = """ - objects = ValueManager() - - class Meta: - verbose_name = _('Value') - verbose_name_plural = _('Values') - id = get_pk_format() # Direct foreign keys - attribute: "ForeignKey[Attribute]" = ForeignKey( + attribute: ForeignKey[Attribute] = ForeignKey( "eav.Attribute", db_index=True, on_delete=models.PROTECT, - verbose_name=_('Attribute'), + verbose_name=_("Attribute"), ) # Entity generic relationships. Rather than rely on database casting, @@ -65,73 +60,73 @@ class Value(models.Model): entity_id = models.IntegerField( blank=True, null=True, - verbose_name=_('Entity id'), + verbose_name=_("Entity id"), ) entity_uuid = models.UUIDField( blank=True, null=True, - verbose_name=_('Entity uuid'), + verbose_name=_("Entity uuid"), ) entity_ct = ForeignKey( ContentType, on_delete=models.PROTECT, - related_name='value_entities', - verbose_name=_('Entity ct'), + related_name="value_entities", + verbose_name=_("Entity ct"), ) entity_pk_int = generic.GenericForeignKey( - ct_field='entity_ct', - fk_field='entity_id', + ct_field="entity_ct", + fk_field="entity_id", ) entity_pk_uuid = generic.GenericForeignKey( - ct_field='entity_ct', - fk_field='entity_uuid', + ct_field="entity_ct", + fk_field="entity_uuid", ) # Model attributes created = models.DateTimeField( default=timezone.now, - verbose_name=_('Created'), + verbose_name=_("Created"), ) modified = models.DateTimeField( auto_now=True, - verbose_name=_('Modified'), + verbose_name=_("Modified"), ) # Value attributes value_bool = models.BooleanField( blank=True, null=True, - verbose_name=_('Value bool'), + verbose_name=_("Value bool"), ) value_csv = CSVField( blank=True, null=True, - verbose_name=_('Value CSV'), + verbose_name=_("Value CSV"), ) value_date = models.DateTimeField( blank=True, null=True, - verbose_name=_('Value date'), + verbose_name=_("Value date"), ) value_float = models.FloatField( blank=True, null=True, - verbose_name=_('Value float'), + verbose_name=_("Value float"), ) value_int = models.BigIntegerField( blank=True, null=True, - verbose_name=_('Value int'), + verbose_name=_("Value int"), ) value_text = models.TextField( blank=True, - null=True, - verbose_name=_('Value text'), + default="", + verbose_name=_("Value text"), ) value_json = models.JSONField( @@ -139,23 +134,23 @@ class Value(models.Model): encoder=DjangoJSONEncoder, blank=True, null=True, - verbose_name=_('Value JSON'), + verbose_name=_("Value JSON"), ) - value_enum: "ForeignKey[Optional[EnumValue]]" = ForeignKey( + value_enum: ForeignKey[Optional[EnumValue]] = ForeignKey( "eav.EnumValue", blank=True, null=True, on_delete=models.PROTECT, - related_name='eav_values', - verbose_name=_('Value enum'), + related_name="eav_values", + verbose_name=_("Value enum"), ) # Value object relationship generic_value_id = models.IntegerField( blank=True, null=True, - verbose_name=_('Generic value id'), + verbose_name=_("Generic value id"), ) generic_value_ct = ForeignKey( @@ -163,16 +158,37 @@ class Value(models.Model): blank=True, null=True, on_delete=models.PROTECT, - related_name='value_values', - verbose_name=_('Generic value content type'), + related_name="value_values", + verbose_name=_("Generic value content type"), ) value_object = generic.GenericForeignKey( - ct_field='generic_value_ct', - fk_field='generic_value_id', + ct_field="generic_value_ct", + fk_field="generic_value_id", ) - def natural_key(self) -> Tuple[Tuple[str, str], int, str]: + objects = ValueManager() + + class Meta: + verbose_name = _("Value") + verbose_name_plural = _("Values") + + def __str__(self) -> str: + """String representation of a Value.""" + entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int + return f'{self.attribute.name}: "{self.value}" ({entity})' + + def save(self, *args, **kwargs): + """Validate and save this value.""" + self.full_clean() + super().save(*args, **kwargs) + + def __repr__(self) -> str: + """Representation of Value object.""" + entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int + return f'{self.attribute.name}: "{self.value}" ({entity})' + + def natural_key(self) -> tuple[tuple[str, str], int, str]: """ Retrieve the natural key for the Value instance. @@ -187,27 +203,12 @@ class Value(models.Model): """ return (self.attribute.natural_key(), self.entity_id, self.entity_uuid) - def __str__(self) -> str: - """String representation of a Value.""" - entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int - return f'{self.attribute.name}: "{self.value}" ({entity})' - - def __repr__(self) -> str: - """Representation of Value object.""" - entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int - return f'{self.attribute.name}: "{self.value}" ({entity})' - - def save(self, *args, **kwargs): - """Validate and save this value.""" - self.full_clean() - super().save(*args, **kwargs) - def _get_value(self): """Return the python object this value is holding.""" - return getattr(self, f'value_{self.attribute.datatype}') + return getattr(self, f"value_{self.attribute.datatype}") def _set_value(self, new_value): """Set the object this value is holding.""" - setattr(self, f'value_{self.attribute.datatype}', new_value) + setattr(self, f"value_{self.attribute.datatype}", new_value) value = property(_get_value, _set_value) diff --git a/eav/queryset.py b/eav/queryset.py index da5587d..08c1b0f 100644 --- a/eav/queryset.py +++ b/eav/queryset.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This module contains custom :class:`EavQuerySet` class used for overriding relational operators and pure functions for rewriting Q-expressions. @@ -19,14 +18,14 @@ Q-expressions need to be rewritten for two reasons: 2. To ensure that Q-expression tree is compiled to valid SQL. For details see: :func:`rewrite_q_expr`. """ + from functools import wraps from itertools import count -from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Case, IntegerField, Q, When from django.db.models.query import QuerySet from django.db.utils import NotSupportedError -from django.db.models import Subquery from eav.models import Attribute, EnumValue, Value @@ -43,9 +42,9 @@ def is_eav_and_leaf(expr, gr_name): bool """ return ( - getattr(expr, 'connector', None) == 'AND' + getattr(expr, "connector", None) == "AND" and len(expr.children) == 1 - and expr.children[0][0] in ['pk__in', '{}__in'.format(gr_name)] + and expr.children[0][0] in ["pk__in", f"{gr_name}__in"] ) @@ -98,7 +97,7 @@ def rewrite_q_expr(model_cls, expr): # We are only interested in Qs. if isinstance(expr, Q): - config_cls = getattr(model_cls, '_eav_config_cls', None) + config_cls = getattr(model_cls, "_eav_config_cls", None) gr_name = config_cls.generic_relation_attr # Recursively check child nodes. @@ -112,18 +111,18 @@ def rewrite_q_expr(model_cls, expr): if len(rewritable) > 1: q = None # Save nodes which shouldn't be merged (non-EAV). - other = [c for c in expr.children if not c in rewritable] + other = [c for c in expr.children if c not in rewritable] for child in rewritable: if not (child.children and len(child.children) == 1): - raise AssertionError('Child must have exactly one descendant') + raise AssertionError("Child must have exactly one descendant") # Child to be merged is always a terminal Q node, # i.e. it's an AND expression with attribute-value tuple child. attrval = child.children[0] if not isinstance(attrval, tuple): - raise AssertionError('Attribute-value must be a tuple') + raise TypeError("Attribute-value must be a tuple") - fname = '{}__in'.format(gr_name) + fname = f"{gr_name}__in" # Child can be either a 'eav_values__in' or 'pk__in' query. # If it's the former then transform it into the latter. @@ -131,7 +130,7 @@ def rewrite_q_expr(model_cls, expr): # If so, reverse it back to QuerySet so that set operators # can be applied. - if attrval[0] == fname or hasattr(attrval[1], '__contains__'): + if attrval[0] == fname or hasattr(attrval[1], "__contains__"): # Create model queryset. _q = model_cls.objects.filter(**{fname: attrval[1]}) else: @@ -140,17 +139,17 @@ def rewrite_q_expr(model_cls, expr): # Explicitly check for None. 'or' doesn't work here # as empty QuerySet, which is valid, is falsy. - q = q if q != None else _q + q = q if q is not None else _q - if expr.connector == 'AND': + if expr.connector == "AND": q &= _q else: q |= _q # If any two children were merged, # update parent expression. - if q != None: - expr.children = other + [('pk__in', q)] + if q is not None: + expr.children = [*other, ("pk__in", q)] return expr @@ -170,9 +169,9 @@ def eav_filter(func): for arg in args: if isinstance(arg, Q): # Modify Q objects (warning: recursion ahead). - arg = expand_q_filters(arg, self.model) + arg = expand_q_filters(arg, self.model) # noqa: PLW2901 # Rewrite Q-expression to safeform. - arg = rewrite_q_expr(self.model, arg) + arg = rewrite_q_expr(self.model, arg) # noqa: PLW2901 nargs.append(arg) for key, value in kwargs.items(): @@ -180,9 +179,10 @@ def eav_filter(func): nkey, nval = expand_eav_filter(self.model, key, value) if nkey in nkwargs: - # Add filter to check if matching entity_id is in the previous queryset with same nkey + # Add filter to check if matching entity_id is + # in the previous queryset with same nkey nkwargs[nkey] = nval.filter( - entity_id__in=nkwargs[nkey].values_list('entity_id', flat=True) + entity_id__in=nkwargs[nkey].values_list("entity_id", flat=True), ).distinct() else: nkwargs.update({nkey: nval}) @@ -229,27 +229,27 @@ def expand_eav_filter(model_cls, key, value): key = 'eav_values__in' value = Values.objects.filter(value_int=5, attribute__slug='height') """ - fields = key.split('__') - config_cls = getattr(model_cls, '_eav_config_cls', None) + fields = key.split("__") + config_cls = getattr(model_cls, "_eav_config_cls", None) if len(fields) > 1 and config_cls and fields[0] == config_cls.eav_attr: slug = fields[1] gr_name = config_cls.generic_relation_attr datatype = Attribute.objects.get(slug=slug).datatype - value_key = '' + value_key = "" if datatype == Attribute.TYPE_ENUM and not isinstance(value, EnumValue): - lookup = '__value__{}'.format(fields[2]) if len(fields) > 2 else '__value' - value_key = 'value_{}{}'.format(datatype, lookup) + lookup = f"__value__{fields[2]}" if len(fields) > 2 else "__value" # noqa: PLR2004 + value_key = f"value_{datatype}{lookup}" elif datatype == Attribute.TYPE_OBJECT: - value_key = 'generic_value_id' + value_key = "generic_value_id" else: - lookup = '__{}'.format(fields[2]) if len(fields) > 2 else '' - value_key = 'value_{}{}'.format(datatype, lookup) - kwargs = {value_key: value, 'attribute__slug': slug} + lookup = f"__{fields[2]}" if len(fields) > 2 else "" # noqa: PLR2004 + value_key = f"value_{datatype}{lookup}" + kwargs = {value_key: value, "attribute__slug": slug} value = Value.objects.filter(**kwargs) - return '%s__in' % gr_name, value + return f"{gr_name}__in", value # Not an eav field, so keep as is return key, value @@ -266,7 +266,7 @@ class EavQuerySet(QuerySet): Pass *args* and *kwargs* through :func:`eav_filter`, then pass to the ``Manager`` filter method. """ - return super(EavQuerySet, self).filter(*args, **kwargs) + return super().filter(*args, **kwargs) @eav_filter def exclude(self, *args, **kwargs): @@ -274,7 +274,7 @@ class EavQuerySet(QuerySet): Pass *args* and *kwargs* through :func:`eav_filter`, then pass to the ``Manager`` exclude method. """ - return super(EavQuerySet, self).exclude(*args, **kwargs) + return super().exclude(*args, **kwargs) @eav_filter def get(self, *args, **kwargs): @@ -282,7 +282,7 @@ class EavQuerySet(QuerySet): Pass *args* and *kwargs* through :func:`eav_filter`, then pass to the ``Manager`` get method. """ - return super(EavQuerySet, self).get(*args, **kwargs) + return super().get(*args, **kwargs) def order_by(self, *fields): # Django only allows to order querysets by direct fields and @@ -292,20 +292,20 @@ class EavQuerySet(QuerySet): # This will be slow, of course. order_clauses = [] query_clause = self - config_cls = self.model._eav_config_cls + config_cls = self.model._eav_config_cls # noqa: SLF001 - for term in [t.split('__') for t in fields]: + for term in [t.split("__") for t in fields]: # Continue only for EAV attributes. - if len(term) == 2 and term[0] == config_cls.eav_attr: + if len(term) == 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004 # Retrieve Attribute over which the ordering is performed. try: attr = Attribute.objects.get(slug=term[1]) - except ObjectDoesNotExist: + except ObjectDoesNotExist as err: raise ObjectDoesNotExist( - 'Cannot find EAV attribute "{}"'.format(term[1]) - ) + f'Cannot find EAV attribute "{term[1]}"', + ) from err - field_name = 'value_%s' % attr.datatype + field_name = f"value_{attr.datatype}" pks_values = ( Value.objects.filter( @@ -318,12 +318,12 @@ class EavQuerySet(QuerySet): .order_by( # Order values by their value-field of # appropriate attribute data-type. - field_name + field_name, ) .values_list( # Retrieve only primary-keys of the entities # in the current queryset. - 'entity_id', + "entity_id", field_name, ) ) @@ -352,16 +352,16 @@ class EavQuerySet(QuerySet): order_clause = Case(*when_clauses, output_field=IntegerField()) - clause_name = '__'.join(term) + clause_name = "__".join(term) # Use when-clause to construct # custom order-by 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: + elif len(term) >= 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004 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 8d2d1d7..39f1b88 100644 --- a/eav/registry.py +++ b/eav/registry.py @@ -3,13 +3,12 @@ from django.contrib.contenttypes import fields as generic from django.db.models.signals import post_init, post_save, pre_save +from eav.logic.entity_pk import get_entity_pk_type from eav.managers import EntityManager from eav.models import Attribute, Entity, Value -from eav.logic.entity_pk import get_entity_pk_type - -class EavConfig(object): +class EavConfig: """ The default ``EavConfig`` class used if it is not overridden on registration. This is where all the default eav attribute names are defined. @@ -29,10 +28,10 @@ class EavConfig(object): if not overridden, it is not possible to query Values by Entities. """ - manager_attr = 'objects' + manager_attr = "objects" manager_only = False - eav_attr = 'eav' - generic_relation_attr = 'eav_values' + eav_attr = "eav" + generic_relation_attr = "eav_values" generic_relation_related_name = None @classmethod @@ -44,7 +43,7 @@ class EavConfig(object): return Attribute.objects.all() -class Registry(object): +class Registry: """ Handles registration through the :meth:`register` and :meth:`unregister` methods. @@ -59,14 +58,14 @@ class Registry(object): .. note:: Multiple registrations for the same entity are harmlessly ignored. """ - if hasattr(model_cls, '_eav_config_cls'): + if hasattr(model_cls, "_eav_config_cls"): return if config_cls is EavConfig or config_cls is None: - config_cls = type("%sConfig" % model_cls.__name__, (EavConfig,), {}) + config_cls = type(f"{model_cls.__name__}Config", (EavConfig,), {}) # set _eav_config_cls on the model so we can access it there - setattr(model_cls, '_eav_config_cls', config_cls) + model_cls._eav_config_cls = config_cls reg = Registry(model_cls) reg._register_self() @@ -79,19 +78,19 @@ class Registry(object): .. note:: Unregistering a class not already registered is harmlessly ignored. """ - if not getattr(model_cls, '_eav_config_cls', None): + if not getattr(model_cls, "_eav_config_cls", None): return reg = Registry(model_cls) reg._unregister_self() - delattr(model_cls, '_eav_config_cls') + delattr(model_cls, "_eav_config_cls") @staticmethod def attach_eav_attr(sender, *args, **kwargs): """ Attach EAV Entity toolkit to an instance after init. """ - instance = kwargs['instance'] + instance = kwargs["instance"] config_cls = instance.__class__._eav_config_cls setattr(instance, config_cls.eav_attr, Entity(instance)) @@ -147,9 +146,10 @@ class Registry(object): self.model_cls._meta._expire_cache() delattr(self.model_cls, self.config_cls.manager_attr) - if hasattr(self.config_cls, 'old_mgr'): + if hasattr(self.config_cls, "old_mgr"): self.config_cls.old_mgr.contribute_to_class( - self.model_cls, self.config_cls.manager_attr + self.model_cls, + self.config_cls.manager_attr, ) def _attach_signals(self): @@ -181,7 +181,7 @@ class Registry(object): generic_relation = generic.GenericRelation( Value, object_id_field=get_entity_pk_type(self.model_cls), - content_type_field='entity_ct', + content_type_field="entity_ct", related_query_name=rel_name, ) generic_relation.contribute_to_class(self.model_cls, gr_name) diff --git a/eav/validators.py b/eav/validators.py index b6541b8..4518021 100644 --- a/eav/validators.py +++ b/eav/validators.py @@ -23,7 +23,7 @@ def validate_text(value): Raises ``ValidationError`` unless *value* type is ``str`` or ``unicode`` """ if not isinstance(value, str): - raise ValidationError(_(u"Must be str or unicode")) + raise ValidationError(_("Must be str or unicode")) def validate_float(value): @@ -32,8 +32,8 @@ def validate_float(value): """ try: float(value) - except ValueError: - raise ValidationError(_(u"Must be a float")) + except ValueError as err: + raise ValidationError(_("Must be a float")) from err def validate_int(value): @@ -42,8 +42,8 @@ def validate_int(value): """ try: int(value) - except ValueError: - raise ValidationError(_(u"Must be an integer")) + except ValueError as err: + raise ValidationError(_("Must be an integer")) from err def validate_date(value): @@ -52,9 +52,10 @@ def validate_date(value): or ``date`` """ if not isinstance(value, datetime.datetime) and not isinstance( - value, datetime.date + value, + datetime.date, ): - raise ValidationError(_(u"Must be a date or datetime")) + raise ValidationError(_("Must be a date or datetime")) def validate_bool(value): @@ -62,7 +63,7 @@ def validate_bool(value): Raises ``ValidationError`` unless *value* type is ``bool`` """ if not isinstance(value, bool): - raise ValidationError(_(u"Must be a boolean")) + raise ValidationError(_("Must be a boolean")) def validate_object(value): @@ -71,10 +72,10 @@ def validate_object(value): django model instance. """ if not isinstance(value, models.Model): - raise ValidationError(_(u"Must be a django model object instance")) + raise ValidationError(_("Must be a django model object instance")) if not value.pk: - raise ValidationError(_(u"Model has not been saved yet")) + raise ValidationError(_("Model has not been saved yet")) def validate_enum(value): @@ -85,7 +86,7 @@ def validate_enum(value): from eav.models import EnumValue if isinstance(value, EnumValue) and not value.pk: - raise ValidationError(_(u"EnumValue has not been saved yet")) + raise ValidationError(_("EnumValue has not been saved yet")) def validate_json(value): @@ -96,9 +97,9 @@ def validate_json(value): if isinstance(value, str): value = json.loads(value) if not isinstance(value, dict): - raise ValidationError(_(u"Must be a JSON Serializable object")) - except ValueError: - raise ValidationError(_(u"Must be a JSON Serializable object")) + raise ValidationError(_("Must be a JSON Serializable object")) + except ValueError as err: + raise ValidationError(_("Must be a JSON Serializable object")) from err def validate_csv(value): @@ -108,4 +109,4 @@ def validate_csv(value): if isinstance(value, str): value = value.split(";") if not isinstance(value, list): - raise ValidationError(_(u"Must be Comma-Separated-Value.")) + raise ValidationError(_("Must be Comma-Separated-Value.")) diff --git a/eav/widgets.py b/eav/widgets.py index 30d862e..3e49d90 100644 --- a/eav/widgets.py +++ b/eav/widgets.py @@ -2,7 +2,7 @@ from django.core import validators from django.core.exceptions import ValidationError from django.forms.widgets import Textarea -EMPTY_VALUES = validators.EMPTY_VALUES + ('[]',) +EMPTY_VALUES = (*validators.EMPTY_VALUES, "[]") class CSVWidget(Textarea): @@ -12,11 +12,11 @@ class CSVWidget(Textarea): """Prepare value before effectively render widget""" if value in EMPTY_VALUES: return "" - elif isinstance(value, str): + if isinstance(value, str): return value - elif isinstance(value, list): + if isinstance(value, list): return ";".join(value) - raise ValidationError('Invalid format.') + raise ValidationError("Invalid format.") def render(self, name, value, **kwargs): value = self.prep_value(value) @@ -31,11 +31,9 @@ class CSVWidget(Textarea): key, we need to loop through each field checking if the eav attribute exists with the given 'name'. """ - widget_value = None - for data_value in data: - try: - widget_value = getattr(data.get(data_value), name) - except AttributeError: - pass # noqa: WPS420 + for data_value in data.values(): + widget_value = getattr(data_value, name, None) + if widget_value is not None: + return widget_value - return widget_value + return None diff --git a/manage.py b/manage.py index b0b78e0..55f825f 100755 --- a/manage.py +++ b/manage.py @@ -13,19 +13,19 @@ def main() -> None: 2. Warns if Django is not installed 3. Executes any given command """ - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") try: - from django.core import management # noqa: WPS433 - except ImportError: + from django.core import management + except ImportError as err: raise ImportError( "Couldn't import Django. Are you sure it's installed and " - + 'available on your PYTHONPATH environment variable? Did you ' - + 'forget to activate a virtual environment?', - ) + + "available on your PYTHONPATH environment variable? Did you " + + "forget to activate a virtual environment?", + ) from err management.execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/test_project/models.py b/test_project/models.py index 00e0078..338a3ec 100644 --- a/test_project/models.py +++ b/test_project/models.py @@ -1,16 +1,11 @@ -import sys import uuid - -if sys.version_info >= (3, 8): - from typing import Final, final -else: - from typing_extensions import Final, final +from typing import Final, final from django.db import models from eav.decorators import register_eav -from eav.models import EAVModelMeta from eav.managers import EntityManager +from eav.models import EAVModelMeta #: Constants MAX_CHARFIELD_LEN: Final = 254 @@ -19,10 +14,10 @@ MAX_CHARFIELD_LEN: Final = 254 class TestBase(models.Model): """Base class for test models.""" - class Meta(object): + class Meta: """Define common options.""" - app_label = 'test_project' + app_label = "test_project" abstract = True @@ -62,7 +57,8 @@ class DoctorSubstringManager(models.Manager): substring (str): The substring to search for in the doctor's name. Returns: - models.QuerySet: A QuerySet of doctors whose names contain the specified substring. + models.QuerySet: A QuerySet of doctors whose names contain the + specified substring. """ return self.filter(name__icontains=substring) @@ -78,13 +74,16 @@ class Doctor(TestBase): objects = DoctorManager() substrings = DoctorSubstringManager() + def __str__(self): + return self.name + @final class Patient(TestBase): name = models.CharField(max_length=MAX_CHARFIELD_LEN) email = models.EmailField(max_length=MAX_CHARFIELD_LEN, blank=True) example = models.ForeignKey( - 'ExampleModel', + "ExampleModel", null=True, blank=True, on_delete=models.PROTECT, @@ -102,7 +101,7 @@ class Encounter(TestBase): patient = models.ForeignKey(Patient, on_delete=models.PROTECT) def __str__(self): - return '%s: encounter num %d' % (self.patient, self.num) + return "%s: encounter num %d" % (self.patient, self.num) def __repr__(self): return self.name @@ -113,7 +112,7 @@ class Encounter(TestBase): class ExampleModel(TestBase): name = models.CharField(max_length=MAX_CHARFIELD_LEN) - def __unicode__(self): + def __str__(self): return self.name @@ -123,7 +122,7 @@ class M2MModel(TestBase): name = models.CharField(max_length=MAX_CHARFIELD_LEN) models = models.ManyToManyField(ExampleModel) - def __unicode__(self): + def __str__(self): return self.name diff --git a/test_project/settings.py b/test_project/settings.py index 07f7f72..47cb3b2 100644 --- a/test_project/settings.py +++ b/test_project/settings.py @@ -1,5 +1,6 @@ +from __future__ import annotations + from pathlib import Path -from typing import List # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).parent.parent @@ -9,51 +10,51 @@ BASE_DIR = Path(__file__).parent.parent # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'secret!' # noqa: S105 +SECRET_KEY = "secret!" # noqa: S105 # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS: List[str] = [] +ALLOWED_HOSTS: list[str] = [] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", # Test Project: - 'test_project.apps.TestAppConfig', + "test_project.apps.TestAppConfig", # Our app: - 'eav', + "eav", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, @@ -64,15 +65,15 @@ TEMPLATES = [ # https://docs.djangoproject.com/en/3.1/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", }, } -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' -EAV2_PRIMARY_KEY_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" +EAV2_PRIMARY_KEY_FIELD = "django.db.models.AutoField" # Password validation @@ -84,9 +85,9 @@ AUTH_PASSWORD_VALIDATORS = [] # Internationalization # https://docs.djangoproject.com/en/3.1/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -98,4 +99,4 @@ USE_TZ = False # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.1/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 2044256..56b6d16 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -27,22 +27,22 @@ else: class Attributes(TestCase): def setUp(self): class EncounterEavConfig(EavConfig): - manager_attr = 'eav_objects' - eav_attr = 'eav_field' - generic_relation_attr = 'encounter_eav_values' - generic_relation_related_name = 'encounters' + manager_attr = "eav_objects" + eav_attr = "eav_field" + generic_relation_attr = "encounter_eav_values" + generic_relation_related_name = "encounters" @classmethod def get_attributes(cls, instance=None): - return Attribute.objects.filter(slug__contains='a') + return Attribute.objects.filter(slug__contains="a") eav.register(Encounter, EncounterEavConfig) eav.register(Patient) - Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT) - Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT) + Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT) + Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT) def tearDown(self): eav.unregister(Encounter) @@ -53,14 +53,14 @@ class Attributes(TestCase): self.assertEqual(Encounter._eav_config_cls.get_attributes().count(), 1) def test_duplicate_attributs(self): - ''' + """ Ensure that no two Attributes with the same slug can exist. - ''' + """ with self.assertRaises(ValidationError): - Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT) def test_setting_attributes(self): - p = Patient.objects.create(name='Jon') + p = Patient.objects.create(name="Jon") e = Encounter.objects.create(patient=p, num=1) p.eav.age = 3 @@ -73,7 +73,7 @@ class Attributes(TestCase): t.eav.age = 6 t.eav.height = 10 t.save() - p = Patient.objects.get(name='Jon') + p = Patient.objects.get(name="Jon") self.assertEqual(p.eav.age, 3) self.assertEqual(p.eav.height, 2.3) e = Encounter.objects.get(num=1) @@ -96,20 +96,21 @@ class Attributes(TestCase): eav.unregister(Encounter) eav.register(Encounter, EncounterEavConfig) - p = Patient.objects.create(name='Jon') + p = Patient.objects.create(name="Jon") e = Encounter.objects.create(patient=p, num=1) with self.assertRaises(IllegalAssignmentException): - e.eav.color = 'red' + e.eav.color = "red" e.save() def test_uuid_pk(self): """Tests for when model pk is UUID.""" - d1 = Doctor.objects.create(name='Lu') - d1.eav.age = 10 + expected_age = 10 + d1 = Doctor.objects.create(name="Lu") + d1.eav.age = expected_age d1.save() - assert d1.eav.age == 10 + assert d1.eav.age == expected_age # Validate repr of Value for an entity with a UUID PK v1 = Value.objects.filter(entity_uuid=d1.pk).first() @@ -119,7 +120,7 @@ class Attributes(TestCase): def test_big_integer(self): """Tests an integer larger than 32-bit a value.""" big_num = 3147483647 - patient = Patient.objects.create(name='Jon') + patient = Patient.objects.create(name="Jon") patient.eav.age = big_num patient.save() @@ -169,5 +170,7 @@ class TestAttributeModel(django.TestCase): def test_attribute_create_with_invalid_slug(): with pytest.raises(ValidationError): Attribute.objects.create( - name="Test Attribute", slug="123-invalid", datatype=Attribute.TYPE_TEXT + name="Test Attribute", + slug="123-invalid", + datatype=Attribute.TYPE_TEXT, ) diff --git a/tests/test_data_validation.py b/tests/test_data_validation.py index 66fab18..2bfe32f 100644 --- a/tests/test_data_validation.py +++ b/tests/test_data_validation.py @@ -30,7 +30,7 @@ class DataValidation(TestCase): p.save() Attribute.objects.create( - name='Weight', datatype=Attribute.TYPE_INT, required=True + name='Weight', datatype=Attribute.TYPE_INT, required=True, ) p.eav.age = 6 self.assertRaises(ValidationError, p.save) @@ -43,10 +43,10 @@ class DataValidation(TestCase): def test_create_required_field(self): Attribute.objects.create( - name='Weight', datatype=Attribute.TYPE_INT, required=True + name='Weight', datatype=Attribute.TYPE_INT, required=True, ) self.assertRaises( - ValidationError, Patient.objects.create, name='Joe', eav__age=5 + ValidationError, Patient.objects.create, name='Joe', eav__age=5, ) self.assertEqual(Patient.objects.count(), 0) self.assertEqual(Value.objects.count(), 0) @@ -57,7 +57,7 @@ class DataValidation(TestCase): def test_validation_error_create(self): self.assertRaises( - ValidationError, Patient.objects.create, name='Joe', eav__age='df' + ValidationError, Patient.objects.create, name='Joe', eav__age='df', ) self.assertEqual(Patient.objects.count(), 0) self.assertEqual(Value.objects.count(), 0) @@ -143,7 +143,7 @@ class DataValidation(TestCase): ynu.values.add(no) ynu.values.add(unkown) Attribute.objects.create( - name='Fever', datatype=Attribute.TYPE_ENUM, enum_group=ynu + name='Fever', datatype=Attribute.TYPE_ENUM, enum_group=ynu, ) p = Patient.objects.create(name='Joe') @@ -199,5 +199,5 @@ class DataValidation(TestCase): p.eav.multi = "one;two;three" p.save() self.assertEqual( - Patient.objects.get(pk=p.pk).eav.multi, ["one", "two", "three"] + Patient.objects.get(pk=p.pk).eav.multi, ["one", "two", "three"], ) diff --git a/tests/test_forms.py b/tests/test_forms.py index f639885..38460a7 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -1,5 +1,3 @@ -import sys - import pytest from django.contrib.admin.sites import AdminSite from django.core.handlers.base import BaseHandler @@ -8,9 +6,9 @@ from django.test import TestCase from django.test.client import RequestFactory import eav -from eav.admin import * +from eav.admin import BaseEntityAdmin from eav.forms import BaseDynamicEntityForm -from eav.models import Attribute +from eav.models import Attribute, EnumGroup, EnumValue from test_project.models import ExampleModel, M2MModel, Patient @@ -20,15 +18,7 @@ class MockRequest(RequestFactory): request = RequestFactory.request(self, **request) handler = BaseHandler() handler.load_middleware() - # BaseHandler_request_middleware is not set in Django2.0 - # and removed in Django2.1 - if sys.version_info[0] < 2: - for middleware_method in handler._request_middleware: - if middleware_method(request): - raise Exception( - "Couldn't create request mock object - " - "request middleware returned a response" - ) + return request @@ -48,49 +38,51 @@ request.user = MockSuperUser() class PatientForm(ModelForm): class Meta: model = Patient - fields = '__all__' + fields = ("name", "email", "example") class PatientDynamicForm(BaseDynamicEntityForm): class Meta: model = Patient - fields = '__all__' + fields = ("name", "email", "example") class M2MModelForm(ModelForm): class Meta: model = M2MModel - fields = '__all__' + fields = ("name", "models") class Forms(TestCase): def setUp(self): eav.register(Patient) - Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT) + Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT) - self.female = EnumValue.objects.create(value='Female') - self.male = EnumValue.objects.create(value='Male') - gender_group = EnumGroup.objects.create(name='Gender') + self.female = EnumValue.objects.create(value="Female") + self.male = EnumValue.objects.create(value="Male") + gender_group = EnumGroup.objects.create(name="Gender") gender_group.values.add(self.female, self.male) Attribute.objects.create( - name='gender', datatype=Attribute.TYPE_ENUM, enum_group=gender_group + name="gender", + datatype=Attribute.TYPE_ENUM, + enum_group=gender_group, ) - self.instance = Patient.objects.create(name='Jim Morrison') + self.instance = Patient.objects.create(name="Jim Morrison") def test_valid_submit(self): - self.instance.eav.color = 'Blue' + self.instance.eav.color = "Blue" form = PatientForm(self.instance.__dict__, instance=self.instance) jim = form.save() - self.assertEqual(jim.eav.color, 'Blue') + self.assertEqual(jim.eav.color, "Blue") def test_invalid_submit(self): - form = PatientForm(dict(color='Blue'), instance=self.instance) + form = PatientForm({"color": "Blue"}, instance=self.instance) with self.assertRaises(ValueError): - jim = form.save() + form.save() def test_valid_enums(self): self.instance.eav.gender = self.female @@ -100,41 +92,41 @@ class Forms(TestCase): self.assertEqual(rose.eav.gender, self.female) def test_m2m(self): - m2mmodel = M2MModel.objects.create(name='name') - model = ExampleModel.objects.create(name='name') - form = M2MModelForm(dict(name='Lorem', models=[model.pk]), instance=m2mmodel) + m2mmodel = M2MModel.objects.create(name="name") + model = ExampleModel.objects.create(name="name") + form = M2MModelForm({"name": "Lorem", "models": [model.pk]}, instance=m2mmodel) form.save() self.assertEqual(len(m2mmodel.models.all()), 1) -@pytest.fixture() +@pytest.fixture def patient() -> Patient: """Return an eav enabled Patient instance.""" eav.register(Patient) - return Patient.objects.create(name='Jim Morrison') + return Patient.objects.create(name="Jim Morrison") -@pytest.fixture() +@pytest.fixture def create_attributes() -> None: """Create some Attributes to use for testing.""" - Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT) + Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT) -@pytest.mark.django_db() +@pytest.mark.django_db @pytest.mark.parametrize( - 'csv_data, separator', + ("csv_data", "separator"), [ - ('', ';'), - ('justone', ','), - ('one;two;three', ';'), - ('alpha,beta,gamma', ','), - (None, ','), + ("", ";"), + ("justone", ","), + ("one;two;three", ";"), + ("alpha,beta,gamma", ","), + (None, ","), ], ) def test_csvdynamicform(patient, csv_data, separator) -> None: """Ensure that a TYPE_CSV field works correctly with forms.""" - Attribute.objects.create(name='csv', datatype=Attribute.TYPE_CSV) + Attribute.objects.create(name="csv", datatype=Attribute.TYPE_CSV) patient.eav.csv = csv_data patient.save() patient.refresh_from_db() @@ -143,7 +135,7 @@ def test_csvdynamicform(patient, csv_data, separator) -> None: patient.__dict__, instance=patient, ) - form.fields['csv'].separator = separator + form.fields["csv"].separator = separator assert form.is_valid() jim = form.save() @@ -151,7 +143,7 @@ def test_csvdynamicform(patient, csv_data, separator) -> None: assert jim.eav.csv == expected_result -@pytest.mark.django_db() +@pytest.mark.django_db def test_csvdynamicform_empty(patient) -> None: """Test to ensure an instance with no eav values is correct.""" form = PatientDynamicForm( @@ -162,29 +154,31 @@ def test_csvdynamicform_empty(patient) -> None: assert form.save() -@pytest.mark.django_db() -@pytest.mark.usefixtures('create_attributes') -@pytest.mark.parametrize('define_fieldsets', (True, False)) +@pytest.mark.django_db +@pytest.mark.usefixtures("create_attributes") +@pytest.mark.parametrize("define_fieldsets", [True, False]) def test_entity_admin_form(patient, define_fieldsets): """Test the BaseEntityAdmin form setup and dynamic fieldsets handling.""" admin = BaseEntityAdmin(Patient, AdminSite()) - admin.readonly_fields = ('email',) + admin.readonly_fields = ("email",) admin.form = BaseDynamicEntityForm + expected_fieldsets = 2 if define_fieldsets: # Use all fields in Patient model admin.fieldsets = ( - (None, {'fields': ['name', 'example']}), - ('Contact Info', {'fields': ['email']}), + (None, {"fields": ["name", "example"]}), + ("Contact Info", {"fields": ["email"]}), ) + expected_fieldsets = 3 view = admin.change_view(request, str(patient.pk)) - adminform = view.context_data['adminform'] + adminform = view.context_data["adminform"] # Count the total fields in fieldsets total_fields = sum( - len(fields_info['fields']) for _, fields_info in adminform.fieldsets + len(fields_info["fields"]) for _, fields_info in adminform.fieldsets ) # 3 for 'name', 'email', 'example' @@ -193,27 +187,27 @@ def test_entity_admin_form(patient, define_fieldsets): assert total_fields == expected_fields_count # Ensure our fieldset count is correct - if define_fieldsets: - assert len(adminform.fieldsets) == 3 - else: - assert len(adminform.fieldsets) == 2 + assert len(adminform.fieldsets) == expected_fieldsets -@pytest.mark.django_db() +@pytest.mark.django_db def test_entity_admin_form_no_attributes(patient): """Test the BaseEntityAdmin form with no Attributes created.""" admin = BaseEntityAdmin(Patient, AdminSite()) - admin.readonly_fields = ('email',) + admin.readonly_fields = ("email",) admin.form = BaseDynamicEntityForm + # Only fields defined in Patient model + expected_fields = 3 + view = admin.change_view(request, str(patient.pk)) - adminform = view.context_data['adminform'] + adminform = view.context_data["adminform"] # Count the total fields in fieldsets total_fields = sum( - len(fields_info['fields']) for _, fields_info in adminform.fieldsets + len(fields_info["fields"]) for _, fields_info in adminform.fieldsets ) # 3 for 'name', 'email', 'example' - assert total_fields == 3 + assert total_fields == expected_fields diff --git a/tests/test_logic.py b/tests/test_logic.py index 7ed06e7..1104c48 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -38,7 +38,7 @@ def test_generate_slug_uniqueness() -> None: generated_slugs[input_str] = slug assert len(generated_slugs) == len( - inputs + inputs, ), "Number of unique slugs doesn't match number of inputs" @@ -72,5 +72,5 @@ def test_generate_slug_valid_identifier(input_str: str) -> None: slug = generate_slug(input_str) assert slug.isidentifier(), ( f"Generated slug '{slug}' for input '{input_str}' " - "is not a valid Python identifier" + + "is not a valid Python identifier" ) diff --git a/tests/test_misc_models.py b/tests/test_misc_models.py index 92fd94a..da0d597 100644 --- a/tests/test_misc_models.py +++ b/tests/test_misc_models.py @@ -6,7 +6,7 @@ from eav.models import Attribute, EnumGroup, EnumValue, Value from test_project.models import Patient -@pytest.fixture() +@pytest.fixture def enumgroup(db): """Sample `EnumGroup` object for testing.""" test_group = EnumGroup.objects.create(name='Yes / No') @@ -19,14 +19,14 @@ def enumgroup(db): def test_enumgroup_display(enumgroup): """Test repr() and str() of EnumGroup.""" - assert ''.format(enumgroup.name) == repr(enumgroup) + assert f'' == repr(enumgroup) assert str(enumgroup) == str(enumgroup.name) def test_enumvalue_display(enumgroup): """Test repr() and str() of EnumValue.""" test_value = enumgroup.values.first() - assert ''.format(test_value.value) == repr(test_value) + assert f'' == repr(test_value) assert str(test_value) == test_value.value @@ -36,7 +36,7 @@ class MiscModels(TestCase): def test_attribute_help_text(self): desc = 'Patient Age' a = Attribute.objects.create( - name='age', description=desc, datatype=Attribute.TYPE_INT + name='age', description=desc, datatype=Attribute.TYPE_INT, ) self.assertEqual(a.help_text, desc) @@ -56,7 +56,7 @@ class MiscModels(TestCase): ynu.values.add(yes) ynu.values.add(no) Attribute.objects.create( - name='is_patient', datatype=Attribute.TYPE_ENUM, enum_group=ynu + name='is_patient', datatype=Attribute.TYPE_ENUM, enum_group=ynu, ) eav.register(Patient) p = Patient.objects.create(name='Joe') diff --git a/tests/test_natural_keys.py b/tests/test_natural_keys.py index 5e3b97a..a705504 100644 --- a/tests/test_natural_keys.py +++ b/tests/test_natural_keys.py @@ -1,7 +1,8 @@ from django.test import TestCase + +import eav from eav.models import Attribute, EnumGroup, EnumValue, Value from test_project.models import Patient -import eav class ModelTest(TestCase): @@ -38,7 +39,7 @@ class ModelTest(TestCase): enum_group = EnumGroup.objects.first() enum_group_natural_key = enum_group.natural_key() enum_group_retrieved_model = EnumGroup.objects.get_by_natural_key( - *enum_group_natural_key + *enum_group_natural_key, ) self.assertEqual(enum_group_retrieved_model, enum_group) @@ -46,6 +47,6 @@ class ModelTest(TestCase): enum_value = EnumValue.objects.first() enum_value_natural_key = enum_value.natural_key() enum_value_retrieved_model = EnumValue.objects.get_by_natural_key( - *enum_value_natural_key + *enum_value_natural_key, ) self.assertEqual(enum_value_retrieved_model, enum_value) diff --git a/tests/test_primary_key_format.py b/tests/test_primary_key_format.py index 4b9410c..43f6000 100644 --- a/tests/test_primary_key_format.py +++ b/tests/test_primary_key_format.py @@ -1,9 +1,8 @@ import uuid -import pytest from django.db import models -from eav.logic.object_pk import get_pk_format +from eav.logic.object_pk import _DEFAULT_CHARFIELD_LEN, get_pk_format def test_get_uuid_primary_key(settings) -> None: @@ -21,7 +20,7 @@ def test_get_char_primary_key(settings) -> None: assert isinstance(primary_field, models.CharField) assert primary_field.primary_key assert not primary_field.editable - assert primary_field.max_length == 40 + assert primary_field.max_length == _DEFAULT_CHARFIELD_LEN def test_get_default_primary_key(settings) -> None: diff --git a/tests/test_queries.py b/tests/test_queries.py index cbc73a3..5a62757 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import pytest from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.db.models import Q from django.db.utils import NotSupportedError @@ -6,7 +9,7 @@ from django.test import TestCase import eav from eav.models import Attribute, EnumGroup, EnumValue, Value from eav.registry import EavConfig -from test_project.models import Encounter, Patient, ExampleModel +from test_project.models import Encounter, ExampleModel, Patient class Queries(TestCase): @@ -14,32 +17,34 @@ class Queries(TestCase): eav.register(Encounter) eav.register(Patient) - Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT) - Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='city', datatype=Attribute.TYPE_TEXT) - Attribute.objects.create(name='country', datatype=Attribute.TYPE_TEXT) - Attribute.objects.create(name='extras', datatype=Attribute.TYPE_JSON) - Attribute.objects.create(name='illness', datatype=Attribute.TYPE_CSV) + Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT) + Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="city", datatype=Attribute.TYPE_TEXT) + Attribute.objects.create(name="country", datatype=Attribute.TYPE_TEXT) + Attribute.objects.create(name="extras", datatype=Attribute.TYPE_JSON) + Attribute.objects.create(name="illness", datatype=Attribute.TYPE_CSV) - self.yes = EnumValue.objects.create(value='yes') - self.no = EnumValue.objects.create(value='no') - self.unknown = EnumValue.objects.create(value='unknown') + self.yes = EnumValue.objects.create(value="yes") + self.no = EnumValue.objects.create(value="no") + self.unknown = EnumValue.objects.create(value="unknown") - ynu = EnumGroup.objects.create(name='Yes / No / Unknown') + ynu = EnumGroup.objects.create(name="Yes / No / Unknown") ynu.values.add(self.yes) ynu.values.add(self.no) ynu.values.add(self.unknown) Attribute.objects.create( - name='fever', datatype=Attribute.TYPE_ENUM, enum_group=ynu + name="fever", + datatype=Attribute.TYPE_ENUM, + enum_group=ynu, ) def tearDown(self): eav.unregister(Encounter) eav.unregister(Patient) - def init_data(self): + def init_data(self) -> None: yes = self.yes no = self.no @@ -47,24 +52,24 @@ class Queries(TestCase): # Name, age, fever, # city, country, extras # possible illness - ['Anne', 3, no, 'New York', 'USA', {"chills": "yes"}, "cold"], - ['Bob', 15, no, 'Bamako', 'Mali', {}, ""], + ["Anne", 3, no, "New York", "USA", {"chills": "yes"}, "cold"], + ["Bob", 15, no, "Bamako", "Mali", {}, ""], [ - 'Cyrill', + "Cyrill", 15, yes, - 'Kisumu', - 'Kenya', + "Kisumu", + "Kenya", {"chills": "yes", "headache": "no"}, "flu", ], - ['Daniel', 3, no, 'Nice', 'France', {"headache": "yes"}, "cold"], + ["Daniel", 3, no, "Nice", "France", {"headache": "yes"}, "cold"], [ - 'Eugene', + "Eugene", 2, yes, - 'France', - 'Nice', + "France", + "Nice", {"chills": "no", "headache": "yes"}, "flu;cold", ], @@ -82,26 +87,26 @@ class Queries(TestCase): ) def test_get_or_create_with_eav(self): - Patient.objects.get_or_create(name='Bob', eav__age=5) + Patient.objects.get_or_create(name="Bob", eav__age=5) self.assertEqual(Patient.objects.count(), 1) self.assertEqual(Value.objects.count(), 1) - Patient.objects.get_or_create(name='Bob', eav__age=5) + Patient.objects.get_or_create(name="Bob", eav__age=5) self.assertEqual(Patient.objects.count(), 1) self.assertEqual(Value.objects.count(), 1) - Patient.objects.get_or_create(name='Bob', eav__age=6) + Patient.objects.get_or_create(name="Bob", eav__age=6) self.assertEqual(Patient.objects.count(), 2) self.assertEqual(Value.objects.count(), 2) def test_get_or_create_with_defaults(self): """Tests EntityManager.get_or_create() with defaults keyword.""" - city_name = 'Tokyo' - email = 'mari@test.com' + city_name = "Tokyo" + email = "mari@test.com" p1, _ = Patient.objects.get_or_create( - name='Mari', + name="Mari", eav__age=27, defaults={ - 'email': email, - 'eav__city': city_name, + "email": email, + "eav__city": city_name, }, ) assert Patient.objects.count() == 1 @@ -109,175 +114,258 @@ class Queries(TestCase): assert p1.eav.city == city_name def test_get_with_eav(self): - p1, _ = Patient.objects.get_or_create(name='Bob', eav__age=6) + p1, _ = Patient.objects.get_or_create(name="Bob", eav__age=6) self.assertEqual(Patient.objects.get(eav__age=6), p1) - Patient.objects.create(name='Fred', eav__age=6) + Patient.objects.create(name="Fred", eav__age=6) self.assertRaises( - MultipleObjectsReturned, lambda: Patient.objects.get(eav__age=6) + MultipleObjectsReturned, + lambda: Patient.objects.get(eav__age=6), ) - def test_filtering_on_normal_and_eav_fields(self): + def test_no_results_for_contradictory_conditions(self) -> None: + """Test that contradictory conditions return no results.""" self.init_data() - - # Check number of objects in DB. - self.assertEqual(Patient.objects.count(), 5) - self.assertEqual(Value.objects.count(), 29) - - # Nobody q1 = Q(eav__fever=self.yes) & Q(eav__fever=self.no) p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 0) - # Anne, Daniel + # Should return no patients due to contradictory conditions + assert p.count() == 0 + + def test_filtering_on_numeric_eav_fields(self) -> None: + """Test filtering on numeric EAV fields.""" + self.init_data() q1 = Q(eav__age__gte=3) # Everyone except Eugene q2 = Q(eav__age__lt=15) # Anne, Daniel, Eugene p = Patient.objects.filter(q2 & q1) - self.assertEqual(p.count(), 2) - # Anne - q1 = Q(eav__city__contains='Y') & Q(eav__fever='no') + # Should return Anne and Daniel + assert p.count() == 2 + + def test_filtering_on_text_and_boolean_eav_fields(self) -> None: + """Test filtering on text and boolean EAV fields.""" + self.init_data() + q1 = Q(eav__city__contains="Y") & Q(eav__fever="no") q2 = Q(eav__age=3) p = Patient.objects.filter(q1 & q2) - self.assertEqual(p.count(), 1) - # Anne - q1 = Q(eav__city__contains='Y') & Q(eav__fever=self.no) + # Should return only Anne + assert p.count() == 1 + + def test_filtering_with_enum_eav_fields(self) -> None: + """Test filtering with enum EAV fields.""" + self.init_data() + q1 = Q(eav__city__contains="Y") & Q(eav__fever=self.no) q2 = Q(eav__age=3) p = Patient.objects.filter(q1 & q2) - self.assertEqual(p.count(), 1) - # Anne, Daniel - q1 = Q(eav__city__contains='Y', eav__fever=self.no) - q2 = Q(eav__city='Nice') + # Should return only Anne + assert p.count() == 1 + + def test_complex_query_with_or_conditions(self) -> None: + """Test complex query with OR conditions.""" + self.init_data() + q1 = Q(eav__city__contains="Y", eav__fever=self.no) + q2 = Q(eav__city="Nice") q3 = Q(eav__age=3) p = Patient.objects.filter((q1 | q2) & q3) - self.assertEqual(p.count(), 2) - # Everyone + # Should return Anne and Daniel + assert p.count() == 2 + + def test_filtering_with_multiple_enum_values(self) -> None: + """Test filtering with multiple enum values.""" + self.init_data() q1 = Q(eav__fever=self.no) | Q(eav__fever=self.yes) p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 5) - # Anne, Bob, Daniel + # Should return all patients + assert p.count() == 5 + + def test_complex_query_with_multiple_conditions(self) -> None: + """Test complex query with multiple conditions.""" + self.init_data() q1 = Q(eav__fever=self.no) # Anne, Bob, Daniel q2 = Q(eav__fever=self.yes) # Cyrill, Eugene - q3 = Q(eav__country__contains='e') # Cyrill, Daniel, Eugene + q3 = Q(eav__country__contains="e") # Cyrill, Daniel, Eugene q4 = q2 & q3 # Cyrill, Daniel, Eugene q5 = (q1 | q4) & q1 # Anne, Bob, Daniel p = Patient.objects.filter(q5) - self.assertEqual(p.count(), 3) - # Everyone except Anne - q1 = Q(eav__city__contains='Y') + # Should return Anne, Bob, and Daniel + assert p.count() == 3 + + def test_excluding_with_eav_fields(self) -> None: + """Test excluding with EAV fields.""" + self.init_data() + q1 = Q(eav__city__contains="Y") p = Patient.objects.exclude(q1) - self.assertEqual(p.count(), 4) - # Anne, Bob, Daniel - q1 = Q(eav__city__contains='Y') + # Should return all patients except Anne + assert p.count() == 4 + + def test_filtering_with_or_conditions(self) -> None: + """Test filtering with OR conditions.""" + self.init_data() + q1 = Q(eav__city__contains="Y") q2 = Q(eav__fever=self.no) q3 = q1 | q2 p = Patient.objects.filter(q3) - self.assertEqual(p.count(), 3) - # Anne, Daniel + # Should return Anne, Bob, and Daniel + assert p.count() == 3 + + def test_filtering_on_single_eav_field(self) -> None: + """Test filtering on a single EAV field.""" + self.init_data() q1 = Q(eav__age=3) p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 2) - # Eugene - q1 = Q(name__contains='E', eav__fever=self.yes) + # Should return Anne and Daniel + assert p.count() == 2 + + def test_combining_normal_and_eav_fields(self) -> None: + """Test combining normal and EAV fields in a query.""" + self.init_data() + q1 = Q(name__contains="E", eav__fever=self.yes) p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 1) - # Extras: Chills - # Without + # Should return only Eugene + assert p.count() == 1 + + def test_filtering_on_json_eav_field(self) -> None: + """Test filtering on JSON EAV field.""" + self.init_data() q1 = Q(eav__extras__has_key="chills") p = Patient.objects.exclude(q1) - self.assertEqual(p.count(), 2) - # With + # Should return patients without 'chills' in extras + assert p.count() == 2 + q1 = Q(eav__extras__has_key="chills") p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 3) - # No chills + # Should return patients with 'chills' in extras + assert p.count() == 3 + q1 = Q(eav__extras__chills="no") p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 1) - # Has chills + # Should return patients with 'chills' set to 'no' + assert p.count() == 1 + q1 = Q(eav__extras__chills="yes") p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 2) - # Extras: Empty - # Yes + # Should return patients with 'chills' set to 'yes' + assert p.count() == 2 + + def test_filtering_on_empty_json_eav_field(self) -> None: + """Test filtering on empty JSON EAV field.""" + self.init_data() q1 = Q(eav__extras={}) p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 1) - # No + # Should return patients with empty extras + assert p.count() == 1 + q1 = Q(eav__extras={}) p = Patient.objects.exclude(q1) - self.assertEqual(p.count(), 4) - # Illness: - # Cold + # Should return patients with non-empty extras + assert p.count() == 4 + + def test_filtering_on_text_eav_field_with_icontains(self) -> None: + """Test filtering on text EAV field with icontains.""" + self.init_data() q1 = Q(eav__illness__icontains="cold") p = Patient.objects.exclude(q1) - self.assertEqual(p.count(), 2) - # Flu + # Should return patients without 'cold' in illness + assert p.count() == 2 + q1 = Q(eav__illness__icontains="flu") p = Patient.objects.exclude(q1) - self.assertEqual(p.count(), 3) - # Empty + # Should return patients without 'flu' in illness + assert p.count() == 3 + + def test_filtering_on_null_eav_field(self) -> None: + """Test filtering on null EAV field.""" + self.init_data() q1 = Q(eav__illness__isnull=False) p = Patient.objects.filter(~q1) - self.assertEqual(p.count(), 1) - def _order(self, ordering): + # Should return patients with null illness + assert p.count() == 1 + + def _order(self, ordering) -> list[str]: query = Patient.objects.all().order_by(*ordering) - return list(query.values_list('name', flat=True)) + return list(query.values_list("name", flat=True)) - def assert_order_by_results(self, eav_attr='eav'): - self.assertEqual( - ['Bob', 'Eugene', 'Cyrill', 'Anne', 'Daniel'], - self._order(['%s__city' % eav_attr]), - ) + def assert_order_by_results(self, eav_attr="eav") -> None: + """Test the ordering functionality of EAV attributes.""" + # Ordering by a single EAV attribute + assert self._order([f"{eav_attr}__city"]) == [ + "Bob", + "Eugene", + "Cyrill", + "Anne", + "Daniel", + ] - self.assertEqual( - ['Eugene', 'Anne', 'Daniel', 'Bob', 'Cyrill'], - self._order(['%s__age' % eav_attr, '%s__city' % eav_attr]), - ) + # Ordering by multiple EAV attributes + assert self._order([f"{eav_attr}__age", f"{eav_attr}__city"]) == [ + "Eugene", + "Anne", + "Daniel", + "Bob", + "Cyrill", + ] - self.assertEqual( - ['Eugene', 'Cyrill', 'Anne', 'Daniel', 'Bob'], - self._order(['%s__fever' % eav_attr, '%s__age' % eav_attr]), - ) + # Ordering by EAV attributes with different data types + assert self._order([f"{eav_attr}__fever", f"{eav_attr}__age"]) == [ + "Eugene", + "Cyrill", + "Anne", + "Daniel", + "Bob", + ] - self.assertEqual( - ['Eugene', 'Cyrill', 'Daniel', 'Bob', 'Anne'], - self._order(['%s__fever' % eav_attr, '-name']), - ) + # Combining EAV and regular model field ordering + assert self._order([f"{eav_attr}__fever", "-name"]) == [ + "Eugene", + "Cyrill", + "Daniel", + "Bob", + "Anne", + ] - self.assertEqual( - ['Eugene', 'Daniel', 'Cyrill', 'Bob', 'Anne'], - self._order(['-name', '%s__age' % eav_attr]), - ) + # Mixing regular and EAV field ordering + assert self._order(["-name", f"{eav_attr}__age"]) == [ + "Eugene", + "Daniel", + "Cyrill", + "Bob", + "Anne", + ] - self.assertEqual( - ['Anne', 'Bob', 'Cyrill', 'Daniel', 'Eugene'], - self._order(['example__name']), - ) + # Ordering by a related model field + assert self._order(["example__name"]) == [ + "Anne", + "Bob", + "Cyrill", + "Daniel", + "Eugene", + ] - with self.assertRaises(NotSupportedError): - Patient.objects.all().order_by('%s__first__second' % eav_attr) + # Error handling for unsupported nested EAV attributes + with pytest.raises(NotSupportedError): + Patient.objects.all().order_by(f"{eav_attr}__first__second") - with self.assertRaises(ObjectDoesNotExist): - Patient.objects.all().order_by('%s__nonsense' % eav_attr) + # Error handling for non-existent EAV attributes + with pytest.raises(ObjectDoesNotExist): + Patient.objects.all().order_by(f"{eav_attr}__nonsense") def test_order_by(self): self.init_data() @@ -291,11 +379,11 @@ class Queries(TestCase): self.init_data() eav.unregister(Patient) eav.register(Patient, config_cls=CustomConfig) - self.assert_order_by_results(eav_attr='data') + self.assert_order_by_results(eav_attr="data") def test_fk_filter(self): - e = ExampleModel.objects.create(name='test1') - p = Patient.objects.get_or_create(name='Beth', example=e)[0] + e = ExampleModel.objects.create(name="test1") + p = Patient.objects.get_or_create(name="Beth", example=e)[0] c = ExampleModel.objects.filter(patient=p) self.assertEqual(c.count(), 1) @@ -310,12 +398,12 @@ class Queries(TestCase): # Use the filter method with 3 EAV attribute conditions patients = Patient.objects.filter( - name='Anne', + name="Anne", eav__age=3, - eav__illness='cold', - eav__fever='no', + eav__illness="cold", + eav__fever="no", ) # Assert that the expected patient is returned self.assertEqual(len(patients), 1) - self.assertEqual(patients[0].name, 'Anne') + self.assertEqual(patients[0].name, "Anne") diff --git a/tests/test_registry.py b/tests/test_registry.py index 516d566..b7e39b5 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -22,72 +22,72 @@ class RegistryTests(TestCase): def register_encounter(self): class EncounterEav(EavConfig): - manager_attr = 'eav_objects' - eav_attr = 'eav_field' - generic_relation_attr = 'encounter_eav_values' - generic_relation_related_name = 'encounters' + manager_attr = "eav_objects" + eav_attr = "eav_field" + generic_relation_attr = "encounter_eav_values" + generic_relation_related_name = "encounters" @classmethod def get_attributes(cls): - return 'testing' + return "testing" eav.register(Encounter, EncounterEav) def test_registering_with_defaults(self): eav.register(Patient) - self.assertTrue(hasattr(Patient, '_eav_config_cls')) - self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects') + self.assertTrue(hasattr(Patient, "_eav_config_cls")) + self.assertEqual(Patient._eav_config_cls.manager_attr, "objects") self.assertFalse(Patient._eav_config_cls.manager_only) - self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav') - self.assertEqual(Patient._eav_config_cls.generic_relation_attr, 'eav_values') + self.assertEqual(Patient._eav_config_cls.eav_attr, "eav") + self.assertEqual(Patient._eav_config_cls.generic_relation_attr, "eav_values") self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, None) eav.unregister(Patient) def test_registering_overriding_defaults(self): eav.register(Patient) self.register_encounter() - self.assertTrue(hasattr(Patient, '_eav_config_cls')) - self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects') - self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav') + self.assertTrue(hasattr(Patient, "_eav_config_cls")) + self.assertEqual(Patient._eav_config_cls.manager_attr, "objects") + self.assertEqual(Patient._eav_config_cls.eav_attr, "eav") - self.assertTrue(hasattr(Encounter, '_eav_config_cls')) - self.assertEqual(Encounter._eav_config_cls.get_attributes(), 'testing') - self.assertEqual(Encounter._eav_config_cls.manager_attr, 'eav_objects') - self.assertEqual(Encounter._eav_config_cls.eav_attr, 'eav_field') + self.assertTrue(hasattr(Encounter, "_eav_config_cls")) + self.assertEqual(Encounter._eav_config_cls.get_attributes(), "testing") + self.assertEqual(Encounter._eav_config_cls.manager_attr, "eav_objects") + self.assertEqual(Encounter._eav_config_cls.eav_attr, "eav_field") eav.unregister(Patient) eav.unregister(Encounter) def test_registering_via_decorator_with_defaults(self): - self.assertTrue(hasattr(ExampleModel, '_eav_config_cls')) - self.assertEqual(ExampleModel._eav_config_cls.manager_attr, 'objects') - self.assertEqual(ExampleModel._eav_config_cls.eav_attr, 'eav') + self.assertTrue(hasattr(ExampleModel, "_eav_config_cls")) + self.assertEqual(ExampleModel._eav_config_cls.manager_attr, "objects") + self.assertEqual(ExampleModel._eav_config_cls.eav_attr, "eav") def test_register_via_metaclass_with_defaults(self): - self.assertTrue(hasattr(ExampleMetaclassModel, '_eav_config_cls')) - self.assertEqual(ExampleMetaclassModel._eav_config_cls.manager_attr, 'objects') - self.assertEqual(ExampleMetaclassModel._eav_config_cls.eav_attr, 'eav') + self.assertTrue(hasattr(ExampleMetaclassModel, "_eav_config_cls")) + self.assertEqual(ExampleMetaclassModel._eav_config_cls.manager_attr, "objects") + self.assertEqual(ExampleMetaclassModel._eav_config_cls.eav_attr, "eav") def test_unregistering(self): old_mgr = Patient.objects eav.register(Patient) - self.assertTrue(Patient.objects.__class__.__name__ == 'EntityManager') + self.assertTrue(Patient.objects.__class__.__name__ == "EntityManager") eav.unregister(Patient) - self.assertFalse(Patient.objects.__class__.__name__ == 'EntityManager') + self.assertFalse(Patient.objects.__class__.__name__ == "EntityManager") self.assertEqual(Patient.objects, old_mgr) - self.assertFalse(hasattr(Patient, '_eav_config_cls')) + self.assertFalse(hasattr(Patient, "_eav_config_cls")) def test_unregistering_via_decorator(self): - self.assertTrue(ExampleModel.objects.__class__.__name__ == 'EntityManager') + self.assertTrue(ExampleModel.objects.__class__.__name__ == "EntityManager") eav.unregister(ExampleModel) - self.assertFalse(ExampleModel.objects.__class__.__name__ == 'EntityManager') + self.assertFalse(ExampleModel.objects.__class__.__name__ == "EntityManager") def test_unregistering_via_metaclass(self): self.assertTrue( - ExampleMetaclassModel.objects.__class__.__name__ == 'EntityManager' + ExampleMetaclassModel.objects.__class__.__name__ == "EntityManager", ) eav.unregister(ExampleMetaclassModel) self.assertFalse( - ExampleMetaclassModel.objects.__class__.__name__ == 'EntityManager' + ExampleMetaclassModel.objects.__class__.__name__ == "EntityManager", ) def test_unregistering_unregistered_model_proceeds_silently(self): @@ -98,10 +98,10 @@ class RegistryTests(TestCase): eav.register(Patient) def test_doesnt_register_nonmodel(self): - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): @eav.decorators.register_eav() - class Foo(object): + class Foo: pass def test_model_without_local_managers(self): @@ -132,5 +132,5 @@ def test_default_manager_stays() -> None: # Explicity test this as for our test setup, we want to have a state where # the default manager is 'objects' - assert instance_meta.default_manager.name == 'objects' + assert instance_meta.default_manager.name == "objects" assert len(instance_meta.managers) == 2